-
[Java] 병렬 스트림 효과적으로 사용하기개발자 라이프 2020. 7. 20. 23:31반응형
이 글은 "모던 자바 인 액션" 책을 스터디하며 정리한 내용입니다.
들어가며
병렬 스트림은 내부적으로 작업을 분할하고 멀티 스레드로 병렬 처리할 수 있도록 합니다. 하지만 적절하게 사용하지 않는다면 순차 스트림보다 더욱 안 좋은 성능을 나타냅니다. 이번 글은 효과적으로 병렬 스트림을 사용하는 방법에 대해 알아봅니다.
병렬 스트림을 효과적으로 사용하기
병렬 스트림을 효과적으로 사용하는 방법은 "1천 개 이상의 아이템일 경우 병렬 처리하라"와 같이 정성적으로 구성되어 있지 않습니다. 하지만 몇가지 기준이 되는 방법이 있습니다.
1. 직접 측정하기
순차 스트림과 병렬 스트림은 손 쉽게 변경할 수 있습니다. 그러므로 각각의 스트림을 구성하고 벤치마크 툴(ex. jmh)을 이용하여 직접 측정하고 비교하여 적절한 스트림을 선택합니다. 병렬 스트림이 항상 순차 스트림보다 빠른 것은 아니기 때문입니다.
import org.openjdk.jmh.annotations.*; import java.util.concurrent.TimeUnit; import java.util.stream.Stream; @BenchmarkMode(Mode.AverageTime) @OutputTimeUnit(TimeUnit.MILLISECONDS) @State(Scope.Benchmark) @Fork(value = 2, jvmArgs = {"-Xms4G", "-Xmx4G"}) public class ParallelStreamBenchmark { private static final long N = 10_000_000L; @Benchmark public long sequentialSum() { return Stream.iterate(1L, i -> i + 1) .limit(N) .reduce(0L, Long::sum); // <- 순차 스트림 구성 } @Benchmark public long parallelSum() { return Stream.iterate(1L, i -> i + 1) .limit(N) .parallel() // <- 병렬 스트림 구성 .reduce(0L, Long::sum); } @TearDown(Level.Invocation) public void tearDown() { System.gc(); } }
2. 박싱에 주의하라
기본 타입에 대한 자동 박싱과 언박싱은 성능을 크게 저하시키는 요소입니다. 따라서 자바 8에서 제공하는 기본형 특화 스트림(IntStream, LongStream, DoubleStream)을 이용하는 것이 안정적입니다.
3. 무조건 병렬 스트림이 빠른 것이 아니다
순서에 의존하는 findFirst와 같은 연산은 병렬 스트림에 적합하지 않은 연산입니다. 만약 순서에 상관없이 N 개의 결과 값을 얻어야 하는 스트림이라면 unordered를 호출하여 정렬되지 않은 스트림을 반환받고, limit를 호출하는 것이 더욱 효율적입니다.
4. 소량의 데이터는 적합하지 않다
병렬 스트림이 병렬 처리를 위해 컬랙션을 나누는 과정에서 비용이 발생합니다. 그렇기 때문에 소량의 데이터는 병렬 스트림을 이용한 처리에 적합하지 않습니다. 만약 처리하는 데이터 갯수가 병렬 스트림에 적합한지 확인이 필요하다면, 1번처럼 직접 측정해보시는 것을 추천합니다.
5. 스트림 구성 요소의 자료구조가 적절한지 확인하라
"상황에 따라선 병렬화 알고리즘을 선택하는 것보다 적절한 자료구조를 선택하는 것이 더 중요하다"
예를 들어, LinkedList는 분할하기 위해 모든 요소를 탐색해야 합니다. 반대로 ArrayList는 전체 탐색 없이도 분할할 수 있기 때문에 효과적입니다.
자료구조 분해성 ArrayList 매우 좋음 LinkedList 나쁨 IntStream.range 훌륭함 Stream.iterate 나쁨 HashSet 좋음 TreeSet 좋음 // iterate와 rangeClosed의 병렬 스트림 분해성 차이에 따른 벤치마킹 private static final long N = 10_000_000L; @Benchmark public long iterateSum() { return LongStream.iterate(1L, i -> i + 1) // <- iterate 호출 .limit(N) .parallel() // <- 병렬 스트림 구성 .reduce(0L, Long::sum); } @Benchmark public long rangeClosedSum() { return LongStream.rangeClosed(1L, N) // <- rangeClosed 호출 .limit(N) .parallel() // <- 병렬 스트림 구성 .reduce(0L, Long::sum); }
6. 중간 연산 특성을 잘 파악하라
스트림 파이프라인의 특성은 중간 연산에 따라 바뀔 수 있습니다. 그리고 이렇게 바뀌는 특성에 따라 병렬 스트림의 분해 과정 성능이 달라질 수 있습니다. 예를 들어, 데이터 갯수가 정해진 스트림의 경우 분해 크기가 예상 가능하지만, 필터 스트림의 경우 데이터 개수를 예측할 수 없으므로 적절하게 병렬 처리를 할 수 있을지 예측할 수 없습니다.
7. 병합 과정 비용을 파악하라
최종 연산에서 분해된 서브 파트를 병합하고 이 과정에서 비용이 발생합니다. 따라서 병합 과정의 비용이 분해 후 처리에 대한 성능 이점보다 크다면 전체적인 스트림의 성능이 기대 이하가 될 수 있습니다.
마무리
Java 8의 스트림은 다양한 이점을 가지고 있습니다. 특히 쉽고 간편한 병렬 처리 구성을 할 수 있다는 것도 그 이점 중 하나입니다. 하지만 적절하게 사용하지 않는다면 순차 스트림보다 더 안좋은 성능을 나타낼 수 있습니다. 그러므로 병렬 스트림 구성에 있어 앞서 살펴본 7가지 내용을 잘 검토해야 합니다.
반응형