요약
스트림 병렬화를 사용하려면 신중해야 한다.
운영 환경과 유사한 조건에서 테스트 했을 때, 올바른 계산 수행을 하고 성능 향상이 확인된 경우에만 사용해야한다.
스트림 병렬화 사용이 부적절한 경우
- Stream 병렬화 적용 안 한 코드
- 스트림을 사용해 처음 20개의 메르센 소수를 생성하는 프로그램
- 12.5초 걸림
public static void main(String[] args) { primes().map(p -> TWO.pow(p.intValueExact()).subtract(ONE)) .filter(mersenne -> mersenne.isProbablePrime(50)) .limit(20) // 병렬화 성능 저하 이유2 .forEach(System.out::println); } static Stream<BigInteger> primes() { return Stream.iterate(TWO, BigInteger::nextProbablePrime); // 병렬화 성능 저하 이유1 }
- Stream 병렬화(parallel()) 적용 → 파이프라인을 병렬화하는 방법을 찾아내지 못하기 때문이다.
- 위 코드에 parallel()을 적용하면 성능이 더 좋아질 것 같지만, 실상은 무한으로 코드가 실행되고 아무것도 출력되지 못한다. 또한 병렬화에서 성능 개선을 기대할 수 없는데, 그 이유는 다음과 같다.
- Stream.iterate 사용
- 무한 스트림을 제공하기 때문에 무한으로 실행됨. 무한 스트림은 병렬화에서 나눠서 진행(포크-조인). 무한이면 몇 개로 나눠야 할지 모르기 때문에 느려짐
- 중간 연산으로 limit() 사용
- limit은 요소 순서에 의존하기 때문에 병렬화가 의미가 없음
따라서 스트림 파이프라인을 마구잡이로 병렬화하면 안된다. 무분별하게 사용하면 성능이 오히려 나빠질 수 있다.
스트림 병렬화 사용이 적합한 경우
1. 병렬시 스트림 소스로 사용하면 좋은 자료구조
- ArrayList, HashMap, HashSet, ConcurrentHashMap의 인스턴스
- 배열, int 범위, long 범위
- 이유
- 모두 데이터를 원하는 크기로 정확하고 손쉽게 나눌 수 있다
- 따라서 일을 다수의 스레드에 분배하기 좋다
- 원소들을 순차적으로 실행할 때 참조 지역성이 뛰어나다
- 이웃한 원소의 참조들이 메모리에 연속해서 저장되어 있음을 의미
- 참조 지역성은 벌크 연산에서 병렬화할 때 중요하다
- 참조 지역성이 가장 뛰어난 자료구조는 배열
- 모두 데이터를 원하는 크기로 정확하고 손쉽게 나눌 수 있다
2. 종단 연산 - 축소
종단 연산에서 연산량이 적어야 병렬 수행 효율이 좋은데, 축소 작업을 사용하면 된다. 축소는 파이프라인에서 만들어진 모든 원소를 하나로 합치는 작업이다. 예시로는 Stream reduce 메서드, min, max, count, sum, anyMatch, allMatch, noneMatch 등이 있다.
반면에 가변 축소를 수행하는 Stream의 collect 메서드는 컬렉션들을 합치는 부담이 크기 때문에 병렬화에 적합하지 않다. + 순서에 의존하는 함수(limit, first 등)
3. (스트림 원소 개수) * (각 원소의 코드라인 수) ≥ 수십만
스트림 병렬화는 오직 성능 최적화 수단임을 알아야 하는데, 적용 전후로 반드시 성능을 테스트해 병렬화를 사용할 가치가 있는지 확인해야 한다. 그 기준으로 스트림 병렬화는 (스트림 원소 개수) * (각 원소의 코드라인 수) ≥ 수십만이 되어야 성능 향상을 기대할 수 있다.
정리
스트림 병렬화의 장점
조건만 잘 갖춰진다면 parallel() 호출 하나로 큰 성능 향상을 경험할 수 있다.
예시 : n이하의 소수 개수 구하기
- parallel 적용 전 : 31초
static long pi(long n) {
return LongStream.rangeClosed(2, n)
.mapToObj(BigInteger::valueOf)
.filter(i -> i.isProbablePrime(50))
.count();
}
- parallel 적용 후 : 9.2초
static long piParallel(long n) {
return LongStream.rangeClosed(2, n) // LongStream : 기본 타입 long을 스트림함, 박싱/언박싱 필요 없어서 성능 최적화
.parallel()
.mapToObj(BigInteger::valueOf)
.filter(i -> i.isProbablePrime(50))
.count();
}
스트림 병렬화의 단점
스트림을 잘못 병렬화하면 응답 불가, 성능 저하를 넘어서 결과 자체가 잘못되거나 예상하지 못한 동작이 발생할 수 있기 때문에 사용에 신중해야 한다.
💡 병렬 스트림 파이프라인을 공통의 포크-조인 풀에서 수행되므로(같은 스레드 풀을 사용하므로), 잘못된 파이프라인 하나가 시스템의 다른 부분의 성능에까지 악영향을 줄 수 있음을 유념하자.
💡 참조 지역성 떨어짐 + 크기가 변하는 링크드 리스트는 쓰면 안됨!!
💡 기본 타입의 스트림을 사용하는 것이 최적화에 더 좋다!
'Dev Language > EffectiveJava' 카테고리의 다른 글
[EffectiveJava] 다중정의는 신중히 사용하라 (0) | 2025.03.16 |
---|---|
[EffectiveJava] 적시에 방어적 복사본을 만들라 (0) | 2025.03.09 |
[EffectiveJava] 스트림에서는 부작용 없는 함수를 사용하라 (0) | 2025.02.23 |
[EffectiveJava] 익명 클래스보다는 람다를 사용하라 (2) | 2025.02.16 |
[EffectiveJava] 정의하려는 것이 타입이라면 마커 인터페이스를 사용하라 (0) | 2025.02.09 |