layout: post
title: "모던 자바 인 액션 스터디 - chapter7(1)"
date: 2022-04-25T00:00:00-00:00
author: sangyeop
categories: Sproutt-2nd
이 장의 내용
컬렉션에 parallelStream
을 호출하면 병렬 스트림이 생성된다.
병렬 스트림이란? : 각각의 스레드에서 처리할 수 있도록 스트림 요소를 여러 청크로 분할한 스트림
다음은 숫자 n을 인수로 받아서 1부터 n까지의 모든 숫자의 합계를 반환하는 메서드를 구현하는 코드이다
public long sequentialSum(long n) {
return Stream.iterate(1L, i->i+1) // 무한 자연수 스트림 생성
.limit(n) // n개 이하로 제한
.reduce(0L, Long::sum); //
}
이를 전통적인 자바로 구현하면 다음과 같다
public Long iterativeSum(long n) {
long result = 0;
for (long i = 1L; i<=n; i++) {
result += i;
}
return result;
}
여기서 parallel
메서드를 호출하면 기존 리듀스 연산이 병렬로 처리된다.
public long parallelSum(long n) {
return Stream.iterate(1L, i -> i+1)
.limit(n)
.parallel() // 스트림 병렬로 변환
.reduce(0L, Long::sum);
}
여기선 스트림이 여러 청크로 분할되어있고, 이 분할된 여러 청크들에 연산을 병렬로 수행할 수 있다. 이후 리듀스 연산으로 생성된 부분 결과를 다시 리듀싱 연산으로 합쳐서 전체 스트림의 리듀싱 결과를 도출한다.
사실 순차 스트림에 parallel
을 호출해도 스트림 자체에는 아무 변화도 없지만, 내부적으로는 이후 연산이 병렬로 수행해야함을 의미하는 불리언 플래그가 설정된다. 반대로 sequential
을 설정하면 순차 스트림으로 바꿀 수 있다.
성능 최적화를 위해서는 측정, 또 측정 하는 것이 매우 중요하다.측정을 위해서는 자바 마이크로벤치 마크 하니스(JMH) 라이브러리를 사용해 벤치마크를 구현한다.
for-loop vs stream
이를 이용해서 위 코드들을 측정하면 for-loop를 사용해 반복하는 것이 가장 저수준으로 동작할 뿐 아니라 기본값을 박싱하거나 언박싱할 필요가 없으므로 스트림을 사용했을 때에 비해 훨씬 빠르다.
순차 스트림 vs 병렬 스트림
또한 예상과는 다르게 병렬 스트림이 순차 스트림보다 훨씬 느린 결과를 보여주었는데 이는 두 가지 이유로 설명할 수 있다
iterate
는 본질적으로 순차적이며, 리듀싱을 시작하는 과정에 전체 리스트가 준비되어 있지 않으므로 스트림을 병렬로 처리하도록 청크 분할을 할 수 없다. 이 때문에 병렬 처리가 지시 되었으나, 결국 순차 방식과 결과에 있어서는 차이가 없게되고 스레드를 할당하는 오버헤드만 증가하여 결과적으로 더 느린 처리시간을 보이게 된다.
이 때문에 내부 구조를 잘 이해하지 못하고 사용할 경우 오히려 성능을 더 악화 시킬수도 있게 된다.
멀티코어 프로세스를 이용한 병렬 연산을 효과적으로 활용하기 위해서는 어떻게 해야할까?
LongStream.rangeClosed
는 기본형 long
을 직접 사용하므로 박싱과 언박싱 오버헤드가 사라진다.LongStream.rangeClosed
는 쉽게 청크로 분할할 수 있는 숫자 범위를 생산한다. 예를 들어 1-20 범위의 숫자를 각각 1-5, 6-10, 11-15, 16-20 범위로 분할할 수 있다.위와 같은 LongStream
또는 IntStream
등 기본형 특화 스트림을 사용하여 오버헤드를 없앨 수 있으며, 숫자 범위의 제한을 함으로써 for-loop 보다 더 빠른 처리 속도를 기대해 볼 수 있게된다.
많은 문제가 공유된 상태를 바꾸는 알고리즘을 사용하기때문에 일어난다. 다음은 n까지 자연수를 더하면서 공유된 누적자를 바꾸는 프로그램을 구현한 코드이다
public Long sideEffectSum(long n) {
Accumulator accumulator = new Accumulator();
LongStream.rangeClosed(1, n).forEach(accumulator::add);
return accumulator.total;
}
public class Accumulator {
public long total = 0;
public void add(long value) {
total += value;
}
}
위와 같은 코드는 본질적으로 순차 실행하도록 구현되어 있기 때문에, total에 접근할때마다 데이터 레이스가 일어날 것이다.
데이터 레이스란? : 여러 쓰레드/프로세스가 공유자원에 동시에 접근하려 할 때 일어나는 경쟁 상황
이처럼 실행하게 되면 올바른 결과값이 도출되지 않는다. 이 예제를 통해서 병렬 스트림과 병렬 계산에서는 공유된 가변 상태를 피해야 한다는 사실을 확인할 수 있었다.
병렬 스트림을 어떤 경우에 사용해야하는지 어떤 기준을 정하는 것은 매우 어렵다.
순차스트림과 병렬스트림 중 어떤 것이 좋을지 애매하다면 일단 측정하라
박싱을 주의하라. 되도록이면 기본형 특화 스트림을 사용하라
순차 스트림보다 병렬 스트림에서 성능이 떨어지는 연산들이 있다. ex) limt
, findFirst
등등
스트림에서 수행하는 전체 파이프라인 연산 비용을 고려하라. 처리해야할 요소가 N개이고 처리하는데 드는 비용이 Q라고하면 전체 비용은 N*Q가 된다. Q가 높아진다는 것은 병렬 스트림으로 성능을 개선할 여지가 있음을 의미한다.
소량의 데이터에서는 병렬 스트림은 도움이 되지 못할 가능성이 크다.
스트림을 구성하는 자료구조가 적절한지 확인하라. 예를 들어 ArrayList
는 LinkedList
와는 다르게 모든 요소를 탐색하지 않고도 리스트를 분할할 수 있기 때문에 병렬 처리에 적합하다.
ArrayList
: index를 가지고 있다. index를 가지고 있고 무작위 접근이 가능하기 때문에, 해당 index의 데이터를 한번에 가져올 수 있다.
LinkedList
: 각 원소마다 앞, 뒤 원소의 주소값을 가지고 있다. 순차 접근이기 때문에 검색 속도가 느리다.
스트림의 특성과 파이프라인 중간 연산이 스트림의 특성을 어떻게 바꾸는지에 따라서 분해 과정의 성능이 달라질 수 있다. SIZED
스트림의 경우에는 정확히 같은 크기의 두 스트림으로 분할할 수있으므로 효과적인 병렬 처리가 가능하다. 반면에 필터 연산이 있을 경우에는 스트림 길이를 예측할 수 없으므로 효과적인 처리가 어렵다.
최종 연산의 병합 과정 비용을 살펴보자. 병합 비용이 비싸면 병렬 스트림의 이득이 상쇄될 수 있다.
소스 | 분해성 |
---|---|
ArrayList | 훌륭함 |
LinkedList | 나쁨 |
IntStream.range | 훌륭함 |
Stream.iterate | 나쁨 |
HashSet | 좋음 |
TreeSet | 좋음 |
순차적이지 않아 요소 탐색 없이 분해할 수 있으면 분해성이 좋고 그렇지 않다면 나쁘다고 하는 것 같다.