1. 개요

Java 8에서는 컬렉션을 데이터 스트림으로 쉽게 반복할 수 있는 Stream API 를 도입했습니다. 병렬로 실행되고 여러 프로세서 코어를 사용하는 스트림을 만드는 것도 매우 쉽습니다.

더 많은 코어에서 작업을 나누는 것이 항상 더 빠르다고 생각할 수도 있습니다. 하지만 그렇지 않은 경우가 많습니다.

이 사용방법(예제)에서는 순차 스트림과 병렬 스트림의 차이점을 살펴봅니다. 먼저 병렬 스트림에서 사용하는 기본 포크 조인 풀을 살펴보겠습니다.

또한 메모리 지역 및 분할/병합 비용을 포함하여 병렬 스트림 사용의 성능 영향을 고려할 것입니다.

마지막으로 순차 스트림을 병렬 스트림으로 변환하는 것이 합리적일 때 권장합니다.

2. 자바의 스트림

Java 의 스트림 은 단순히 데이터 소스를 감싸는 래퍼이므로 편리한 방식으로 데이터에 대한 대량 작업을 수행할 수 있습니다.

데이터를 저장하거나 기본 데이터 소스를 변경하지 않습니다. 오히려 데이터 파이프라인에서 기능 스타일 작업에 대한 지원을 추가합니다.

2.1. 순차 스트림

기본적으로 Java의 모든 스트림 작업은 명시적으로 병렬로 지정되지 않는 한 순차적으로 처리됩니다.

순차 스트림은 단일 스레드를 사용하여 파이프라인을 처리합니다.

List<Integer> listOfNumbers = Arrays.asList(1, 2, 3, 4);
listOfNumbers.stream().forEach(number ->
    System.out.println(number + " " + Thread.currentThread().getName())
);

이 순차 스트림의 출력은 예측 가능합니다. List 요소는 항상 순서대로 인쇄됩니다.

1 main
2 main
3 main
4 main

2.2. 병렬 스트림

Java의 모든 스트림은 순차에서 병렬로 쉽게 변환될 수 있습니다.

이것은 순차 스트림에 병렬 메소드를 추가 하거나 컬렉션 의 parallelStream 메소드를 사용하여 스트림을 생성 함으로써 달성할 수 있습니다 .

List<Integer> listOfNumbers = Arrays.asList(1, 2, 3, 4);
listOfNumbers.parallelStream().forEach(number ->
    System.out.println(number + " " + Thread.currentThread().getName())
);

병렬 스트림을 사용하면 별도의 코어에서 병렬로 코드를 실행할 수 있습니다. 최종 결과는 각 개별 결과의 조합입니다.

그러나 실행 순서는 우리가 통제할 수 없습니다. 프로그램을 실행할 때마다 변경될 수 있습니다.

4 ForkJoinPool.commonPool-worker-3
2 ForkJoinPool.commonPool-worker-5
1 ForkJoinPool.commonPool-worker-7
3 main

3. 포크-조인 프레임워크

병렬 스트림은 포크 조인 프레임워크와 작업자 스레드의 공통 풀을 사용합니다.

fork-join 프레임워크는 여러 스레드 간의 작업 관리를 처리하기 위해 Java 7의 java.util.concurrent 에 추가되었습니다.

3.1. 소스 분할

fork-join 프레임워크는 작업자 스레드 간에 소스 데이터를 분할하고 작업 완료 시 콜백을 처리하는 역할을 합니다.

정수의 합을 병렬로 계산하는 예를 살펴보겠습니다.

우리는 reduce 메소드를 사용하고 0에서 시작하는 대신 시작 합계에 5를 더할 것입니다.

List<Integer> listOfNumbers = Arrays.asList(1, 2, 3, 4);
int sum = listOfNumbers.parallelStream().reduce(5, Integer::sum);
assertThat(sum).isNotEqualTo(15);

순차 스트림에서 이 작업의 결과는 15입니다.

그러나 축소 작업이 병렬로 처리되기 때문에 실제로 모든 작업자 스레드에서 숫자 5가 추가됩니다.

실제 결과는 공통 ​​fork-join 풀에서 사용되는 스레드 수에 따라 다를 수 있습니다.

이 문제를 해결하려면 병렬 스트림 외부에 숫자 5를 추가해야 합니다.

List<Integer> listOfNumbers = Arrays.asList(1, 2, 3, 4);
int sum = listOfNumbers.parallelStream().reduce(0, Integer::sum) + 5;
assertThat(sum).isEqualTo(15);

따라서 어떤 작업을 병렬로 실행할 수 있는지 주의해야 합니다.

3.2. 공통 스레드 풀

공통 풀의 스레드 수는 프로세서 코어 수와 같습니다.

그러나 API를 사용하면 JVM 매개변수를 전달하여 사용할 스레드 수를 지정할 수 있습니다.

-D java.util.concurrent.ForkJoinPool.common.parallelism=4

이것은 전역 설정이며 모든 병렬 스트림과 공통 풀을 사용하는 다른 모든 분기 조인 작업에 영향을 미친다는 점을 기억하는 것이 중요합니다. 매우 타당한 이유가 없는 한 이 매개변수를 수정하지 않는 것이 좋습니다 .

3.3. 사용자 지정 스레드 풀

기본 공통 스레드 풀 외에도 사용자 지정 스레드 풀 에서 병렬 스트림을 실행할 수도 있습니다 .

List<Integer> listOfNumbers = Arrays.asList(1, 2, 3, 4);
ForkJoinPool customThreadPool = new ForkJoinPool(4);
int sum = customThreadPool.submit(
    () -> listOfNumbers.parallelStream().reduce(0, Integer::sum)).get();
customThreadPool.shutdown();
assertThat(sum).isEqualTo(10);

Oracle은 공통 스레드 풀 을 사용하는 것을 권장합니다. 사용자 지정 스레드 풀에서 병렬 스트림을 실행해야 하는 매우 좋은 이유가 있습니다.

4. 성능상의 영향

병렬 처리는 다중 코어를 완전히 활용하는 데 도움이 될 수 있습니다. 그러나 다중 스레드 관리, 메모리 지역성, 소스 분할 및 결과 병합의 오버헤드도 고려해야 합니다.

4.1. 오버헤드

정수 스트림의 예를 살펴보겠습니다.

순차 및 병렬 축소 작업에 대한 벤치마크를 실행합니다.

IntStream.rangeClosed(1, 100).reduce(0, Integer::sum);
IntStream.rangeClosed(1, 100).parallel().reduce(0, Integer::sum);

이 간단한 합계 감소에서 순차 스트림을 병렬 스트림으로 변환하면 성능이 저하됩니다.

Benchmark                                                     Mode  Cnt        Score        Error  Units
SplittingCosts.sourceSplittingIntStreamParallel               avgt   25      35476,283 ±     204,446  ns/op
SplittingCosts.sourceSplittingIntStreamSequential             avgt   25         68,274 ±       0,963  ns/op

그 이유는 때때로 스레드, 소스 및 결과를 관리하는 오버헤드가 실제 작업을 수행하는 것보다 비용이 많이 드는 작업이기 때문입니다.

4.2. 비용 분할

데이터 소스를 균등하게 분할하는 것은 병렬 실행을 활성화하는 데 필요한 비용이지만 일부 데이터 소스는 다른 데이터 소스보다 더 잘 분할됩니다.

ArrayListLinkedList 를 사용하여 이것을 시연해 보겠습니다 .

private static final List<Integer> arrayListOfNumbers = new ArrayList<>();
private static final List<Integer> linkedListOfNumbers = new LinkedList<>();

static {
    IntStream.rangeClosed(1, 1_000_000).forEach(i -> {
        arrayListOfNumbers.add(i);
        linkedListOfNumbers.add(i);
    });
}

두 가지 유형의 List에서 순차 및 병렬 축소 작업에 대한 벤치마크를 실행할 것입니다.

arrayListOfNumbers.stream().reduce(0, Integer::sum)
arrayListOfNumbers.parallelStream().reduce(0, Integer::sum);
linkedListOfNumbers.stream().reduce(0, Integer::sum);
linkedListOfNumbers.parallelStream().reduce(0, Integer::sum);

우리의 결과는 순차 스트림을 병렬 스트림으로 변환하면 ArrayList 에 대해서만 성능상의 이점이 있음을 보여줍니다 .

Benchmark                                                     Mode  Cnt        Score        Error  Units
DifferentSourceSplitting.differentSourceArrayListParallel     avgt   25    2004849,711 ±    5289,437  ns/op
DifferentSourceSplitting.differentSourceArrayListSequential   avgt   25    5437923,224 ±   37398,940  ns/op
DifferentSourceSplitting.differentSourceLinkedListParallel    avgt   25   13561609,611 ±  275658,633  ns/op
DifferentSourceSplitting.differentSourceLinkedListSequential  avgt   25   10664918,132 ±  254251,184  ns/op

그 이유는 배열이 저렴하고 고르게 분할될 수 있지만 LinkedList 에는 이러한 속성이 없기 때문입니다. TreeMapHashSet 은 LinkedList 보다 잘 분할 되지만 배열만큼은 분할되지 않습니다.

4.3. 비용 병합

병렬 계산을 위해 소스를 분할할 때마다 결국 결과를 결합해야 합니다.

서로 다른 병합 작업으로 합계 및 그룹화를 사용하여 순차 및 병렬 스트림에서 벤치마크를 실행해 보겠습니다.

arrayListOfNumbers.stream().reduce(0, Integer::sum);
arrayListOfNumbers.stream().parallel().reduce(0, Integer::sum);
arrayListOfNumbers.stream().collect(Collectors.toSet());
arrayListOfNumbers.stream().parallel().collect(Collectors.toSet())

우리의 결과는 순차 스트림을 병렬 스트림으로 변환하는 것이 합 연산에 대해서만 성능상의 이점을 가져온다는 것을 보여줍니다.

Benchmark                                                     Mode  Cnt        Score        Error  Units
MergingCosts.mergingCostsGroupingParallel                     avgt   25  135093312,675 ± 4195024,803  ns/op
MergingCosts.mergingCostsGroupingSequential                   avgt   25   70631711,489 ± 1517217,320  ns/op
MergingCosts.mergingCostsSumParallel                          avgt   25    2074483,821 ±    7520,402  ns/op
MergingCosts.mergingCostsSumSequential                        avgt   25    5509573,621 ±   60249,942  ns/op

병합 작업은 축소 및 추가와 같은 일부 작업의 경우 정말 저렴하지만 집합 또는 맵으로 그룹화하는 것과 같은 병합 작업은 상당히 비쌀 수 있습니다.

4.4. 메모리 지역

최신 컴퓨터는 정교한 다단계 캐시를 사용하여 자주 사용하는 데이터를 프로세서 가까이에 보관합니다. 선형 메모리 액세스 패턴이 감지되면 하드웨어는 데이터가 곧 필요할 것이라는 가정 하에 다음 데이터 라인을 프리페치합니다.

병렬 처리는 프로세서 코어가 유용한 작업을 계속 바쁘게 할 수 있을 때 성능 이점을 가져옵니다. 캐시 미스를 기다리는 것은 유용한 작업이 아니기 때문에 메모리 대역폭을 제한 요소로 고려해야 합니다.

하나는 기본 유형을 사용하고 다른 하나는 객체 데이터 유형을 사용하는 두 개의 배열을 사용하여 이를 시연해 보겠습니다.

private static final int[] intArray = new int[1_000_000];
private static final Integer[] integerArray = new Integer[1_000_000];

static {
    IntStream.rangeClosed(1, 1_000_000).forEach(i -> {
        intArray[i-1] = i;
        integerArray[i-1] = i;
    });
}

두 어레이에서 순차적 및 병렬 축소 작업에 대한 벤치마크를 실행할 것입니다.

Arrays.stream(intArray).reduce(0, Integer::sum);
Arrays.stream(intArray).parallel().reduce(0, Integer::sum);
Arrays.stream(integerArray).reduce(0, Integer::sum);
Arrays.stream(integerArray).parallel().reduce(0, Integer::sum);

우리의 결과는 순차 스트림을 병렬 스트림으로 변환하는 것이 기본 배열을 사용할 때 약간 더 많은 성능 이점을 가져온다는 것을 보여줍니다.

Benchmark                                                     Mode  Cnt        Score        Error  Units
MemoryLocalityCosts.localityIntArrayParallel                sequential stream  avgt   25     116247,787 ±     283,150  ns/op
MemoryLocalityCosts.localityIntArraySequential                avgt   25     293142,385 ±    2526,892  ns/op
MemoryLocalityCosts.localityIntegerArrayParallel              avgt   25    2153732,607 ±   16956,463  ns/op
MemoryLocalityCosts.localityIntegerArraySequential            avgt   25    5134866,640 ±  148283,942  ns/op

기본 요소의 배열은 Java에서 가능한 최고의 지역성을 제공합니다. 일반적으로 데이터 구조에 포인터가 많을수록 참조 개체를 가져오기 위해 메모리에 더 많은 압력을 가합니다. 여러 코어가 동시에 메모리에서 데이터를 가져오기 때문에 병렬화에 부정적인 영향을 미칠 수 있습니다.

4.5. NQ 모델 _

Oracle은 병렬 처리가 성능 향상을 제공할 수 있는지 여부를 결정하는 데 도움이 되는 간단한 모델을 제시했습니다. NQ 모델에서 N 소스 데이터 요소의 수를 나타내고 Q 는 데이터 요소당 수행된 계산의 양을 나타냅니다.

N*Q 의 곱이 클수록 병렬화로 인해 성능이 향상될 가능성이 높아집니다. 숫자 합산과 같이 아주 작은 Q 문제 의 경우 경험상 N 은 10,000보다 커야 합니다. 계산 수가 증가하면 병렬 처리를 통해 성능을 높이는 데 필요한 데이터 크기가 줄어듭니다.

4.6. 파일 검색 비용

병렬 스트림을 사용한 파일 검색은 순차 스트림에 비해 성능이 더 좋습니다. 1500개가 넘는 텍스트 파일을 검색하기 위해 순차 및 병렬 스트림에 대한 벤치마크를 실행해 보겠습니다.

Files.walk(Paths.get("src/main/resources/")).map(Path::normalize).filter(Files::isRegularFile)
      .filter(path -> path.getFileName().toString().endsWith(".txt")).collect(Collectors.toList());
Files.walk(Paths.get("src/main/resources/")).parallel().map(Path::normalize).filter(Files::
      isRegularFile).filter(path -> path.getFileName().toString().endsWith(".txt")).
      collect(Collectors.toList());

결과에 따르면 순차 스트림을 병렬 스트림으로 변환하면 더 많은 수의 파일을 검색할 때 약간 더 많은 성능 이점을 얻을 수 있습니다.

Benchmark                                Mode  Cnt     Score         Error    Units
FileSearchCost.textFileSearchParallel    avgt   25  10808832.831 ± 446934.773  ns/op
FileSearchCost.textFileSearchSequential  avgt   25  13271799.599 ± 245112.749  ns/op

5. 병렬 스트림을 사용하는 경우

지금까지 살펴본 것처럼 병렬 스트림을 사용할 때는 매우 신중해야 합니다.

병렬 처리는 특정 사용 사례에서 성능 이점을 가져올 수 있습니다. 그러나 병렬 스트림은 마법 같은 성능 향상 장치로 간주될 수 없습니다. 따라서 개발 중에는 순차 스트림을 계속 기본으로 사용해야 합니다.

실제 성능 요구 사항이 있을 때 순차 스트림을 병렬 스트림으로 변환할 수 있습니다 . 이러한 요구 사항이 주어지면 먼저 성능 측정을 실행하고 가능한 최적화 전략으로 병렬 처리를 고려해야 합니다.

많은 양의 데이터와 요소당 수행되는 많은 계산은 병렬 처리가 좋은 옵션이 될 수 있음을 나타냅니다.

반면에 적은 양의 데이터, 고르지 않은 소스 분할, 값비싼 병합 작업 및 열악한 메모리 지역성은 병렬 실행에 대한 잠재적인 문제를 나타냅니다.

6. 결론

이 기사에서는 Java에서 순차 스트림과 병렬 스트림의 차이점을 살펴보았습니다. 우리는 병렬 스트림이 기본 포크-조인 풀과 해당 작업자 스레드를 사용한다는 것을 배웠습니다.

그런 다음 병렬 스트림이 항상 성능 이점을 가져오는 것은 아님을 확인했습니다. 우리는 다중 스레드 관리, 메모리 지역성, 소스 분할 및 결과 병합의 오버헤드를 고려했습니다. 우리는 어레이가 가능한 최고의 지역성을 가져오고 저렴하고 균일하게 분할할 수 있기 때문에 병렬 실행을 위한 훌륭한 데이터 소스라는 것을 알았습니다.

마지막으로 NQ 모델을 살펴보고 실제 성능 요구 사항이 있는 경우에만 병렬 스트림을 사용하는 것이 좋습니다.

항상 그렇듯이 소스 코드는 GitHub 에서 사용할 수 있습니다 .

Generic footer banner