JAVA/Effective Java

item 48. 스트림 병렬화는 주의해서 적용해라

Garonguri 2022. 7. 2. 17:09
728x90

  • 자바 5부터는 동시성 컬렉션인 java.util.concurrent 라이브러리와 실행자(Executor) 프레임워크를 지원했다.
  • 자바 7부터는 고성능 병렬 분해 프레임워크인 포크-조인 패키지를 추가했다.
    • 포크조인(ForkJoin) 프레임워크는 병렬 처리를 위한 모델이고 분할 정복 알고리즘을 통해서 재귀적으로 처리
  • 자바 8부터는 parallel 메서드만 한 번 호출하면 파이프라인을 병렬 실행할 수 있는 스트림을 지원했다.
    • 병렬 스트림은 요소들을 병렬 처리하기 위해 포크조인(ForkJoin) 프레임워크를 사용
  • 이처럼 자바로 동시성 프로그램을 작성하기가 점점 쉬워지고는 있지만, 이를 올바르고 빠르게 작성하는 일은 여전히 어려운 작업이다.

스트림 병렬화의 문제점.

  • 데이터 소스가 Stream.iterate거나 중간 연산으로 limit를 쓰면 파이프라인 병렬화로는 성능 개선을 기대할 수 없다.
  • limit을 다룰 때 CPU 코어가 남는다면 원소를 몇 개 더 처리한 후 제한된 개수 이후의 결과를 버려도 아무런 해가 없다고 가정한다.
  • 원소 하나를 계산하는 비용이 그 이전까지 원소 전부를 계산하는 비용을 합친 것만큼 든다.

-> 즉, 스트림 파이프라인을 마구잡이로 병렬화하면 성능이 끔찍해질 수 있다.


 그럼 언제 사용해야 하는지?

-> 스트림의 소스가 ArrayList, HashMap, HashSet, ConcurrentHashMap의 인스턴스거나 배열, int범위, long 범위일때 사용!

해당 자료구조들의 공통점

1. 모두 데이터를 원하는 크기로 정확하고 손쉽게 나눌 수 있어 일을 다수의 스레드로 분배하기에 좋다는 특징이 있다. (데이터를 나눌 때는 Spliterator사용. 이는 Stream or Iterable의 메서드를 사용한다.)

2. 모두 원소들을 순차적으로 실행할 때의 참조 지역성이 뛰어나다. (이웃한 원소의 참조들이 메모리에 연속해서 저장됨)

(기본 타입 배열이 가장 참조지역성이 뛰어나다. 데이터 자체가 메모리에 연속해서 저장되기 때문)

 

-> 스트림 파이프라인의 종단 연산의 동작 방식도 병렬 수행 효율에 영향을 준다. 종단 연산 중 '축소'가 병렬화에 적합하다.

Stream의 reduce, min, max, count, sum등의 메서드 중 하나 또는 anyMatch, allMatch, noneMatch등의 메서드를 사용한다.

* 가변 축소를 수행하는 Stream의 collect메서드는 병렬화에 적합하지 않다.

 

스트림을 잘못 병렬화하면 (응답 불가를 포함해) 성능이 나빠질 뿐만 아니라 결과 자체가 잘못되거나 예상 못한 동작이 발생할 수있다.

 

따라서, Stream 명세는 이때 사용되는 함수 객체에 관한 엄중한 규약을 정의해놨다.

ex) Stream reduce 연산에 건네지는 accumulator(누적기)와 combiner (결합기)함수는 반드시 결합법칙을 만족하고, 간섭받지 않고, 상태를 갖지 않아야 한다.

 

스트림 병렬화를 적용하기 전에 고려 사항

  • 스트림 병렬화는 오직 성능 최적화 수단이라는 것을 기억하자.
  • 따라서 다른 최적화와 마찬가지로 변경 전후로 성능 테스트를 통해 가치가 있는지 확인해야 한다.
  • 보통은 병렬 스트림 파이프라인도 공통의 포크-조인 풀에서 수행(같은 스레드 풀에서 수행)되므로 잘못된 파이프라인 하나가 다른 부분의 성능에까지 악영향을 미칠수 있다.

그럼 스트림을 병렬화하지 말까?!

-> 아니다.

조건이 잘 갖춰지면 parallel 메서드 호출 하나로 거의 프로세서 코어 수에 비례하는 성능 향상을 만끽할 수 있다.

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

[이유] (위에서 다뤘지만 한번 더 말하면)

  1. 위 자료구조들은 모두 데이터를 원하는 크기로 정확하고 손쉽게 나눌 수 있어 일을 다수의 스레드로 분배하기에 좋다는 특징이 있다.
    • 나누는 작업은 Spliterator가 담당하며, Spliterator 객체는 Stream 이나, Iterable의 spliterator 메소드로 얻어올수 있다.
  2. 순차적으로 실행할 떄 참조 지역성이 뛰어나다는 특징이 있다.
    • 참조 지역성:  메모리에 연속으로 저장되어 있다는 의미
    • 가장 뛰어난 자료구조는 기본타입의 배열이다. (데이터 자체가 메모리에 연속해서 저장되기 때문)

Random값으로 이루어진 스트림 병렬화

1. ThreadLocalRandom : 단일 쓰레드에서 사용하기 위해 만들어졌다.

2. SplittableRandom : 무작위 수를 위한 스트림 병렬화를 위해 설계되었다.

3. Random : 모든 연산을 동기화 하기 때문에 최악의 연산을 자랑한다.

 

핵심 정리

계산도 올바르게 수행하고, 성능도 빨라질 것이란 확신 없이는 스트림 파이프라인 병렬화를 시도하지 말아라.
-> 잘못하다간 아주 마앙할수있다

병렬화하는 편이 낫다고 하더라도, 수정 후 코드가 여전히 정확한지 항상 확인해야 한다.

"확실할"때만 병렬화를 진행하고 반영하자!

 

 

728x90