[Java] Item 48 : 스트림 병렬화는 주의해서 적용하라
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