일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | |
7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | 18 | 19 | 20 |
21 | 22 | 23 | 24 | 25 | 26 | 27 |
28 | 29 | 30 |
- EntityGraph
- BFS
- 이펙티브자바
- effective java
- 동시성처리
- lombok
- 파이썬
- 코딩테스트
- EffectiveJava
- @Transactional
- 클린아키텍처
- 캐시
- Transactional
- AOP
- spring
- collapse
- Garbage Collection
- thymeleaf
- Java
- TDD
- cache
- 멱등성
- 알고리즘
- Spring Security
- JVM
- 배낭문제
- 자바
- 타임리프
- JPA
- interceptor
- Today
- Total
Jinnie devlog
[Effective Java] 스트림 병렬화는 주의해서 적용하라 본문
자바는 동시성 프로그래밍 측면에서 항상 앞서갔다.
- 처음 릴리즈된 1996년부터 스레드, 동기화, wait/notify를 지원했다.
- 자바 5부터는 동시성 컬렉션인 java.util.concurrent 라이브러리와 실행자(Executor) 프레임워크를 지원했다.
- 자바 7부터는 고성능 병렬 분해 프레임워크인 포크-조인(fork-join) 패키지를 추가했다.
- 자바 8부터는 parallel 메서드만 한 번 호출하면 파이프라인을 병렬 실행할 수 있는 스트림을 지원했다.
이처럼 자바로 동시성 프로그램을 작성하기가 점점 쉬워지고는 있지만, 이를 올바르고 빠르게 작성하는 일은 여전히 어려운 작업이다.
- 동시성 프로그래밍을 할 때는 안전성과 응답 가능 상태를 유지하기위해 애써야 한다.
파이프라인 병렬화가 불가능한 경우
예) 메르센 소수를 생성하는 프로그램 (메르센 수 M(n)은 2n -1 형태인 숫자를 말한다. 메르센 소수는 메르센 수 중 소수인 것들을 가리킨다.)
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);
}
안타깝게도 이 프로그램은 아무것도 출력하지 못하면서 CPU는 90%나 잡아먹는 상태가 무한히 계속된다.
왜 그럴까?
원인은 stream 라이브러리가 이 파이프라인을 병렬화 하는 방법을 찾아내지 못했기 때문이다.
- 데이터 소스가 Stream.iterate인 경우
- 중간 연산으로 limit을 사용하는 경우
위 두가지 경우에는 파이프라인 병렬화로 성능 향상을 기대하기 어렵다.
원소 하나를 계산하는 비용이 대략 그 이전까지의 원소 전부를 계산한 비용을 합친 것만큼 든다는 뜻이다. 스트림 파이프라인을 마구잡이로 병렬화하면 안 된다. 성능이 오히려 끔찍하게 나빠질 수도 있다.
언제 병렬화를 사용해야 할까?
- ArrayList
- HashMap
- HashSet
- ConcurrentHashMap
- 배열
- int 범위, long 범위
스트림의 데이터 소스가 위와 같은 클래스의 인스턴스 일 때 병렬화의 효과가 가장 좋다.
- 이 자료구조들은 모두 데이터를 원하는 크기로 정확하고 손쉽게 나눌 수 있어서 일을 다수의 스레드에 분배하기에 좋다는 특징이 있다.
- 나누는 작업은 Spliterator가 담당하며, Spliterator 객체는 Stream이나 Iterable의 spliterator 메서드로 얻어올 수 있다.
또한 위의 자료구조들의 또 다른 중요한 공통점은 원소들을 순차적으로 실행할 때의 참조 지역성(locality of reference)이 뛰어나다는 것이다.
참조 지역성이란? 한번 참조한 데이터는 다시 참조될 가능성이 높고 참조된 데이터 주변의 데이터 역시 같이 참조될 가능성이 높은 성질이다.
스트림 파이프라인의 종단 연산
스트림 파이프라인의 종단 연산의 동작 방식 역시 병렬 수행 효율에 영향을 준다.
종단 연산 중 병렬화에 가장 적합한 것은 축소(reduction)다.
- 축소는 파이프라인에서 만들어진 모든 원소를 하나로 합치는 작업이다.
- Stream의 reduce 메서드 중 하나, 혹은 min,max,count,sum 같이 완성된 형태로 제공되는 메서드 중 하나를 선택해 수행한다.
- anyMatch, allMatch, noneMatch처럼 조건에 맞으면 바로 반환되는 메서드도 병렬화에 적합하다.
- 반면, 가변 축소(Mutable Reduction)을 수행하는 Stream의 collect 메서드는 병렬화에 적합하지 않다. 컬렉션들을 합치는 부담이 크기 때문이다.
잘못 병렬화하는것 보단 안하는게 낫다.
스트림을 잘못 병렬화하면 (응답 불가를 포함해) 성능이 나빠질 뿐만 아니라 결과 자체가 잘못되거나 예상 못한 동작이 발생할 수 있다.
결과가 잘못되거나 오동작하는 것은 안전 실패(safety failure) 이라 한다.
안전 실패는 병렬화한 파이프라인이 사용하는 mappers, filters 혹은 프로그래머가 제공한 다른 함수 객체가 명시한대로 동작하지 않을 때 발생할 수 있다.
Stream 명세는 이때 사용되는 함수 객체에 관한 엄중한 규약을 정의해놨다.
Stream 명세의 함수 객체에 관한 규약
- stream의 reduce 연산에 건네지는 accumulator(누적기)와 combiner(결합기) 함수는 반드시 결합법칙을 만족해야 한다. (결합 법칙 : (a op b) op c = a op (b op c))
- 간섭받지 않아야 한다.(non-interfering) - 파이프라인이 수행되는 동안 데이터소스가 변경되지 않아야 한다.
- 상태를 갖지 않아야 한다.(stateless)
위의 요구사항을 지키지 못하더라도 파이프라인을 순차적으로만 수행한다면야 올바른 결과를 얻을 수도 있다.
하지만 병렬로 수행하면 참혹한 실패로 이어지기 십상이다.
스트림 병렬화는 오직 성능 최적화 수단이다.
변경 전후로 반드시 성능을 테스트하여 병렬화를 사용할 가치가 있는지 확인해야 한다. (아이템 67)
- 이상적으로는 운영 시스템과 흡사한 환경에서 테스트하는 것이 좋다.
- 보통은 병렬 스트림 파이프라인도 같은 스레드 풀을 사용하므로, 잘못된 파이프라인 하나가 시스템의 다른 부분의 성능에까지 악영향을 줄 수 있음을 유념하자.
- 조건이 잘 갖춰지면 parallel 메서드 호출 하나로 거의 프로세서 코어 수에 비례하는 성능 향상을 만끽할 수 있다. (머신러닝과 데이터 처리 같은 특정 분야에서는 이러한 성능 개선이 필요한 경우가 많을 것이다.)
스트림 파이프라인 병렬화가 적합한 경우
//소수 계산 스트림 파이프라인
static long pi(long n) {
return LongStream.rangeClosed(2, n)
.mapToObj(BigInteger::valueOf)
.filter(i -> i.isProbablePrime(50))
.count();
}
//병렬화 버전
static long pi(long n) {
return LongStream.rangeClosed(2, n)
.parallel()
.mapToObj(BigInteger::valueOf)
.filter(i -> i.isProbablePrime(50))
.count();
}
- 위의 코드에서는 31초, 아래의 parallel()호출을 추가한 코드에서는 9.2초가 걸렸다.
핵심 정리
- 계산도 올바로 수행하고 성능도 빨라질 거라는 확신 없이는 스트림 파이프라인 병렬화는 시도조차 하지 말자.
- 스트림을 잘못 병렬화하면 프로그램을 오작동하게 하거나 성능을 급격하게 떨어뜨린다.
- 병렬화하는 편이 낫다고 믿더라도, 수정 후의 코드가 여전히 정확한지 확인하고 운영 환경과 유사한 조건에서 수행해보며 성능지표를 유심히 관찰하라.
- 계산도 정확하고 성능도 좋아졌음이 확실해졌을 때만 병렬화 버전 코드를 운영 코드에 반영하라.
참고
https://jaehun2841.github.io/2019/02/17/effective-java-item48/#스트림-파이프라인-병렬화가-효과적인-예
'Java' 카테고리의 다른 글
[Effective Java] 적시에 방어적 복사본을 만들라 (0) | 2023.02.03 |
---|---|
[Effective Java] 매개변수가 유효한지 검사하라 (0) | 2023.02.03 |
[Effective Java] 반환 타입으로는 스트림보다 컬렉션이 낫다 (0) | 2023.02.03 |
[Effective Java] 스트림에서는 부작용 없는 함수를 사용하라 (0) | 2023.02.03 |
[Effective Java] 스트림은 주의해서 사용하라 (0) | 2023.02.02 |