📗 개발자 책 읽기/모던 자바 인 액션

[모던자바인액션] chapter7. 자바 병렬 처리 와 성능측정 - parallelStream

민돌v 2022. 9. 20. 19:56
728x90

 

 

 

" Modern Java In Action - 모던 자바 인 액션, 전문가를 위한 자바 8, 9, 10 기법 가이드 "
독서 스터디 후, 책 내용을 정리한 글입니다.


1) Chapter 1. 자바 8, 9, 10, 11 : 무슨일이 일어나고 있는가?
2) Chapter 2. 동작 파라미터화 코드 전달하기
3) Chapter 3. 람다 표현식
4) Chapter 4. Stream
5) Chapter 5. Stream 활용
6) Chapter 6. Stream으로 데이터 수집하기 
7) Chapter 7. Stream 병렬 데이터처리와 성능

 

 

 

 

Chapter7 에서는, 자바 병렬 스트림의 사용과 성능에 대해서 이야기합니다.

 

자바 7이 등장하기 전에는 자바에서 병렬처리를 할려면 많은 노력을 해야했습니다.

  1. 데이터를 서브파트로 분할
  2. 분할된 서브파트를 각각의 스레드로 할당
  3. 할당 후 의도치 않은 레이스 컨디션(경쟁 상태) 가 발생하지 않도록적절한 동기화 추가
  4. 마지막으로 부분 결과를 합침 

➡️ 자바 7은 더 쉽게 병렬화를 수행하면서 에러를 최소화할 수 있도록 포크/조인 프레임워크 기능을 제공합니다.

 

이번장에는 자바 8이후부터 나온 stream 파이프라인으로 병렬처리를 하는 방법과 내부적으로 일어나는 과정, 그 과정에서 발생하는 병렬 스트림의 성능에 대해 이야기합니다

 

 

 

 

[목차]

  1. 병렬 스트림 가이드
  2. 병렬 스트림의 스레드 할당
  3. 병렬 스트림 성능 측정 및 성능개선

 


1. 병렬 스트림

  • 자바 8 이후부터는 스트림 인터페이스를 이용하여 간단하게 요소를 병렬로 처리할 수 있습니다.
  • 바로 컬렉션에 parallelStream을 호출하기만 하면 병렬 스트림이 생성됩니다.

 

📌 병렬 스트림이란, 각각의 스레드에서 처리할 수 있도록 스트림 요소를 여러 청크로 분할한 스트림입니다.

  • 따라서 병렬 스트림을 이용하면 모든 멀티코어 프로세서가 각각의 청크를 처리하도록 할당할 수 있습니다.

 

 

(1) 병렬스트림을 만드는 방법

아래와 같은 1~n 까지의 숫자의 합을 구하는 순차적인 반복작업을 진행하는 전통적인 자바 코드와, 순차 스트림이 있습니다.

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

//전통적인 자바 반복문
public long iterativeSum(long n) {
    long result =0;
    for (long i = 1L; i <= n; i++) {
        result+=i;
    }
    return result;
}

숫자 n 이 커질수록 많은 반복과 리듀싱이 일어나고 이 작업을 나누어서 처리할 수 있다면 성능이 무척 계선됩니다.

그러기 위해서는 동기화, 스레드 할당, 숫자 생성, 청크 분할 등 신경써줘야할게 무척 많습니다... 이를 병렬 스트림으로 매우 간단하게 해결할 수 있습니다.

 

Stream.parallel()

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

Stream.parallel() 함수를 호출하기만 한다면, Stream 요소가 내부적으로 병렬처리를 진행합니다...!

  • ➡️ 순차스트림에서 parallel() 을 호출해도 스트림에서는 아무런 변화가 일어나지 않습니다.
  • ➡️ 하지만 내부적으로는 parallel() 이 호출되면 병렬 처리를 한다는 Boolean flag 가 설정됩니다.

 

Stream.sequential()

  • sequential() 메서드를 호출하면 스트림이 순차 스트림으로  처리하겠다는 것을 의미합니다.
  • 순차냐 병렬이냐는 내부적인 Boolean 값으로 판다하기 때문에 최종 선언된 메소드에 의해 전체 파이프라인이 영향을 받습니다.
public long parallelSum(long n) {
    return Stream.iterate(1L, i -> i + 1)
        .limit(n)
        .parallel()
        .filter(..)
        .sequential()
        .map(..)
        .parallel()
        .reduce(0L, Long::sum);
}

➡️ 따라서 이 코드는 가장 마지막에 호출된 parallel 에 의해 병렬로 처리됩니다!

 

 

(2) 병렬 스트림의 스레드 할당 (스레드 풀 설정)

  • 스트림에서 parallel 메서드르 병렬 스트림으로 만들때 내부적으로 스레드는 어떻게 할당 될까 ❓

 

병렬스트림은 내부적으로 (자바 7에서 등장한) ForkJoinPool 을 사용합니다. 

기본적으로 ForkJoinpoll 은 프로세서 수, 즉, Runtime.getRuntime().availableProcessors(); 가 반환하는 값에 상응하는 스레드를 갖습니다.

 

이 스레드 할당 수를 바꿔 주고 싶다면 아래처럼 하면 되지만, 전역 설정 코드이므로 이후의 모든 병렬 스트림 연산에 영향을 줍니다.

현재는 하나의 병렬 스트림에 사용할 수 있는 특정한 값을 지정할 수 없습니다.

System.setProperties("java.util.concurrent.ForkJoinPool.common.parallelism","12");

 

 

 


2. 병렬 스트림 성능 측정

  1. 병렬화를 이용하면 순차나 반복에 비해 성능이 좋아질거라 생각하지만 꼭 그렇지는 않습니다.
  2. 성능최적화를 할 때는 가장 중요한건 단 하나 측정 입니다.
  3. 측정을 위해선, JMH라는 라이브러리를 활용해 측정해볼 수 있습니다.

 

package jmh;

import ...

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@State(Scope.Benchmark)
@Fork(value = 2, jvmArgs = {"-Xms4G", "-Xmx4G"})
public class ParallelStreamBenchmark {
    private static final long N = 10_000_000L;

	// 순차 스트림 성능 측정
    @Benchmark
    public long sequentialSum() {
        return Stream.iterate(1L, i -> i + 1)
                     .limit(N)
                     .reduce(0L, Long::sum);
    }

	// 네이티브 자바 반복문 성능 측정
    @Benchmark
    public long interactiveSum() {
        long result = 0;
        for(long i = 1L; i <= N; i++) {
            result += i;
        }
        return result;
    }
    

	// parallel 병렬 스트림 성능 측정
    @Benchmark
    public long parallelSum() {
        return Stream.iterate(1L, i -> i + 1)
                     .limit(N)
                     .parallel()
                     .reduce(0L, Long::sum);
    }

	//매 번 벤치마크 실행시 가비지 컬렉터 실행
    @TearDown(Level.Invocation)
    public void tearDown() {
        System.gc();
    }
}

결과는 아래와 같습니다.

⬇️

iterativeSum() 즉, 단순 반복 for 문이 Stream 연산보다 월등히 빠른 결과를 보여주었습니다.. 왜 그런걸까요?

 

✨ Stream 보다 반복문이 빠른 이유

  • 전통적인 for 루프를 사용해 반복하는 방법이 더 저수준으로 동작할 뿐 아니라, 특히 기본값을 박싱 or 언박싱할 필요가 없기 때문입니다.
  • 1) 반복 결과로 박싱된 객체가 만들어지므로 숫자를 더하려면 언박싱을 해주어야합니다.
  • 2) 또 이전 연산의 결과에 따라 다음 함수의 입력이 달라지는 반복적인 iterate 연산을 청크로 분할하기 어렵습니다.
    • iterate 는 본질적으로 순차적입니다.
    • 따라서 병렬로 처리되도록 지시(parallel) 했지만 본질적으론 순차처리가 되고(논리적으로) 이에 따른 스레드 할당 오버헤드만 증가하기 때문에 단순 반복문보다 성능이 낮게나온 이유입니다.

 

 

(1) 병렬 스트림 성능 개선하기

위와 같은 이슈를 해결한다면 스트림이 단순 반복문보다 빨라질까요??

  • 오토박싱 이슈
  • iterate 이슈

 

👏🏻 정답은 Yes 입니다.

  • 기본형 특화 스트림을 사용한다면 오토박싱이슈와 rangeClose 로 범위를 확실하게 나누어주면 성능이 확연하게 빨라집니다.
//기본형 특화 스트림의 병렬 처리 성능 측정

@Benchmark
public long parallelSum(long n) {
    return LongStream.rangeClosed(1, n)
        .parallel()
        .reduce(0L, Long::sum);
}

 

 

(2) 병렬 스트림 주의사항 (올바른 사용)

📌 간단하게 말해, 병렬 스트림과 병렬 연산에서는 공유된 가변 상태를 피해야합니다.

// n까지의 자연수를 더하면서 공유된 누적자를 바꾸는 코드
public long sideEffectSum(long n) {
        Accumulator accumulator = new Accumulator();
        LongStream.rangeClosed(1, n).forEach(accumulator::add);
        return accumulator.total;
    }

public class Accumulator {
    public long total = 0;
    public void add(long value) {
        total += value;
    }
}
  1. 위 코드는 본질적으로 순차 실행하도록 되어있어 병렬 처리에 올바르지 않은 경우이고
  2. 병렬처리시, 다수의 스레드에서 total 에 접근하게 된다면 데이터 레이스 문제가 일어나게 됩니다.
  3. 결과적으로는 올바르지 않은 결과값이 도출될 확률이 매우 큽니다.

 

 

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

  1. 확신이 서지 않으면 직접 측정하라.
  2. 박싱을 주의하라.(기본형 특화 스트림을 활용)
  3. 순차 스트림보다 병렬 스트림에서 성능이 떨어지는 연산이 있다.
  4. 스트림에서 수행하는 전체 파이프라인 연산 비용을 고려하라
  5. 소량의 데이터에서는 병렬 스트림이 도움되지 않는다.
  6. 스트림을 구성하는 자료구조가 적절한지 확인하라.
  7. 스트림의 특성과 파이프라인의 중간 연산이 스트림의 특성을 어떻게 바꾸는지에 따라 분해 과정의 성능이 달라질 수 있다.
  8. 최종 연산의 병합 과정 비용을 살펴보라.

 

 

 

 

오예 끝!

 

여기까지가 모던 자바 인 액션 스터디 계획이였구,,

이 후로는 공부를 할수도 있고 안할수도 있다는 슈뢰딩거의 모던자바...

반응형