Effective Java : 아이템 48. 스트림 병렬화는 주의해서 적용하라.

    아이템 48. 스트림 병렬화는 주의해서 적용하라.

    • 스트림 병렬화는 paralle() 메서드를 호출해서 할 수 있음.
    • 스트림 병렬화를 할 때는 Spliterator 객체의 trySplit() 메서드를 호출해서 각 쓰레드별로 진행할 작업을 나눔.
    • 객체별로 고유한 Spliterator가 구현되어 있지 않으면, Stream 클래스의 Spliterator를 사용함. 
    • 병렬 스트림이 효율적이지 않은 경우
      • 작업을 효율적으로 나누지 못하는 경우. (Spliterator가 없는 경우, 자료구조가 적합하지 않은 경우)
      • 데이터 소스로 Stream.iterate를 사용하는 경우.
      • 스트림 중간에 limit()을 사용하는 경우
    • 병렬 스트림이 효율적인 경우
      • 작업 나누기 쉬운 자료구조 + 참조 지역성이 좋은 경우.
      • 예를 들면 배열, ArrayList, Int range, Long range 같은 녀석들이 좋음.
    • 병렬 스트림은 성능 최적화 수단임. → 안하는게 가장 좋을 수도 있음.
    • 병렬 스트림은 순서를 보장하지 않음. 따라서 순서 보장이 필요한 경우 forEach() 대신 forOrderedEach()를 호출해야 함. 
    • 병렬 스트림에 적합한 메서드
      • 종단 연산(마지막 연산)에 필요한 시간이 많은 경우를 가정하면 
      • reducing 관련 연산이 효율적임. counting(), maxby(), nontMatch() 등. 왜냐하면 처리할 원소가 적어지기 때문임. 

     


    스트림 병렬화

        public static void main(String[] args) {
            List<String> words = Arrays.asList("apple", "banana", "cherry", "date", "elderberry", "fig", "grape");
    
            // 병렬 스트림을 사용하여 각 단어의 길이를 출력
            words.stream()
                 .parallel()  // 스트림을 병렬 모드로 설정
                 .map(String::length)
                 .forEach(System.out::println);
        }

    스트림은 각 구성요소들에 대한 일련의 계산이 진행되는 것이다. 스트림은 parallel() 메서드를 통해서 병렬 모드로 실행될 수 있도록 지원한다. 자바 스트림에서 병렬로 처리될 때는 스트림의 Spliterator의 trySplit()을 호출해 각 쓰레드가 실행할 작업을 분리해서 병렬로 실행한다. 

    만약 객체의 Spliterator가 구현되어 있다면 그 객체의 Spliterator를 이용해 작업을 나눈다. 그렇지만 고유한 Spliterator가 구현되어 있지 않다면 Stream이 제공하는 기본적인 Spliterator를 이용한다. 그런데 Spliterator가 적절하게 작업을 나누지 못한다면 병렬 처리가 오히려 더 느려지는 경우가 발생한다. 

    예를 들어 ArrayList 같은 경우에는 Spliterator를 통해서 효율적으로 작업을 쪼갤 수 있다. 배열을 기반으로 구성되어있기 때문에 인덱스를 기반으로 손쉽게 작업을 쪼갤 수 있기 때문이다. 그러나 LinkedHashMap의 경우 효율적으로 작업을 분리할 수 없어서, 오히려 병렬 처리가 더 느려질 수도 있다. 

     


    병렬 스트림이 문제되는 경우

    병렬 스트림이 자주 문제되는 상황은 아래 세 가지 경우일 때다. 

    • 작업을 효율적으로 나누는 방법을 찾지 못하는 경우.
    • 데이터 소스로 Stream.iterate를 사용하는 경우
    • 스트림 중간에 limit()를 사용하는 경우. 

    작업을 효율적으로 나누는 방법을 찾지 못하는 경우는 소스 데이터 타입의 Spliterator가 구현되어 있지 않거나, 데이터 타입 자체가 병렬처리에 적합하지 않은 경우다. 예를 들어 ArrayList는 배열 기반으로 구성되어 있기 때문에 인덱스 기반으로 작업을 나누기 쉽지만, LinkedList는 효율적으로 작업을 나누기 어렵다. 

    limit()을 사용하는 경우는 병렬 스트림의 동작 방식 때문에 문제가 된다. 병렬 스트림은 limit를 다룰 때 CPU 코어가 남는다면 원소를 몇 개 더 처리한 후 제한된 개수 이후의 결과를 버려도 아무런 해가 없다고 가정한다. 따라서 몇 개의 작업을 불필요하게 더 할 수 있고, 이 비용이 크다면 오히려 병렬 처리가 더 느려질 수 있다. 


    병렬 스트림에 효율적인 자료구조

    다음 자료구조에서 병렬 스트림을 처리하는게 효율적이다.

    • ArrayList
    • HashMap
    • HashSet
    • ConcurrentHashMap
    • 배열
    • int 범위
    • long 범위

    이 자료구조들은 데이터를 원하는 크기로 정확하고 손쉽게 나눌 수 있어서 일을 다수의 스레드에 손쉽게 분배할 수 있다는 특징이 있다. 

     


    병렬 스트림이 효율적인 자료구조 - 또 다른 이유

    위에서 이야기 한 자료구조들의 또 다른 공통점은 참조 지역성 (Locality of reference)가 뛰어나다는 점이다. 이것은 자료구조 내에서 이웃한 원소들의 참조값들이 모두 메모리에 연속되어 저장되어 있다는 것이다. 

    만약 참조 지역성이 낮으면, 스트림에서 다음 원소의 값을 메모리 → 캐시까지 불러오는 시간동안 코어가 멍하니 있게 된다. 따라서 참조 지역성은 다량의 데이터를 처리하는 벌크 연산을 병렬화 할 때 중요한 요소가 된다.


    병렬 스트림에 적합한 메서드

    스트림에서 종단 연산(마지막 연산)이 차지하는 비용이 큰 경우를 가정해보자. 이런 경우라면 병렬 스트림에 적합한 메서드가 있다. 

    • forEach() : 모든 원소에 대해서 작업을 해야하기 때문에 비용이 비쌈. 
    • reduction() : 축소는 파이프라인에서 발생한 원소를 병합하는 작업을 하기 때문에 종단 연산 비용을 저렴하게 가져가 줌.
    • anyMatch,  allMatch, noneMatch : 조건에 맞으면 바로 반환되는 메서드도 병렬화에 적합함. 

     


    병렬 스트림은 순서를 보장하지 않음.

    병렬 스트림은 먼저 작업이 끝나는 것들을 스트림의 원소로 다시 재구성한다. 따라서 처음 사용되었던 소스와 순서가 달라질 수 있으며,  병렬 스트림의 마지막 연산을 forEach()로 호출하면서 순서대로 출력되길 기대하더라도, 기대한 것과 다른 순서대로 출력될 수도 있다. 이럴 때는 forEach() 대신 forEachOrdered()로 호출하면 된다. 


    스트림 병렬화는 성능 최적화 수단임

    • 스트림 병렬화는 성능 최적화 수단이다. 따라서 스트림 병렬화는 필요없을 가능성이 높고, 만약 스트림 병렬화를 적용했다면 적용 전/후로 성능 측정을 해서 개선점을 확인해야만 한다. 
    • 일반적으로 스트림 안의 원소 수 * 원소 당 수행 코드 수 > 수십만 인 경우에 시도 해볼만함.
    • 스트림 병렬화에서 사용되는 쓰레드는 공통의 포크-조인 풀에서 수행됨. 따라서 스트림 병렬화의 문제가 전체 어플리케이션으로 퍼질 수 있음. 

     


    스트림 병렬화로 이득을 보는 경우. 

    // 병렬 스트림이 적당한 경우.
    static long piParallel(long n) {
        return LongStream.rangeClosed(2, n)
                .parallel()
                .mapToObj(BigInteger::valueOf)
                .filter(i -> i.isProbablePrime(50))
                .count();
    }
    • 앞서 이야기한 것처럼 int, long의 레인지 범위 내에서의 연산은 작업을 나누기 편리하기 때문에 병렬 스트림을 했을 때 성능 개선 가능성이 있을 때 효과가 있다. 
    • 또한 count()를 호출해서 reducing 연산을 하기 때문에 더욱 병렬 스트림의 효과가 기대된다.

    위의 행위를 병렬로 하는 경우에 실제로 성능이 얼마나 개선되는지 확인하면 아래와 같다. 실제로는 6배 정도의 성능 개선이 있는 것을 확인할 수 있다. 

    n = 500,000 걸린 시간
    병렬 X 1364 ms
    병렬 O 223  ms

     

    댓글

    Designed by JB FACTORY