본문 바로가기
프로그래밍/JAVA 내용정리

[ModernJavaInAction] 7장 병렬스트림 정리 1편

by 노잼인간이라불립니다 2022. 10. 10.

이 글은 ModernJavaInAction이란 책을 읽고 요약 정리한 글 입니다.

 

모든 실습 코드는 아래 주소에 있습니다.

https://github.com/jojojojocho/mordernjavainaction

 

 

안녕하세요!!

이번 장에서는 스트림의 성능을 좀 더 개선할 수 있는 방법 중 하나인 병렬스트림에 대해서 알아보겠습니다.

 

 7장 병렬 스트림

병렬 스트림이란?

 데이터를 각각의 스레드에서 처리 할 수 있도록 스트림 요소를 여러개의 청크로 분할한 스트림 입니다.

 

그러나 무턱대고 순차로직을 병렬화 하게 되면 race condition 문제가 발생할 수 있습니다.

 

** race condition(경쟁상태)

둘 이상의 입력 또는 조작의 타이밍이나 순서가 예상과 다르게 작동하면 정상적인 결과가 나오지 않게 될 위험이 있는데

이를 '경쟁 위험' 이라고 합니다.

 

전산학에서 경쟁상태란?

공유자원에 대해 여러개의 프로세스가 동시에 접근을 시도할 때

접근의 타이밍이나 순서등이 결과값에 영향을 줄수 있는 상태 라고 합니다.

 

이를 해결하기 위한 방법으로는 피터슨의 알고리즘, 동기화 명령어, 세마포어 등이 있다고 합니다.

-출처- 위키백과

 

자바 7 부터는 더 쉽게 병렬화를 수행하면서 에러를 최소화 할 수 있는

"포크 / 조인 프레임워크" 기능을 제공합니다.

 

순차스트림을 병렬스트림으로 변환하기

 

그럼 예제를 통해 순차스트림을 병렬로 바꾸어 볼까요?

 

/**
 * 7.1.1 순차 스트림을 병렬 스트림으로 변환하기
 */
@DisplayName("순차스트림을 병렬 스트림으로 변환하기")
@Test
public void streamToParallel() {
    long n = 10L;

    Long result =
            Stream.iterate(1L, i -> i + 1)
                    .limit(n)
                    .parallel()
                    .reduce(0L, Long::sum);

    System.out.println(result); // 55
}

- 순차 스트림에 parallel의 메서드를 호출하면

1.기존의 함수형 리듀싱 연산(숫자 합계 계산)이 병렬로 처리됩니다.

2. 이후 연산이 병렬로 실행되야 한다는 것을 의미하는 불리언 플래그가 설정됩니다.

 

- 병렬 스트림은 내부적으로 ForkJoinPool을 사용합니다.

 - 기본적으로 ForkJoinPool은 프로세서의 수에 상응하는 스레드를 갖습니다.

이는 Runtime.getRuntime().availableProcessors()의 반환값과 동일합니다.

 

하지만 순차 스트림에 parallel()만 넣어 병렬스트림으로 실행한다고 해서,

해당 연산이 올바르게 수행된다고는 보장할 수 없습니다.

 

그럼 어떻게 하면 병렬 스트림을 적재적소에 올바르게 사용할 수 있을 까요?

다음으로는 그 방법에 대해 알아보도록 하겠습니다.

 

병렬스트림의 올바른 사용법.

병렬 스트림을 잘못 사용하면서 발생되는 대부분의 문제들은

공유된 자원을 변경하면서 문제가 발생 됩니다.

 

그러므로 병렬 스트림을 사용할 경우 공유된 가변 상태의 변수를 사용하면 안됩니다!!

 

예제를 통해 살펴보시죠.

public class Accumulator {
    public long total = 0;

    public void add(long value) {
        total += value;
    }
}
/**
 * 7.1.3 병렬스트림의 올바른 사용법 - sequential
 */
@DisplayName("공유된 누적자를 이용한 sum - sequential")
@Test
public void sharedAccumulatorSumSequential(){
    // given
    long N = 1000;
    Accumulator accumulator = new Accumulator();

    // when
    LongStream.rangeClosed(1, N).forEach(accumulator::add);

    // then
    System.out.println(accumulator.getTotal()); // 500500
}

/**
 * 7.1.3 병렬스트림의 올바른 사용법 - parallel
 */
@DisplayName("공유된 누적자를 이용한 sum - parallel")
@Test
public void sharedAccumulatorSumParallel(){
    // given
    long N = 1000;
    Accumulator accumulator = new Accumulator();

    // when
    LongStream.rangeClosed(1,N).parallel().forEach(accumulator::add);

    // then
    System.out.println(accumulator.getTotal()); // 487158
}

위의 코드의 문제점이 보이시나요?

순차로 실행했을 때에는 문제가 발생하지 않지만, 병렬로 실행했을 경우,

Accumulator의 클래스 안에 있는 add 메서드가 호출 될 때

전역변수인 total에 동시에 접근하게 되면서 문제가 생기게 됩니다.

 

이런 식으로 공유자원에 접근하여 수정하는 로직이 있다면

로직을 변경하지 않은 채 병렬로 실행하는 것은 피해야 할 것입니다.

 

위 코드를 보니 그럼 언제 병렬 스트림을 써야 할지 감이 잘 안오신다고요?

그럼 아래의 내용을 통해 조금이나마 지침을 정하고 가시는 건 어떨까요?

 

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

병렬스트림을 사용할 기준 또는 지침.

1. 확신이 서지 않으면 직접 벤치마크를 이용해 측정할 것.

순차 스트림은 병렬로 변경하는 것이 간단하지만,

어떤 것을 사용해야할 지 감이 안온다면, 직접 성능측정을 하는 것도 좋은 방법이 될 수 있습니다.

여러번 성능측정을 통해 평균값을 가지고 판단을 해보는 건 어떨까요?

 

2. 박싱을 주의할 것.

스트림에서의 자동 언박싱, 박싱은 성능에 영향을 줄 수 있습니다.

그러므로 primitive 타입을 사용한다면,

기본 특화 스트림인 IntStream, LongStream, DoubleStream을 사용하는 것이 좋습니다!!

 

3. 순차 스트림보다 병렬 스트림에서 성능이 떨어지는 연산을 주의하라!

순차스트림 보다 병렬스트림에서 성능이 떨어지는 연산이 존재합니다.

대표적으로 limit나 findFirst처럼 요소의 순서에 의존하는 연산은

병렬스트림에서 비싼비용을 치뤄야 합니다.

그러므로 가급적 순서가 없는 스트림에 병렬스트림을 적용합시다!

 

4. 스트림에서 수행하는 전체 파이프라인의 연산비용을 고려하자!

요소의 갯수 a, 하나를 처리하는 비용이 b라고 한다면,

전체 처리 비용은 axb 가 됩니다.

여기서 b의 비용이 매우 비싸다고 한다면,

순차처리보다는 병렬처리를 고려해야할 것입니다.

 

5. 소량의 데이터에서는 병렬스트림이 도움이 되지 않습니다!

데이터를 처리하는 비용보다 병렬화의 비용이 더 크기 때문에

소량의 데이터는 순차스트림을 이용해 처리하는 것이 더 효율 적 입니다.

 

6. 스트림을 구성하는 자료구조가 적절한지 확인할 것! 

병렬 스트림을 사용하기 위해서는 분할하는 로직이 필수적입니다.

그렇기 때문에 분할하기 쉬운 자료구조를 사용하는 것이 병렬스트림의 효율을 높여 줄 수 있습니다.

 

ex) ArrayList와 LinkedList

ArrayList : 인덱스만으로 요소탐색 없이 바로 슬라이싱이 가능.

LinkedList : 전체 요소 탐색을 통해 슬라이싱.

 

range와 같은 범위를 정하는 팩토리 메서드도 쉽게 분할이 가능합니다.

 

그리고 나중에 나오는 Spliterator를 통해서도 분할을 완벽하게 제어 가능합니다.

 

7. 스트림의 특성과 파이프라인의 중간연산에 따라 분해과정의 성능이 달라질 수 있습니다.

ex) sized 스트림은 같은 크기의 스트림으로 쉽게 분할하여 병렬로 실행 할 수 있습니다.

ex ) filter와 같은 경우 스트림의 길이를 예측 불가능 하므로 효과적인 병렬처리가 어렵다.

 

8. 최종연산의 병합과정의 비용을 살펴볼 것.

ex) collector의 combiner 메서드

만약 병합과정에 대한 비용이 비싸다고 하면 성능과 이익이 상충되어 효과적이지 않을 수 있다.

 

스트림 소스와 분해성에 대한 표

index 자료구조 분해성
1 ArrayList 훌륭함
2 LinkedList 나쁨
3 IntStream 훌륭함
4 Stream.iterate 나쁨
5 HashSet 좋음
5 TreeSet 좋음

 

 

 

 

 

 

 

참고

모던 자바 인 액션

위키백과