개발 서적/모던 자바 인 액션

[모던 자바 인 액션] Chapter7. 병렬 데이터 처리와 성능

더즈 2022. 3. 2. 14:42

스트림을 사용하면 내부 반복으로 네이티브 자바 라이브러리가 스트림 요소의 처리를 제어할 수 있다. 따라서 개발자는 컬렉션 데이터 처리 속도를 높이려고 따로 고민할 필요가 없다. 컴퓨터의 멀티코어를 활용해서 파이프라인 연산을 실행할 수 있다는 점이 중요한 특징이다.

7.1 병렬 스트림

  • 컬렉션에 parallelStream을 호출하면 병렬 스트림이 생성된다.
  • 병렬 스트림이란 각각의 스레드에서 처리할 수 있도록 스트림 요소를 여러 청크로 분할한 스트림이다.
  • 따라서 모든 멀티코어 프로세서가 각각의 청크 처리를 할당할 수 있다.

병렬 실행이 무조건 빠른 것은 아니다.

static long sum(int n) {
   long sum = 0;
   for (int i = 0; i < n; i++) {
      sum += 1;
   }
   return sum;
}

static long sequentialSum(long n) {
   return Stream.iterate(1L, i -> i + 1)
      .limit(n)
      .reduce(0L, Long::sum);
}

static long parallelSum(long n) {
   return Stream.iterate(1L, i -> i + 1)
      .limit(n)
      .parallel()
      .reduce(0L, Long::sum);
}
  • 이 세 함수에 대해서 일반 반복 > 순차 스트림 > 병렬 스트림 순으로 속도가 빨랐다.
  • 문제점
    • 반복 결과로 박싱된 객체가 만들어지므로 숫자를 더하려면 언박싱을 해야 한다.
    • 반복 작업은 병렬로 수행될 수 있는 독립 단위로 나누기가 어렵다.
      • 위의 iterate 연산은 이전 연산의 결과에 따라 다음 함수의 입력이 달라지기 때문에 청크로 분할하기 어려운 것이다.
      • 리듀싱 과정을 시작하는 시점에 전체 숫자 리스트가 준비되지 않았기 때문에 분할할 수 없는 것
      • 결국 순차 처리와 다를 게 없는데 스레드 할당 오버헤드만 증가

특화된 메서드 사용 LongStream.rangeClosed

static long sequentialSum(long n) {
   return LongStream.rangeClosed(1L, n)
      .reduce(0L, Long::sum);
}

static long parallelSum(long n) {
   return LongStream.rangeClosed(1L, n)
      .parallel()
      .reduce(0L, Long::sum);
}
  •  LongStream.rangeColsed는 기본형을 직접 사용하므로 언박싱 오버헤드가 사라진다.
  • LongStream.rangeColsed는 쉽게 청크로 분할할 수 있는 숫자 범위를 생산한다.
  • 결과로 일반 반복 > 병렬 스트림 > 순차 스트림 순으로 빨랐다.

-> 올바른 자료구조를 선택해야 병렬 실행도 최적의 성능을 발휘할 수 있다.

 

병렬화가 공짜는 아니다.

  • 스트림을 재귀적으로 분할
  • 각 서브 스트림을 서로 다른 스레드의 리듀싱 연산으로 할당
  • 이들 결과를 하나의 값으로 합치기
  • 멀티 코어간 이동은 생각보다 비싸다.
  • 따라서 코어간 데이터 전송보다 훨씬 오래 걸리는 작업만 병렬로 수행하는 것이 바람직

병렬 스트림은 올바르게 동작하려면 공유된 가변 상태를 피해야 한다.

static long sideEffectSum(long n) {
   Accumulator accumulator = new Accumulator();

   LongStream.rangeClosed(1, n).parallel().forEach(accumulator::add);

   return accumulator.total;
}

static class Accumulator {
   public long total = 0;

   public void add(long value) {
      total += value;
   }
}
  • total에 접근할 때마다 (다수의 스레드에서 동시에 데이터에 접근하는) 데이터 레이스 문제가 일어난다.
  • 성능은 둘째 치고 올바른 결괏값이 나오지 않는다.

병렬 스트림 효과적으로 사용하기

  • 확신이 서지 않으면 직접 측정하라
  • 박싱을 주의하라 (기본형 특화 스트림을 사용하자)
  • 순차 스트림보다 병렬 스트림에서 성능이 떨어지는 연산이 있다.
    • limit나 findFirst처럼 순서 의존 연산은 병렬에서 비싼 비용을 치른다.
    • findAny가 더 성능이 좋다.
    • 정렬된 스트림에 unordered를 호출하면 비 정렬된 스트림을 얻을 수 있다. 요소 순서가 상관없다면 비정렬 스트림에 limit를 호출하는 것이 더 효율적이다.
  • 스트림에서 수행하는 전체 파이프라인 연산 비용을 고려하라.
    • 처리 요소 수가 N이고 요소당 처리 비용이 Q일 때 총 처리 비용 = N*Q
    • Q가 높아진다는 것은 병렬 스트림으로 성능 개선 가능성이 있다.
  • 소량의 데이터에서는 병렬 스트림이 도움이 되지 않는다.
  • 스트림을 구성하는 자료구조가 적절한지 확인하라
    • LinkedList보다 ArrayList가 분해가 효율적이다.
    • range 팩토리 메서드로 만든 기본형 스트림도 쉽게 분해할 수 있다.
    • 커스텀 Spliterator를 구현해서 분해 과정 완벽 제어 가능
  • 스트림의 특성과 중간 연산이 스트림을 어떻게 바꾸는지에 따라 분해 과정의 성능이 달라질 수 있다.
    • 필터 연산이 있으면 스트림 길이 예측이 어렵기 때문에 효과적으로 병렬 처리 가능한지 알 수 없게 된다.
  • 최종 연산의 병합 과정 비용을 살펴보라.
    • 병합 비용이 비싸다면 병렬로 얻은 성능 이익이 상쇄될 수 있다.
  • 병렬 스트림이 수행되는 내부 인프라 구조도 살펴봐야 한다.
    • 포크/조인 프레임워크