본문 바로가기
Java/Effective Java 3E

[이펙티브자바 3판] ITEM48. 스트림 병렬화는 주의해서 적용하라

by 잭피 2020. 12. 15.

이번장의 핵심은...

올바르게 수행하고 성능도 빨라질거라는 확신없이 스트림 파이프라인 병렬화는 시도하지 말자


스트림은 parallel 메서드만 호출하면 자동으로 병렬 실행할 수 있습니다

하지만 주의할 점이 있습니다

// 병렬 스트림을 사용해 처음 20개의 메르센 소수를 생성하는 프로그램 
// 주의: 병렬화의 영향으로 프로그램이 종료하지 않는다.
public class ParallelMersennePrimes {
    public static void main(String[] args) {
        primes().map(p -> TWO.pow(p.intValueExact()).subtract(ONE))
                .parallel() // 스트림 병렬화
                .filter(mersenne -> mersenne.isProbablePrime(50))
                .limit(20)
                .forEach(System.out::println);
    }

    static Stream<BigInteger> primes() {
        return Stream.iterate(TWO, BigInteger::nextProbablePrime);
    }
}

병렬로 처리하기 때문에 성능이 좋아 보이지만, 실제로는 스트림 라이브러리가 이 파이프라인을 병렬화 하는 방법을 찾지 못합니다

 

1. 데이터 소스가 Stream.iterate인 경우

parallel은 병렬로 실행하기 위해서 데이터 소스를 chunk 단위로 자르는데,

iterate는 순차적으로 다음 요소를 반환하는 방식이라 chunk 단위로 자르기 어렵습니다

 

2. 중간 연산으로 limit() or findFirst 같이 요소의 순서에 의존하는 연산을 사용하는 경우

→ limit 대신 rangeClosed를 쓰자 (LongStream.rangeClosed(1,n))

limit 같은 것은 소수 찾기 처럼 나중에 갈 수록 오래걸리는 케이스에 더 주의해야합니다

 

→ 파이프라인 병렬화는 limit을 다룰 때, CPU 코어가 남는다면, 원소를 몇 개 더 처리한 후 제한된 개수 이후의 결과를 버려도 아무런 해가 없다고 가정해보자

ex) 10개의 소수 찾기를 쿼드코어 환경에서 돌린다고 가정하면, 10번째 소수를 찾는 마지막 연산에서 남는 CPU 코어가 11,12,13번째 소수를 찾는 연산을 시작한 후, 결과를 버립니다

(비효율적이고 심각하게 오래걸립니다...)

 

병렬화 사용

ArrayList, HashMap, HashSet, ConcurrentHashMap, Array, int/long

→ 스트림의 데이터 소스가 위와 같은 클래스의 인스턴스 일 때 병렬화의 효과가 가장 좋습니다

 

위 자료구조들은 모두 데이터를 원하는 크기로 정확하고 손쉽게 나눌 수 있어 다수의 스레드에 분배하기에 좋습니다

 

나누는 작업은 Spliterator가 담당하며,

Spliterator 객체는 Stream, Iterable의 spliterator() 메서드로 얻어올 수 있습니다

 

또한, 위 자료구조는 참조 지역성이 높아 성능이 좋습니다

 

데이터의 지역성은 다음의 세가지로 분류하는데 아래와 같습니다

 

공간적 지역성 (partial locality)

메인메모리에서 CPU가 요청한 주소지점의 데이터에 인접한 주소들이 앞으로 참조될 확률이 높음을 의미합니다

이웃한 원소들의 참조가 메모리에 연속적으로 저장되어 있어 다음 참조에 대한 접근 속도가 빠르게 합니다

 

시간적 지역성 (temporal locality)

한번 참조되었던 데이터는 후에 다시 참조될 가능성이 높음을 의미합니다

 

순차적 지역성 (sequential locality)

따로 분기가 없는 한 데이터가 기억장치에 저장된 순서대로 인출되고 실행될 가능성이 높음을 의미합니다 (FIFO)

 

종단 연산

스트림 파이프라인의 종단연산의 동작방식 역시 병렬 수행 효율에 영향을 줍니다

종단 연산 중 병렬화에 가장 적합한 것은 축소(reduction) 입니다 

축소는 파이프라인에서 만들어진 모든 원소를 하나로 합치는 작업입니다

ex) min, max, sum, count 같이 완성된 형태로 제공되는 메서드

 

anyMatch, allMatch, noneMatch 처럼 조건에 맞으면 바로 반환되는 메서드도 병렬화에 적합합니다

 

반면, 가변 축소(Mutable Reduction)을 수행하는 Stream의 collect 메서드는 병렬화에 적합하지 않습니다

→ 컬렉션들을 합치는 부담이 큽니다

 

병렬화에 대해 잘모르면 안하는게 낫습니다
→ 잘못 병렬화하면 성능이 나빠질 뿐만 아니라 결과 자체가 잘못되거나 예상 못한 동작을 할 수 있습니다
(결과가 잘못되거나 오동작하는 것은 안전 실패(safety failure)라고 합니다)

 

스트림 파이프라인 병렬화 효과적인 예

ex) n보다 작거나 같은 소수의 개수를 계산하는 함수

static long pi(long n) {
    return LongStream.rangeClosed(2, n)
                     .mapToObj(BigInteger::valueOf)
                     .filter(i -> i.isProbablePrime(50))
                     .count();
} // 31초
static long pi(long n) {
    return LongStream.rangeClosed(2, n)
                     .parallel() // 병렬화!
                     .mapToObj(BigInteger::valueOf)
                     .filter(i -> i.isProbablePrime(50))
                     .count();
} // 9.2초

Random한 수의 경우

무작위 수들로 이뤄진 스트림을 병렬화하려거든 ThreadLocalRandom보다는 SplittableRandom 인스턴스를 이용합니다

 

SplittableRandom

보통 ThreadLocalRandom을 쓰는데, 병렬 스트림에는 SplittableRandom을 사용합니다

SplittableRandom은 정확히 이럴 때 쓰고자 설계된 것이라 병렬화 하면 성능이 선형으로 증가합니다.

한편 ThreadLocalRandom은 단일 스레드에서 사용하고자 만들어졌습니다

 

 

 

이 글은 “이펙티브 자바 3판” 책 내용을 정리한 글입니다.

만약 저작권 관련 문제가 있다면 “shk3029@kakao.com”로 메일을 보내주시면, 바로 삭제하도록 하겠습니다.

 

 

댓글