아이템 48. 스트림 병렬화는 주의해서 적용하라. - ksw6169/effective-java GitHub Wiki

자바의 동시성 프로그래밍 지원

자바는 동시성 프로그래밍을 지원하기 위해 끊임없이 변화해왔다. 자바 7부터는 fork-join 프레임워크가 추가되었고, 자바 8부터는 parallel 메소드만 호출하면 파이프라인을 병렬 실행할 수 있는 스트림을 지원했다. 이처럼 동시성 프로그램을 작성하기가 점점 쉬워지고는 있지만 이를 올바르고 빠르게 작성하는 일은 여전히 어렵다.


예제 - 메르센 소수 출력

다음은 스트림을 사용해 처음 20개의 메르센 소수를 출력하는 예제다.

private static final BigInteger ONE = new BigInteger("1");
private static final BigInteger TWO = new BigInteger("2");

/**
 * 스트림을 사용해 처음 20개의 메르센 소수를 생성하는 프로그램
 */
public static void main(String[] args) {
    primes().map(p -> TWO.pow(p.intValueExact()).subtract(ONE))
            .filter(mersenne -> mersenne.isProbablePrime(50))
            .limit(20)
            .forEach(System.out::println);
}

private static Stream<BigInteger> primes() {
    // Stream.iterate() : 첫 번째는 초기값, 두 번째는 값이 어떻게 변경될 것인지를 나타내는 람다 함수
    return Stream.iterate(TWO, BigInteger::nextProbablePrime);
}

이 코드의 성능을 개선하기 위해 parallel() 을 사용하고자 할 수 있다. 하지만 이 메소드를 사용할 경우 위 코드는 아무것도 출력하지 못하면서 CPU는 90%나 잡아먹는 상태가 무한히 계속된다.


스트림 병렬화를 적용했는데 왜 느려질까?

데이터 소스가 Stream.iterate거나 중간 연산으로 limit을 쓰면 파이프라인 병렬화로는 성능 개선을 기대할 수 없다. 파이프라인 병렬화는 limit을 다룰 때 CPU 코어가 남는다면 원소를 몇 개 더 처리한 후 제한된 개수 이후의 결과를 버려도 아무런 해가 없다고 가정한다. 따라서 스트림 파이프라인을 마구잡이로 병렬화해서는 안된다. 성능이 오히려 끔찍하게 나빠질 수도 있다.


파이프라인 병렬화에서 limit을 사용하면 성능이 저하되는 이유

단순하게 파이프라인 병렬화가 작업을 CPU 코어 수만큼 병렬로 수행한다고 하면 쿼드 코어 시스템에서 위 코드를 수행했을 때 19번째 계산까지 마치고 마지막 20번째 계산을 수행하는 시점에는 CPU 코어 3개가 한가할 것이다. 따라서 21, 22, 23번째 메르센 소수를 찾는 작업이 병렬로 시작되는데, 20번째 계산이 끝나더라도 이 계산들은 끝나지 않는다.


그렇다면 스트림 파이프라인 병렬화는 언제 적용해야 할까?

대체로 다음 상황에서 병렬화의 효과가 가장 좋다.

  • 스트림의 소스가 ArrayList, HashMap, HashSet, ConcurrentHashMap 의 인스턴스인 경우
  • 스트림의 소스가 배열, int, long 범위인 경우

병렬화를 적용하기에 좋은 자료구조들이 가지는 특징

1. 이 자료구조들은 모두 데이터를 원하는 크기로 정확하고 손쉽게 나눌 수 있어서 일을 다수의 스레드에 분배하기 좋다.
- 여기서 나누는 작업은 Spliterator가 담당한다.
- Spliterator 객체는 Stream이나 Iterable의 spliterator 메소드로 얻어올 수 있다.

2. 원소들을 순차적으로 실행할 때의 참조 지역성이 뛰어나다.
- 이 말은 즉, 이웃한 원소의 참조들이 메모리에 연속해서 저장되어 있다는 뜻이다.
- 참조 지역성이 낮으면 스레드는 데이터가 주 메모리에서 캐시 메모리로 적용되어 오기를 기다리며 대부분의 시간을 멍하니 보내게 된다.
- 따라서 참조 지역성은 다량의 데이터를 처리하는 벌크 연산을 병렬화할 때 아주 중요한 요소로 작용한다.
- 참조 지역성이 가장 뛰어난 자료구조는 기본 타입의 배열이다. (배열은 참조가 아닌 데이터 자체가 메모리에 연속해서 저장된다.)

병렬로 수행했을 때의 효율에 영향을 미치는 연산

  • 종단 연산에서 수행하는 작업량이 파이프라인 전체 작업에서 상당 비중을 차지하면서 순차적인 연산이라면 파이프라인 병렬 수행의 효과는 제한될 수 밖에 없다.
  • 종단 연산 중 병렬화에 가장 적합한 것은 축소(reduction)다. 축소는 파이프라인에서 만들어진 모든 원소를 하나로 합치는 작업으로, Stream의 reduce 메소드 중 하나, 혹은 min, max, count, sum 같이 완성된 형태로 제공되는 메소드 중 하나를 선택해 수행한다.
  • 혹은 anyMatch, allMatch, noneMatch 처럼 조건에 맞으면 바로 반환되는 메소드도 병렬화에 적합하다.
  • 반면 가변 축소(mutable reduction)를 수행하는 Stream의 collect 메소드는 병렬화에 적합하지 않다. 컬렉션들을 합치는 부담이 크기 때문이다.

병렬화의 이점을 누리고 싶다면 spliterator 메소드를 재정의하라.

직접 구현한 Stream, Iterable, Collection이 병렬화의 이점을 제대로 누리게 하고 싶다면 spliterator 메소드를 반드시 재정의하고 결과 스트림의 병렬화 성능을 강도 높게 테스트하라.


스트림 병렬화는 신중하게 적용해야 한다.

  • 보통은 병렬 스트림 파이프라인도 공통의 포크-조인 풀에서 수행되므로(즉, 같은 스레드 풀을 사용하므로) 잘못된 파이프라인 하나가 시스템의 다른 부분의 성능에까지 악영향을 줄 수 있음을 유념해야 한다.
  • 스트림 파이프라인을 병렬화해서 효과를 보는 경우는 많지 않다. 하지만 조건이 잘 갖춰지면 parallel 메소드 호출 하나로 거의 프로세서 코어 수에 비례하는 성능 향상을 만끽할 수 있다.

예제 - 스트림 파이프라인 병렬화가 잘 반영된 코드

다음은 n보다 작거나 같은 소스의 개수를 계산하는 함수로 스트림 파이프라인 병렬화가 효과를 제대로 발휘하는 간단한 예제다.

static long pi(long n) {
    return LongStream.rangeClosed(2, n)
            .mapToObj(BigInteger::valueOf)
            .filter(i -> i.isProbablePrime(50))
            .count();
}

위 코드에 parallel() 을 추가하면 성능을 향상시킬 수 있다. (필자의 컴퓨터에서 31초 → 9.2초로 단축)

static long pi(long n) {
    return LongStream.rangeClosed(2, n)
            .parallel()
            .mapToObj(BigInteger::valueOf)
            .filter(i -> i.isProbablePrime(50))
            .count();
}

무작위 수들로 이뤄진 스트림을 병렬화하는 꿀팁

무작위 수들로 이뤄진 스트림을 병렬화하는 경우 ThreadLocalRandom (혹은 Random) 보다는 SplittableRandom 인스턴스를 사용하라.

  • SplittableRandom 은 병렬 수행을 위해 설계된 것으로 병렬화하면 성능이 선형으로 증가한다.
  • ThreadLocalRandom 은 단일 스레드에서 쓰고자 만들어졌다. 병렬 스트림용 데이터 소스로도 사용할 수는 있지만 SplittableRandom 만큼 빠르지는 않다.
  • Random은 모든 연산을 동기화하기 때문에 병렬로 처리하면 최악의 성능을 보인다.

정리

  • 스트림을 잘못 병렬화하면 프로그램을 오동작하게 하거나 성능을 급격히 떨어뜨린다.
  • 병렬화하는 편이 낫다고 생각하더라도 계산이 정확한지, 운영 환경과 유사한 조건에서 수행해보며 성능 지표를 유심히 관찰해야 한다.
  • 계산이 정확하고 성능도 좋아졌음이 확실해졌을 때만 병렬화 버전 코드를 운영 코드에 반영하라.

참고 자료

  • Effective Java 3/E
⚠️ **GitHub.com Fallback** ⚠️