본문은 Effective Java를 읽고 간단하게 정리한 글입니다. 필요에 따라 생략/수정된 부분이 있을 수 있으며, 내용이 추후 변경될 수 있습니다.
선결론
- 스트림을 잘못 병렬화하면 많은 문제가 발생하기 때문에 신중하게 결정해야 한다
- 병렬화를 시도하더라도 성능지표를 관찰하고, 유의미한 경우에만 운영 코드에 반영해야 한다
스트림 병렬화
1. 순차 스트림
List<Integer> listOfNumbers = Arrays.asList(1, 2, 3, 4);
listOfNumbers.stream().forEach(number ->
System.out.println(number + " " + Thread.currentThread().getName())
);
1 main
2 main
3 main
4 main
- 순차 스트림은 단일 스레드를 사용하여 파이프라인을 처리한다
- 위 코드에서 스트림의 요소들은 항상 순차적으로 출력된다
2. 병렬 스트림
List<Integer> listOfNumbers = Arrays.asList(1, 2, 3, 4);
listOfNumbers.parallelStream().forEach(number ->
System.out.println(number + " " + Thread.currentThread().getName())
);
4 ForkJoinPool.commonPool-worker-3
2 ForkJoinPool.commonPool-worker-5
1 ForkJoinPool.commonPool-worker-7
3 main
- 순차 스트림의 파이프라인에 parallel() 메서드를 추가하거나 컬렉션의 parllelStream() 메서드를 사용하여 병렬 스트림을 생성할 수 있다
- 실행 순서는 보장할 수 없다
- 이러한 병렬 작업은 fork-join 프레임워크을 통해 이루어진다
- fork-join 프레임워크는 분할 정복 방식을 이용해 병렬 처리 속도를 높인다
- fork: 작업을 더 작은 독립 하위작업으로 재귀적으로 분할한다
- join: 다시 재귀적으로 모든 하위 작업의 결과가 단일 결과로 결합된다
스트림 병렬화를 고려할 수 있는 경우
1. 스트림의 소스가 ArrayList, HashMap, HashSet, ConcurrentHashMap의 인스턴스거나 배열, int 범위, long 범위일 때
1) 데이터의 분할이 정확하고 쉽다
- 일을 다수의 스레드에 분배하기 좋다
- Spliterator를 이용해 데이터를 분할할 수 있다
2) 참조 지역성이 뛰어나다
- 참조 지역성이 낮으면 캐시 실패 확률이 높아지고, 이에 따라 스레드가 낭비된다
- 따라서 참조 지역성은 다량의 데이터를 처리하는 벌크 연산을 병렬화할 때 아주 중요한 요소로 작용한다
- 참조 지역성이 가장 뛰어난 자료구조는 기본 타입의 배열이다
2. 종단 연산이 병렬화에 적합할 때
- 축소(파이프라인에서 만들어진 모든 원소를 합치는 작업) 연산이 가장 병렬화에 적합하다
- Stream의 reduce 메서드 중 하나, 혹은 min, max, count, sum 같이 완성된 형태로 제공되는 메서드 중 하나
- anyMatch, allMatch, noneMatch처럼 조건에 맞으면 바로 반환되는 메서드
- 가변 축소(mutable reduction)를 수행하는 Stream의 collect 메서드는 병렬화에 적합하지 않다
- 컬렉션들을 합치는 부담이 크기 때문
스트림 병렬화가 문제가 되는 경우
1. 안전 실패
- 스트림을 잘못 병렬화하면 응답 불가, 성능 저하, 오동작등이 발생한다
- 결과가 잘못되거나 오동작하는 경우를 안전 실패(safety failure)라고 한다
public class Item48 {
public static void main(String[] args) {
List<Integer> listOfNumbers = Arrays.asList(1, 2, 3, 4);
int sumByParallel = listOfNumbers.parallelStream().reduce(5, Integer::sum);
int sumBySequential = listOfNumbers.stream().reduce(5, Integer::sum);
System.out.println("sumByParallel = " + sumByParallel);
System.out.println("sumBySequential = " + sumBySequential);
listOfNumbers.parallelStream().forEach(n -> {
System.out.println("number = " + n + " " + Thread.currentThread().getName());
});
}
}
sumByParallel = 30
sumBySequential = 15
number = 3 main
number = 4 ForkJoinPool.commonPool-worker-5
number = 2 ForkJoinPool.commonPool-worker-19
number = 1 ForkJoinPool.commonPool-worker-23
- 순차 스트림과 병렬 스트림의 최종 연산 결과가 다르다
- 축소 작업이 병렬로 처리되기 때문이다
- (5+1) + (5+2) + (5+3) + (5+4) = 30
- 이는 종단 함수 내에서 사용되는 함수가 상태를 가졌기 때문이다
2. 실제 작업보다 병렬화에 드는 추가 비용이 더 클 때
- 앞의 조건을 모두 만족하더라도 파이프라인이 수행하는 작업의 비용 < 병렬화에 드는 추가 비용이라면 성능 향상은 기대하기 어렵다
- 스트림 안의 원소 수와 원소당 수행되는 코드 줄 수가 최소 수십만은 되어야 성능 향상이 기대된다
3. 한 스레드의 문제가 다른 스레드에게 전이되는 경우
- 병렬 스트림 파이프라인은 같은 스레드 풀을 사용하므로 잘못된 파이프라인 하나가 시스템의 다른 부분의 성능에까지 악영향을 줄 수 있다
- 따라서 스트림 병렬화를 적용할 땐 변경 전후로 성능을 테스트하여 문제가 없는지, 성능 향상이 실제로 유의미하게 이루어지는지 확인해야 한다
참고
'책 > Effective Java' 카테고리의 다른 글
[이펙티브 자바] 아이템 51: 메서드 시그니처를 신중히 설계하라 (0) | 2022.06.22 |
---|---|
[이펙티브 자바] 아이템 50: 적시에 방어적 복사본을 만들라 (0) | 2022.06.21 |
[이펙티브 자바] 아이템 46: 스트림에서는 부작용 없는 함수를 사용하라 (0) | 2022.06.15 |
[이펙티브 자바] 아이템 43: 람다보다는 메서드 참조를 사용하라 (0) | 2022.06.10 |
[이펙티브 자바] 아이템 42: 익명 클래스보다는 람다를 사용하라 (0) | 2022.06.10 |