Effective Java의 아이템들을 보면, 그리고 추후 아이템에는 더 빈번하게 동시성 처리, 쓰레드 안전성에 대한 내용들이 나온다.
사실 백엔드 개발자 취준생의 입장에서 관련된 많은 부분들을 스프링 프레임워크 등이 도와주기 때문에 관련 내용들을 미니 프로젝트에서 직접 적용하거나 혹은 관련 문제를 겪을 일이 거의 없다고 생각한다.
그래서 관련 내용의 아이템들을 넘어갈까 했지만 전체 아이템들을 정리하는 과정에서 중간에 아이템이 빠지는 것도 아쉽고, 그것보다도 계속 개발자로 살아가면서 관련 문제를 만나면 이 개념들을 더 알고 있는 것이 도움이 되지 않을까 싶어 그냥 정리하기로 했다.
이해하는데 다른 아이템들에 비해 시간이 더 걸리겠지만 나중에 다시 보아도 최대한 이해가 쉽도록 정리해보고자 한다.
스트림 병렬화는 주의해서 적용하라
자바는 처음 릴리스될 때부터 쓰레드, 동기화, wait/notify를 지원하고 Java 5부터는 동시성 컬렉션인 java.util.concurrent 라이브러리와 Executor 프레임워크를 지원했다. 동시성 프로그래밍에 진심인 언어이다.
Java 7부터는 고성능 병렬 분해 프레임워크인 fork-join 패키지도 추가되었고, Java 8부터는 parallel 메서드만 한 번 호출하면 파이프라인을 병렬 실행할 수 있는 스트림을 지원했다.
그런데 자바로 동시성 프로그램을 작성하기 쉬워졌지만 여전히 올바르고 빠르게 작성하는 일은 어렵다. 동시성 프로그래밍은 안전성과 응답 가능 상태를 유지하기 위해 노력해야 한다.
- 안전성
- 프로그램이 실행되는 동안 데이터의 일관성과 무결성을 보장하는 것을 뜻한다.
- 여러 쓰레드가 동시에 데이터를 읽고 쓸 수 있기 때문에 데이터 경합, 데드락 등의 문제를 방지하기 위함이다.
- 응답 가능 상태
- 시스템이 계속해서 응답 가능한 상태를 유지하는 것을 의미한다.
- 시스템이 교착 상태에 빠지지 않고 모든 작업이 적절한 시간 내에 완료되는 것을 목표로 한다.
이번 아이템에서 다룰 것은 병렬 스트림 파이프라인이다.
병렬 스트림 파이프라인
1
2
3
4
5
6
7
8
9
10
11
12
List<Integer> primes = IntStream.rangeClosed(start, end)
.filter(num -> isPrime(num))
.boxed()
.collect(Collectors.toList());
public static boolean isPrime(int number) {
if (number <= 1) return false;
for (int i = 2; i <= Math.sqrt(number); i++) {
if (number % i == 0) return false;
}
return true;
}
- 주어진 범위 내의 숫자들 중 소수인 수만 List에 저장하는 코드이다.
- 걸리는 시간을 측정해보자.
- 1부터 100만까지로 측정해보니 320ms가 걸렸다.
속도를 높이기 위해 스트림 파이프라인의 parallel()을 호출하면 어떻게 될까?
1
2
3
4
5
List<Integer> primes = IntStream.rangeClosed(start, end)
.parallel()
.filter(num -> isPrime(num))
.boxed()
.collect(Collectors.toList());
- 82ms가 걸린다.
- 문제 없이 성능을 향상시킬 수 있음을 확인했다. 정말 편하다. 단순히 parallel()을 추가했을 뿐인데 성능이 크게 향상되었다.
병렬 스트림 파이프라인을 잘못 사용한 경우
1
2
3
4
5
List<Integer> primes = Stream.iterate(1, n -> n + 1)
.limit(1000000)
.filter(num -> isPrime(num))
.boxed()
.collect(Collectors.toList());
- 위 코드는 앞의 예시와 똑같은 동작을 하는 코드이다.
- Stream.iterate를 통해 무한 스트림을 생성하고, 무한 스트림에 대해 limit을 사용하면 스트림이 그 숫자까지의 값을 생성한다.
- 성능은 330ms로 기존 코드와 거의 차이나지 않는다.
위 코드에도 parallel()을 사용해 파이프라인을 병렬 실행하도록 해보자.
1
2
3
4
5
6
List<Integer> primes = Stream.iterate(1, n -> n + 1)
.parallel()
.limit(1000000)
.filter(num -> isPrime(num))
.boxed()
.collect(Collectors.toList());
- 성능이 153ms로 줄어들었다.
- 성능을 비교적 개선하지 못했다. 그리고 책 내용에 의하면 실행이 끝마치지 못하는 현상까지도 볼 수 있다.
- limit을 10억까지 더 늘려보자.
- OutOfMemoryError: Java heap space 에러가 발생한다.
- 자바 프로그램이 힙 영역에서 사용할 수 있는 메모리를 모두 소진했을 때 발생하는 에러이다.
- 객체를 동적으로 할당하는데 사용되는 영역이다.
- Stream.iterate()는 초기값과 다음 값을 생성하는 람다 함수를 받는다.
- 그리고 Stream.iterate()는 무한 스트림을 생성한다. 그러나 limit을 사용하여 스트림의 크기를 제한할 수 있다.
- 그런데 문제는 모든 요소가 메모리에 유지된다. 모든 요소가 저장된 후에야 필터링 및 Collect가 수행된다. 따라서 에러가 발생하게 된 것이다.
- 생성된 무한 스트림에 limit으로 제한한 값(10억)만큼의 값을 메모리에 유지해야 하는데 그 크기가 너무 커서 에러가 발생한 것이다.
- 자바 프로그램이 힙 영역에서 사용할 수 있는 메모리를 모두 소진했을 때 발생하는 에러이다.
반면, IntStream을 사용한 코드는 어떨까?
- IntStream을 사용한 코드도 end값을 10억까지 늘려보았다.
- 에러는 발생하지 않고 계산 시간이 매우 오래 걸린다.
- IntStream의 내부 동작
- IntStream.rangeClosed()는 주어진 범위의 숫자들을 생성하는 유한 스트림을 만든다.
- 유한 스트림이고 명확한 범위를 가진다. 메모리를 더 효율적으로 사용한다.
- 중간 결과를 메모리에 유지하지 않는다. 메모리에 범위만큼의 값을 한 번에 저장하는 것이 아닌 필요한 만큼만 메모리에 유지하고 나머지는 즉시 처리한다.
- 이미 범위를 알고 있기 때문에 가능한 처리 방식이다.
이 부분은 사실 병렬 처리와의 관계보다는 Stream.iterate()의 동작을 더 잘 이해하기 위해 설명한 부분이다. 우리가 집중해야 할 부분은 왜 limit()을 사용한 코드에서 성능 개선이 덜 이루어졌는지이다.
Stream.iterate, limit을 사용했을 때 왜 병렬화 성능 개선이 덜 이루어졌을까?
스트림 라이브러리가 파이프라인을 병렬화하는 방법을 찾아내지 못했을 때 문제가 생긴다. 데이터 소스가 Stream.iterate거나 중간 연산으로 limit을 쓰면 파이프라인 병렬화로는 성능 개선을 기대할 수 없다.
- 파이프라인 병렬화는 limit을 다룰 때 CPU 코어가 남는다면 원소를 몇 개 더 처리한 후 제한된 개수 이후의 결과를 버려도 아무런 해가 없다고 가정한다.
- 이 가정이 문제를 발생시킨다.
- 병렬 스트림에서 limit을 사용할 때 여러 CPU 코어가 병렬로 작업을 수행한다.
- 그런데 limit은 결과를 제한하는 연산이다. 스트림의 결과를 지정한 수 만큼의 요소로 제한한다.
- 병렬 스트림은 각각의 쓰레드가 독립적으로 스트림의 일부를 계산하게 된다. 이 때 limit의 100만개 수보다 더 많은 부분을 계산한다.
- 그 후에 limit을 통해 값을 100만개로 제한하게 되어, 일부 결과가 버려져 비효율적이게 되는 것이다.
- IntStream의 경우랑 비교해보면, 스트림이 유한하기 때문에 확실한 그 범위만을 계산하기 때문에 문제가 없다.
책에서의 예시 (메르센 소수)
책에서는 메르센 소수를 예시로 들고 있다.
- 새롭게 메르센 소수를 찾을 때 마다 그 전 소수를 찾을 때 보다 두 배 정도 더 오래 걸린다.
- 원소 하나를 계산하는 비용이 대략 그 이전까지의 원소 전부를 계산한 비용을 합친 것 만큼 든다.
- 위 이유는 메르센 소수를 계산할 때 2^p의 계산을 해야하는데 p가 증가할 때마다 해당 계산의 비용이 매우 커지기 때문이다.
그런데 우리가 학습했다시피 limit으로 값을 제한하기 전에 독립적인 쓰레드가 limit의 제한 범위를 벗어나는 값을 계산할 수 있는데, 그 때 그 값이 큰 수라면 계산 비용이 매우 큰 것을 계산해놓고 버리는 비효율을 보이게 되는 것이다.
결론적으로는 스트림 파이프라인을 마구잡이로 병렬화하면 문제가 생길 수 있다는 것이다. 성능의 개선을 목적에 두었지만 오히려 반대의 결과를 줄 수 있는 것이다.
스트림 병렬화의 효과가 좋은 경우
- ArrayList
- HashMap, HashSet
- ConcurrentHashMap
- 배열
- int 범위, long 범위
스트림의 소스가 위 경우의 인스턴스이거나, 범위일 때 병렬화의 효과가 가장 좋다.
- 해당 자료구조들의 공통점
- 데이터를 원하는 크기로 정확하고 손쉽게 나눌 수 있다. 원하는 작업을 다수의 쓰레드에 적절히 분배하기에 좋다.
- 나누는 작업은 Spliterator가 담당하고 Spliterator 객체는 Stream이나 Iterable의 spliterator 메서드로 얻어올 수 있다.
- 원소들을 순차적으로 실행할 때의 참조 지역성이 뛰어나다. 이웃한 원소들이 메모리에 연속해서 저장되어 있다.
- 만약 참조 지역성이 낮으면 쓰레드는 데이터가 주 메모리에서 캐시 메모리로 전송되어 오기를 기다리며 허비하는 시간이 길어져 비효율적이다.
- 데이터를 원하는 크기로 정확하고 손쉽게 나눌 수 있다. 원하는 작업을 다수의 쓰레드에 적절히 분배하기에 좋다.
그리고 또 스트림 병렬화 성능에 영향을 줄 수 있는 요소는 종단 연산의 동작 방식이다.
- 종단 연산이 순차적인 연산이라면 병렬 수행의 효과는 당연히 제한된다.
- 종단 연산 중 병렬화에 가장 적합한 것은 축소이다.
- min, max, count, sum과 같이 완성된 형태로 제공되는 메서드 중 하나를 수행하는 경우.
- anyMatch, allMatch, noneMatch처럼 조건에 맞으면 바로 반환되는 경우
- 스트림의 요소들을 하나의 값으로 결합하는 연산인데, 각각의 일부 범위들을 각 쓰레드가 계산한 후 합치면 당연히 효율적일 것이다.
- 반대로 적합하지 않은 경우는 가변 축소를 수행하는 collect 메서드의 경우이다.
- 컬렉션을 합치는 부담이 크기 때문이다.
결론 및 정리
스트림 병렬화는 오직 성능화 최적화 수단일 뿐이다. 변경 전후로 성능을 테스트해 병렬화를 사용할 가치가 있는 지 반드시 확인해야 한다.
실제로 위에서 설명한 제한적인 부분때문에 병렬화할 일이 별로 없을 수 있다.
하지만 조건이 잘 갖춰진 상태라면 parallel() 하나만으로 큰 성능 향상을 기대할 수 있기 때문에 계산을 올바르게 수행하고 성능도 빨라질 것이라는 확신이 있다면 스트림 파이프라인 병렬화를 시도해보자.