Language/Java

[effective java] 아이템 48. 스트림 병렬화는 주의해서 적용하라.

JOYERIM 2023. 7. 3. 13:38

 

 

 

 

 

 

 

핵심 정리

 

스트림 병렬화는 신중하게 사용해야 한다. 그리고 실제로 테스트하여 성능을 확인해야 한다. 무분별한 병렬화는 오히려 성능 저하를 일으키고, 예상치 못한 문제를 야기할 수 있다.

 

1. 스트림 병렬화를 신중하게 사용해라

스트림 병렬화를 적용하면 코어 수에 따라 성능이 향상될 수 있지만, 이는 잘못 사용하면 성능을 저하시킬 수 있다. 병렬화는 추가적인 계산 비용을 발생시키고, 모든 작업이 병렬화로 이익을 얻지는 못한다. 따라서 병렬화를 고려하기 전에 작업이 병렬로 수행될 때 성능 이득을 얻을 수 있는지 충분히 분석해야 한다.

 

2. 자동 병렬화를 무조건적으로 신뢰하지 마라.

Stream API는 데이터 처리를 병렬화하는 도구를 제공하지만, 이를 잘못 사용하면 예상치 못한 성능 저하나 잘못된 결과를 초래할 수 있다. 병렬화가 필요한 경우에는, 적절한 성능 테스트를 수행하여 실제로 이득이 있는지 확인하라.

 

3. 병렬화에 적합한 작업만 병렬화하라.

대량 데이터를 다루고, CPU 연산에 의해 제한되는 상황에서는 병렬화가 효과적일 수 있다. 반면, I/O 기반의 작업이나 네트워크 작업 등은 병렬화에 적합하지 않을 수 있다.

 

4. 공유 가변 데이터에 대한 접근은 피하라.

병렬화된 스트림에서는 공유 가변 데이터에 대한 접근을 최소화해야 한다. 이러한 데이터에 접근하는 것은 스레드 안전성 문제를 야기하고, 성능 저하를 가져올 수 있다.

 

 

 

 

 

 

 

자바의 동시성

 

  • 1996년부터 스레드, 동기화, wait/notify를 지원
  • 자바 5부터 java.util.concurrent 라이브러리, Executor 프레임워크 지원
  • 자바 7부터 포크-조인(fork-join) 패키지 지원
  • 자바 8부터 병렬 스트림인 parallel 메소드 지원

 

 

 

 

 

예제 1
: 스트림을 사용해 처음 20개의 메르센 소수를 생성하는 프로그램
: 스트림 병렬화를 사용하면 안되는 경우

 

public class ParallelMersennePrimes {
    public static void main(String[] args) {
        primes().map(p -> TWO.pow(p.intValueExact()).subtract(ONE))
                .parallel() // 스트림 병렬화
                .filter(mersenne -> mersenne.isProbablePrime(50))
                .limit(20)
                .forEach(System.out::println);
    }

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

 

위 코드는 1시간 반이 지나 강제 종료할 때까지 아무 결과도 출력하지 않는다. 데이터 소스가 Stream.iterate이며 중간 연산으로 limit을 사용하기 때문이다.

 

 

 

 

 

 

 

파이프라인 병렬화로 성능 개선을 할 수 없는 경우

 

데이터 소스가 Stream.iterate인 경우

Stream.iterate는 요소들을 하나씩 생성한다. 이는 병렬 처리에 적합하지 않다. 왜냐하면 각 요소는 이전 요소를 기반으로 생성되므로 데이터가 병렬로 생성될 수 없기 때문이다. 이러한 순차적인 특성은 병렬 처리가 불가능하게 한다.

 

중간 연산으로 limit를 사용하는 경우

병렬화는 일반적으로 데이터의 모든 부분에 작업을 독립적으로 수행하고 결과를 합치는 데 효과적이다. 그러나 limit 연산은 전체 데이터 집합을 검토해야하므로 병렬화의 이점을 얻을 수 없다. limit는 처리할 데이터의 양을 줄이지만, 이 연산 이후의 모든 연산은 병렬로 수행할 수 없게 한다.

병렬 처리에 적합하지 않은 연산이나 데이터 소스를 사용하면, 병렬화가 오히려 성능을 저하시킬 수 있다. 이러한 경우에는 순차 스트림을 사용하는 것이 더 효율적일 수 있다.

 

 

 

 

스트림 병렬화를 사용해야 할 경우

 

대체로 스트림의 소스가 ArrayList, HashMap, HashSet, ConcurrentHashMap의 인스턴스거나 배열, int 범위, long 범위일 때 병렬화의 효과가 가장 좋다.

 

해당 자료구조들은 아래와 같은 두 가지 공통점을 지닌다.

 

1. 정확성

 

이러한 자료 구조들은 데이터를 정확하고 쉽게 분할할 수 있다. 이는 스트림의 병렬 처리에서 매우 중요한 속성이다. 나누는 작업은 Spliterator를 통해 이루어지며, Iterable과 Stream에서 얻을 수 있다.

 

2. 참조 지역성

 

참조 지역성은 프로그램이 데이터를 참조하는 패턴이다. 지역성이 높을 경우 캐시 히트율이 높아져 성능이 향상된다.

 

- 시간 지역성 : 최근에 참조된 주소는 빠른 시간 내에 다시 참조되는 특성
- 공간 지역성 : 참조된 주소와 인접한 주소의 내용이 다시 참조되는 특성
- 순차 지역성 : 데이터가 순차적으로 엑세스 되는 특성(공간 지역성)

 

 

 

 

 

 

 

종단 연산과 병렬화

 

종단 연산에서 수행하는 작업량이 파이프라인 전체 작업에서 상당 비중을 차지하면서 순차적인 연산일 경우
➡ 스트림 파이프라인 병렬화에 적합하지 않다.

 

예를 들어, collect와 같은 종단 연산은 내부적으로 수행해야 할 작업이 많다. 결과를 수집하기 위해 내부적으로 컬렉션을 생성하고, 중간 연산의 결과를 이 컬렉션에 추가한다. 이 과정은 병렬화하기 어렵기 때문에 병렬화에 부적합하다.

 

reduce, min, max, count, sum, anyMatch, allMatch, noneMatch와 같은 연산일 경우
➡ 스트림 파이프라인 병렬화에 적합하다.

 

입력 원소들을 하나의 결과로 합치는 연산이므로, 이 연산을 수행하는 데 필요한 작업은 간단하고 병렬로 수행할 수 잇다.

 

 

 

 

 

 

 

 

 

 

기타 주의해야 할 점

 

직접 구현한 Stream, Iterable, Collection을 병렬화할 경우
spliterator 메소드를 반드시 재정의하고 성능 평가를 꼼꼼히 테스트하라.

 

Stream API 명세
1. 결합 법칙을 만족해야 한다.
➡ reduce 연산에 사용되는 accumulator와 combiner 함수는 반드시 결합 법칙을 만족해야 한다.
2. 간섭받지 않아야 한다.
➡ 파이프라인이 수행되는 동안 데이터 소스가 변경되지 않아야 한다.
3. 상태를 갖지 않아야 한다.
➡ 입력 이외의 상태에 영향을 받지 않고, 입력에만 의존한 결과를 반환해야 한다.,

 

스트림 병렬화는 성능 최적화 수단이다.
반드시 성능테스트를 통해 확인해야 한다.

 

Random한 수로 이뤄진 스트림을 병렬화할 경우
ThreadLocalRandom, Random 보다는 SplittableRandom 인스턴스를 사용하라.
성능이 선형적으로 증가한다.