Effective Java를 읽고 정리한 정리본입니다.

📌 Item 48 : 스트림 병렬화는 주의해서 적용하라

🫧 자바의 동시성 프로그래밍

자바는 동시성 프로그래밍을 지원한다.

처음 릴리스된 1996년부터 스레드, 동기화, wait/notify(모니터 락)을 지원했다.

자바 5부터는 동시성 컬렉션인 java.util.concurrent 라이브러리와 실행자(Executor, 비동기 처리) 프레임워크를 지원했다.

자바 7부터는 고성능 병렬 분해 (parallel decom-position) 프레임워크인 포크-조인(fork-join) 패키지를 추가했다.

여기서 포크-조인 프레임워크란 멀티쓰레드 프로그래밍을 구현하기 위해 하나의 작업을 작은 단위로 나눠서 여러 쓰레드가 동시에 처리하는 것을 쉽게 만들어준다.

자바 8부터는 parallel 메서드만 한 번 호출하면 파이프라인을 병렬 실행할 수 있는 스트림을 지원했다.

사용 예는 다음과 같다.

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

이처럼 자바로 동시성 프로그램을 작성하기가 점점 쉬워지고는 있지만, 이를 올바르고 빠르게 작성하는 이른 여전히 어려운 작업이다.

동시성 프로그래밍을 할 때는 안전성(safety)응답 가능(liveness) 상태를 유지해야 하는데, 이는 병렬 스트림 파이프라인 프로그래밍에서도 다를 바 없다.

✨ 스트림에서의 병렬성 프로그래밍 예시 코드

아이템 45에서 다루었던 메르센 소수를 생성하는 프로그램을 다시 살펴보자.

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);
}

static Stream<BigInteger> primes() {
  return Stream.iterate(TWO, BigInteger::nextProbablePrime);
}

만일 이 코드에 성능을 향상시키고자 스트림 파이프라인의 parallel()을 호출한다면 어떻게 될까?

안타깝게도 이 프로그램은 아무것도 출력하지 못하면서 CPU는 90%나 잡아먹는 상태가 무한히 계속된다. (응답불가, liveness failure)

그 이유는 스트림 라이브러리가 이 파이프라인을 병렬화하는 방법을 찾아내지 못했기 떄문이다.

환경이 아무리 좋더라도 데이터 소스가 Stream.iterate거나 중간 연산으로 limit를 쓰면 파이프라인 병렬화로는 성능 개선 기대가 어렵다.

그런데 이 코드는 두 문제를 모두 지니고 있다.

그뿐만 아니다.

파이프라인 병렬화는 limit를 다룰 때 CPU 코어가 남는다면 원소를 몇 개 더 처리한 후 제한된 개수 이후의 결과를 버려도 아무런 해가 없다고 가정하는데, 이 코드의 경우 새롭게 메르센 소수를 찾을 때마다 그 전 소수를 찾을 때보다 두 배 정도 더 오래 걸린다.

즉, 원소 하나를 계산하는 비용이 대략 그 이전까지의 원소 전부를 계산한 비용을 합친 것만큼 든다.

🫧 1. 스트림에서의 병렬성 사용 - 스트림 소스

그렇다면 스트림에서 병렬성 효과가 좋은 경우는 언제일까?

대체로 스트림의 소스가 다음과 같은 경우 병렬성이 좋다고 얘기한다.

  • ArrayList
  • HashMap
  • HastSet
  • ConcurrenthashMap
  • 배열
  • int 범위
  • long 범위

이 자료구조들의 특징은 다음과 같다.

✨ 1.1 분배 용이

데이터를 원하는 크기로 정확하고 손쉽게 나눌 수 있어서 일을 다수의 스레드에 분배하기 좋다.

나누는 작업은 Spliterator가 담당하며, Spliterator 객체는 Stream이나 Iterable의 spliterator 메서드로 얻어올 수 있다.

✨ 1.2 참조지역성

원소들을 순차적으로 실행할 때의 참조지역성이 좋다.

참조지역성은 이웃한 원소의 참조들이 메모리에 연속해서 저장되어 있다는 뜻이다.

🫧 2. 스트림에서의 병렬성 사용 - 종단 연산

뿐만 아니라, 스트림 파이프라인의 종단 연산의 동작 방식 역시 병렬 수행 효율에 영향을 준다.

✨ 2.1 축소

파이프라인에서 만들어진 모든 원소를 하나로 합치는 작업인 축소는 종단 연산 중 병렬화에 가장 적합하다.

축소 작업의 대표적 예는 다음과 같다.

  • Stream의 reduce 메서드
  • min, max, count, sum 같이 완성된 형태로 제공되는 메서드

반면, 가변 축소를 수행하는 Stream의 collect 메서드는 컬렉션을 합치는 부담으로 인해 병렬화에 적합하지 않다.

✨ 2.2 조건에 맞으면 바로 반환되는 메서드

ex) anyMatch, allMatch, noneMatch

🫧 참고 자료

[Java] 쓰레드 8 - fork & join 프레임 워크 | Tistory | 계범