본문 바로가기
그룹 스터디 공부(IT 서적)/모던 자바 인 액션

chapter 7 병렬 데이터 처리와 성능

by hanyugyeong 2023. 7. 24.
반응형
SMALL

자바 7이 등장하기 전에는 데이터 컬렉션을 병렬로 처리하기 어려웠음. 다음과 같은 일련의 과정을 거쳐야 했음.

1) 데이터를 서브파트로 분할해야 한다.
2) 분할된 서브파트를 각각의 스레드로 할당한다.
3) 스레드로 할당한 다음에는 의도치 않은 레이스 컨디션(역자주_경쟁 상태)이 발생하지 않도록 적절한 동기화를 추가해야 한다.
4) 부분 결과를 합쳐야 한다.

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

이 장에서는 스트림으로 데이터 컬렉션 관련 동작을 얼마나 쉽게 병렬로 실행할 수 있는지 설명한다.

7.1 병렬 스트림

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

-> 숫자 n을 인수로 받아서 1부터 n까지의 모든 숫자의 합계를 반환하는 메서드

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

전통적인 자바에서는 다음과 같이 반복문으로 이를 구현할 수 있다.

public static long iterativeSum(long n) {
    long result = 0;
    for (long i = 0; i <= n; i++) {
      result += i;
    }
    return result;
  }

특히 n이 커진다면 이 연산을 병렬로 처리하는 것이 좋을 것이다.

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

순차 스트림에 parallel 메서드를 호출하면 기존의 함수형 리듀싱 연산(숫자 합계 계산)이 병렬로 처리된다.

public static long parallelSum(long n) {
    return Stream.iterate(1L, i -> i + 1).limit(n)
    .parallel() // 스트림을 병렬 스트림으로 변환
    .reduce(Long::sum).get();
  }

→ 스트림이 여러 청크로 분할되어 병렬로 수행되고, 마지막으로 리듀싱 연산으로 생성된 부분 결과를 다시 리듀싱 연산으로 합쳐서 전체 스트림의 리듀싱 결과를 도출함

내부적으로 parallel을 호출하면 이후 연산이 병렬로 수행해야함을 의미하는 불리언 플래그가 설정된다. 반대로 sequential로 병렬을 순차 스트림으로 바꿀 수 있다.

stream().parallel()
                .filter(...)
                .sequential()
                .map(...)
                .parallel()
                .reduce();

최종적으로 호출된 메서드가 전체 파이프라인에 영향을 미친다. 이 예제에서는 마지막으로 호출된 parallel에 따라 병렬로 실행된다.

병렬 스트림에서 사용하는 스레드 풀 설정

스트림의 parallel 메서드에서 병렬로 작업을 수행하는 스레드는 어디서 생성되는 것이며, 몇개나 생성되는지, 그리고 그 과정을 어떻게 커스터마이즈할 수 있는지 궁금할 것이다.

병렬 스트림은 내부적으로 ForkJoinPool을 사용한다. 기본적으로 ForkJoinPool은 프로세스 수, 즉 Runtime.getRuntime().availableProcessors()가 반환하는 값에 상응하는 스레드를 가짐

7.1.2 스트림 성능 측정

성능 측정을 위해 자바 마이크로 벤치마크(Java Microbenchmark Harness, JMH)라는 라이브러리를 이용한다. JMH를 이용하면 간단하고, 어노테이션 기반 방식을 지원하며, 안정적으로 자바 프로그램이나 JVM을 대상으로 하는 다른 언어용 벤치 마크를 구현할 수 있다.

n개의 숫자를 더하는 함수의 성능 측정

@BenchmarkMode(Mode.AverageTime) //벤치마크 대상 메서드를 실행하는 데 걸린 평균 시간 측정 
@OutputTimeUnit(TimeUnit.MILLISECONDS) //벤치마크 결과를 밀리초 단위로 출력 
@Fork(value = 2, jvmArgs = { "-Xms4G", "-Xmx4G" }) //4Gb의 힙 공간을 제공한 환경에서 두 번 벤치마크를 수행해 결과의 신뢰성 확보 
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 parallelSum() {
    return Stream.iterate(1L, i -> i + 1).limit(N).parallel().reduce(0L, Long::sum);
  }

  @TearDown(Level.Invocation) // 매 번 벤치마크를 실행한 다음에는 가비지 컬렉터 동작 시도 
  public void tearDown() {
    System.gc();
  }

}

순차적인 스트림(sequentialSum), 전통적인 for루프 방식(interactiveSum), 병렬 스트림(parallelSum)의 성능 측정 결과 interactiveSum > sequentaialSum > parallelSum 순으로 빠른 처리 속도를 보임을 확인할 수 있었다.

  • 전통적인 for루프 방식이 빠른 이유? 순차적 스트림보다 병렬 스트림이 더 느린 이유?→ 반복 작업은 병렬로 수행할 수 있는 독립 단위로 나누기가 어렵다.

  • → 반복 결과로 박싱된 객체가 만들어지므로 숫자를 더하려면 언박싱을 해야 한다.

iterate 연산은 본질적으로 순차적이다. 이전 연산의 결과에 따라 다음 함수의 입력이 달라지기 때문에 iterate 연산을 청크로 분할하기 어렵다. → 스트림을 병렬로 처리되도록 지시했고 각각의 합계가 다른 스레드에서 수행되었지만 결국 순차처리 방식과 크게 다른점이 없으므로 스레드를 할당하는 오버헤드만 증가

더 특화된 메서드 사용

iterate 대신 LongStream과 같은 기본형 특화 스트림을 이용해서 박싱 비용을 줄여보자. LongStream이 iterate에 비해 같는 장점은 다음과 같다.

  • LongStream.rangeClosed는 기본형 long을 직접 사용하므로 박싱과 언박싱 오버헤드가 사라짐
  • LongStream.rangeClosed는 쉽게 청크로 분할할 수 있는 숫자 범위를 생산함. 예를들어 1-20범위의 숫자를 각각 1-5, 6-10, 11-15, 16-20으로 분할할 수 있음
    @Benchmark
    public long rangedSum() {
        return LongStream.rangeClosed(1, N)
                         .reduce(0L, Long::sum);
    }

→ 측정 결과 iterate로 생성한 기존의 순차 스트림에 비해 처리 속도가 더 빠르다. (상황에 따라서는 알고리즘보다 적절한 자료구조가 중요함을 보여주는 사례)
새로운 버전에 병렬 스트림을 적용하면 성능이 향상될까?

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

→ 올바른 자료구조는 병렬 실행의 최적의 성능을 발휘할 수 있도록 한다. 순차 실행보다 빠른 성능을 갖는 병렬 리듀싱이 되었다.

결론적으로 함수형 프로그래밍을 올바르게 사용하면 최신 멀티 코어 CPU가 제공하는 병렬 실행의 힘을 단순하게 직접적으로 얻을 수 있음!

하지만 병렬화가 완전 공짜라는 아니라는 사실을 기억하자!

  • 스트림을 재귀적으로 분할하고, 각 서브스트림을 서로 다른 스레드의 리듀싱 연산으로 할당하고, 결과를 하나의 값으로 합치는 일련의 작업이 수반됨
  • 멀티코어 간의 데이터 이동은 비싼 비용을 치른다. 코어간 데이터 전송 시간보다 훨씬 오래 걸리는 작업만 병렬로 수행하는 것이 바람직함
  • 상황에 따라 아예 병렬화를 사용할 수 없는 때도 있음

스트림을 병렬화해서 코드 실행 속도를 높이고자 한다면 병렬화를 올바르게 사용하고 있는지 짚어봐야한다.

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

병렬 스트림을 잘못 사용하면서 발생하는 많은 문제는 공유된 상태를 바꾸는 알고리즘을 사용하기 때문.

// 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;
    }
}

위 코드는 본질적으로 순차 실행할 수 있도록 구현되어 있으므로 병렬로 실행하면 참사가 일어난다.

  • total을 접근할 때마다 (다수의 스레드에서 동시에 데이터에 접근하는) 데이터 race condition 문제가 일어남
  • 동기화로 문제를 해결하려다보면 병렬화라는 특성이 사라짐

스트림을 병렬로 만들어서 어떤 일이 발생하는지 확인

  public static long sideEffectParallelSum(long n) {
    Accumulator accumulator = new Accumulator();
    LongStream.rangeClosed(1, n).parallel().forEach(accumulator::add);
    return accumulator.total;
  }

  System.out.println("SideEffect parallel sum done in: " + measurePerf(ParallelStreams::sideEffectParallelSum, 10_000_000L) + " msecs" );

올바른 결과값(50000005000000)이 나오지 않는다. 여러 스레드에서 동시에 누적자의 total += value를 실행하면서 문제가 발생한다.

→ 병렬 스트림과 병렬 계산에서는 공유된 가변 상태를 피해야함을 확인

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

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

7.2 포크/조인 프레임워크

병렬화 할 수 있는 작업을 재귀적으로 작은 작업으로 분할한 뒤 작은 작업들의 결과를 합쳐서 전체 결과를 만들도록 설계되어있다. 포크/조인 프레임워크에서는 작은 작업들을 스레드 풀(Fork Join Pool)의 작업자 스레드에 분살 할당하는 ExecutorService 인터페이스를 구현한다.

7.2.1 RecursiveTask 활용

RecursiveTask의 서브클래스를 구현해야 스레드 풀을 이용할 수 있다. RecursiveTask를 정의하기 위해서는 추상 메서드인 compute를 구현해야 한다.

protected abstract R compute();

compute 메서드는 태스크를 서브태스크로 분할하는 로직과 더 이상 분할할 수 없을 때 개별 서브태스크의 결과를 생산할 알고리즘을 정의한다. 대부분 아래와 같은 의사코드 형식을 따른다

if (태스크가 충분이 작거나 더 이상 분할할 수 없으면) {
  순차적을 태스크 계산
} else {
  태스크를 두 서브 태스크로 분할
  태스크가 다시 서브 태스크로 분활되도록 이 메서드를 재귀적으로 호출
  모든  서브태스크의 연산이 완료될 때까지 기다림
  각 서브태스크의 결과를 합침
}

포크/조인 프레임워크를 이용한 병렬 합계 수행 코드

public class ForkJoinSumCalculator extends RecursiveTask<Long> { // RecursiveTask를 상속받아 포크/조인 프레임워크에서 사용할 테스크를 생성한다.

  public static final long THRESHOLD = 10_000; //이 값 이하의 서브태스크는 더 이상 분할할 수 없다.

  private final long[] numbers; // 더할 숫자 배열
  private final int start; //이 서브태스크에서 처리할 배열의 초기 위치와 최종 위치
  private final int end;

  public ForkJoinSumCalculator(long[] numbers) {
    this(numbers, 0, numbers.length);
  } // 메인 태스크를 생성할 때 사용할 공개 생성자

  private ForkJoinSumCalculator(long[] numbers, int start, int end) { //메인 테스크의 서브태스크를 재귀적으로 만들 때 사용할 비공개 생성자
    this.numbers = numbers;
    this.start = start;
    this.end = end;
  }

  @Override
  protected Long compute() { //RecursiveTask의 추상 메서드 오버라이드
    int length = end - start; // 이 태스크에서 더할 배열의 길이
    if (length <= THRESHOLD) {
      return computeSequentially(); // 기준값과 같거나 작으면 순차적으로 결과를 계산한다.
    }
    ForkJoinSumCalculator leftTask = new ForkJoinSumCalculator(numbers, start, start + length / 2); //배열의 첫 번째 절반을 더하도록 서브태스크를 생성한다.
    leftTask.fork(); //ForkJoinPool의 다른 스레드로 새로 생성한 태스크를 비동기로 실행한다. 
    ForkJoinSumCalculator rightTask = new ForkJoinSumCalculator(numbers, start + length / 2, end); //배열의 나머지 절반을 더하도록 서브태스크를 생성한다. 
    Long rightResult = rightTask.compute(); // 두 번째 서브태스크를 동기 실행한다. 이때 추가로 분할이 일어날 수 있다. 
    Long leftResult = leftTask.join(); // 첫 번째 서브태스크의 결과를 읽거나 아직 결과가 없으면 기다린다. 
    return leftResult + rightResult; // 두 서브태스크의 결과를 조합한 값이 이 태스크의 결과다. 
  }

  private long computeSequentially() { // 더 분할할 수 없을때 서브태스크의 결과를 계산하는 단순한 알고리즘 
    long sum = 0;
    for (int i = start; i < end; i++) {
      sum += numbers[i];
    }
    return sum;
  }

  public static long forkJoinSum(long n) {
    long[] numbers = LongStream.rangeClosed(1, n).toArray();
    ForkJoinTask<Long> task = new ForkJoinSumCalculator(numbers);
    return FORK_JOIN_POOL.invoke(task);
  }

}

ForkJoinPool은 일반적으로 필요한 곳에서 언제든 가져다 쓸 수 있도록 한번만 인스턴스화 해서 정적 필드에 싱글톤으로 저장한다. fork() 를 호출해서 인수가 없는 디폴트 생성자를 이용했는데, 이는 JVM에서 이용할 수 있는 모든 프로세서가 자유롭게 풀에 접근할 수 있음을 의미한다.

ForkJoinSumCalculator 실행

ForkJoinSumCalculator를 ForkJoinPool로 전달하면 풀의 스레드가 compute()메서드를 호출한다. 이후 위의 의사코드처럼 부분 결과를 합쳐서 최종 결과를 계산한다.

  • 포크/조인 알고리즘 과정

    병렬 스트림을 이용할 때보다 성능이 나빠졌다. 하지만 이는 ForkJoinSumCalculator 태스크에서 사용할 수 있도록 전체 스트림을 long[]으로 변환했기 때문이다.

포크/조인 프레임워크를 제대로 사용하는 방법

  • join()메서드를 호출하면 태스크가 생산하는 결과가 준비될 때까지 호출자를 블록시킨다. 따라서 두 서브태스크가 모두 시작된 다음에 join()을 호출해야 한다. 그렇지 않으면 각각의 서브태스크가 다른 태스크가 끝나길 기다리는 일이 발생하며 원래의 순차 알고리즘보다 느리고 복잡한 프로그램이 된다.

  • RecursiveTask 내에서는 순차 코드에서 병렬 계산을 시작할 떄를 제외하고는 ForkJoinPool의 invoke() 메서드를 사용하지 말아야한다. 대신에 compute()나 fork() 는 직접 호출할 수 있다.

  • 서브태스크의 fork() 메서드를 호출해서 ForkJoinPool의 일정을 조절할 수 있다. 왼쪽과 오른쪽 모두 fork()를 호출하는 것이 효율적일 것 같지만, 한쪽에서만 fork()를 호출하고 나머지 한쪽은 compute()를 호출하는 것이 더 효율적이다. 그래야만 두 서브태스크의 한 태스크에서는 같은 스레드를 재사용할 수 있으므로 풀에서 불필요한 태스크를 할당하는 오버헤드를 피할 수 있다.

  • 포크/조인 프레임워크를 이용한 병렬 계산은 디버깅이 어렵다.

  • 무조건 멀티코어에 포크/조인 프레임워크 사용이 빠를 것이라는 생각은 버려야 한다. 병렬 처리로 성능을 개선하려면 태스크를 여러 독립적인 서브태스크로 분리할 수 있어야 한다.

    7.2.3 작업 훔치기

    포크/조인 프레임워크에서는 작업 훔치기라는 기법으로 주어진 서브태스크를 더 분할할 것인지 결정할 기준을 정하는데 도움을 준다. 작업 훔치기를 통해서 ForkJoinPool의 모든 스레드를 거의 공정하게 분할한다.

  • 각각의 스레드는 자신에게 할당된 태스크를 포함하는 이중 연결 리스트를 참조한다.

  • 작업이 끝날때마다 큐의 헤드에서 다른 태스크를 가져와서 작업을 처리한다.

  • 만약 한 스레드가 다른 스레드보다 작업을 빨리 끝냈을 경우 기다리는 것이 아니라, 다른 스레드 큐의 꼬리에서 작업을 훔쳐와서 처리한다. 이 과정을 모든 큐가 빌때까지 즉, 모든 작업을 처리할 때까지 반복한다.

    따라서 태스크 크기를 작게 나누어야 스레드간 작업 부하를 비슷한 수준으로 유지할 수 있다.

7.3 Spliterator 인터페이스

Spliterator는 Iterator 처럼 소스 요소 탐색 기능을 제공한다는 점은 같지만, 병렬화에 특화되어 있다는 점에서 차이점이 있다. Spliterator는 다음과 같은 여러 메서드를 정의한다.

public interface Spliterator<T> {
  boolean tryAdvance(Consumer<? super T> action);
  Spliterator<T> trySplit();
  long estimateSize();
  int characteristics();
}
  • tryAdavance : 요소를 하나씩 순차적으로 소비하면서 탐색할 요소가 남아있다면 true를 반환한다.
  • trySplit() : Spliterator의 일부 요소를 분할해서 두 번째 Spliterator를 만든다
  • estimateSize() : 탐색해야 할 요소의 수에 대한 값을 반환

7.3.1 분할 과정

  1. trySplit() 첫번째 호출로 인해 두 개의 Spliterator가 된다
  2. 다시 trySplit() 를 호출하면 4개의 Spliterator가 된다
  3. trySplit()의 결과가 null이 될 때까지 이 과정을 반복한다.

Spliterator 특성

  • characteristics()
    Spliterator 자쳉의 특성 집합을 포함하는 int를 반환한다. Spliterator를 이용하는 프로그램은 이들 특성을 참고해서 Spliterator를 더 잘 제어하고 최적화 할 수 있다.

Spliterator의 특성
ORDERED
요소에 정해진 순서가 있으므로 탐색, 분할 할때 이 순서에 따라야 한다.
DISTINCT
x, y 두 요소를 방문했을때 x.equals(y)는 항상 false를 반환한다.
SORTED
탐색된 요소는 미리 정의된 정렬 순서를 따른다.
SIZED
estimatedSize()는 정확한 값을 반환한다.
NON-NULL
탐색하는 모든 요소는 null이 아니다.
IMMUTABLE
불변이다. 요소를 탐색하는 동안 요소를 추가하거나, 삭제하거나, 변경할 수 없다.
CONCURRENT
동기화 없이 Spliterator의 소스를 여러 스레드에서 동시에 고칠 수 있다.
SUBSIZED
분할되는 모든 Spliterator까지 모두 SIZED 특성을 갖는다.

7.3.2 커스텀 Spliterator 구현하기

반복형으로 단어 수를 세는 메서드

 public static void main(String[] args) {
    System.out.println("Found " + countWordsIteratively(SENTENCE) + " words");
  //  System.out.println("Found " + countWords(SENTENCE) + " words");
  }

  public static int countWordsIteratively(String s) {
    int counter = 0;
    boolean lastSpace = true;
    for (char c : s.toCharArray()) { // 문자열의 모든 문자를 하나씩 탐색한다. 
      if (Character.isWhitespace(c)) {
        lastSpace = true;
      }
      else {
        if (lastSpace) {
          counter++; //문자를 하나씩 탐색하다 공백 문자를 만나면 지금까지 탐색한 문자를 단어로 간주하여(공백 문자는 제외) 단어 수를 증가시킨다.
        }
        lastSpace = Character.isWhitespace(c);
      }
    }
    return counter;
  }

함수형으로 단어 수를 세는 메서드 재구현하기

반복형 대신 함수형을 사용하면 직접 스레드를 동기화하지 않고 병렬 스트림으로 작업을 병렬화할 수 있다. 먼저 String을 스트림으로 바꾸어주어야 한다(스트림은 int,long,double 기본형만 제공하므로 Stream를 사용해야 한다.)

Stream<Character> stream = IntStream.range(0, SENTENCE.length())
  .mapToObj(SENTENCE::charAt);
  private static class WordCounter {

    private final int counter;
    private final boolean lastSpace;

    public WordCounter(int counter, boolean lastSpace) {
      this.counter = counter;
      this.lastSpace = lastSpace;
    }

    public WordCounter accumulate(Character c) { // 반복 알고리즘처럼 accumulate 메서드는 문자열의 문자를 하나씩 탐색한다. 
      if (Character.isWhitespace(c)) {
        return lastSpace ? this : new WordCounter(counter, true);
      }
      else {
        // 문자를 하나씩 탐색하다 공백 문자를 만나면 지금까지 탐색한 문자를 단어로 간주하여(공백 문자는 제외)단어 수를 증가시킨다.
        return lastSpace ? new WordCounter(counter + 1, false) : this; 
      }
    }

    public WordCounter combine(WordCounter wordCounter) {
      return new WordCounter(counter + wordCounter.counter, // 두 WordCounter의 counter값을 더한다. 
                              wordCounter.lastSpace); //counter 값만 더할것이므로 마지막 공백은 신경 쓰지 않는다. 
    }

    public int getCounter() {
      return counter;
    }

  }
public class Main {
    private static final String SENTENCE = "한한한 유유유 경경경";

    public static void main(String[] args) {
        Stream<Character> stream = IntStream.range(0, SENTENCE.length())
                                            .mapToObj(SENTENCE::charAt);
        System.out.println(countWords(stream));
    }

    private static int countWords(Stream<Character> stream) {
        WordCounter wordCounter = stream.reduce(new WordCounter(0, true),
                WordCounter::accumulator,
                WordCounter::combine);

        return wordCounter.getCounter();
    }
}

3

스트림을 탐색하면서 새로운 문자를 찾을 때마다 accumulate() 메서드를 호출한다. countWordsIteratively()에서와 같이 새로운 비공백 문자를 탐색하고 마지막 문자가 공백이면 counter를 증가시킨다. 그리고 combine()은 WordCounter 내부의 counter 값을 서로 합치는 역할을 한다.

WordCounter 병렬로 수행하기

아래 처럼 병렬 스트림으로 처리하려고 하면 원하는 답이 나오지 않는다.

System.out.println(countWords(stream.parallel()));

그 이유는 원래 문자열의 임의의 위치에서 둘로 분할하다 보니, 예상치 못하게 하나의 단어를 둘로 나누어서 계산하는 경우가 발생할 수 있기 때문이다. 이를 해결하기 위해서는 문자열의 임의의 위치에서 분할하는 것이 아닌, 단어가 끝나는 위치에서만 분할이 이루어지도록 함으로써 문제를 해결할 수 있다.

public class WordCounterSpliterator implements Spliterator<Character> {
    private final String string;
    private int currentChar = 0;

    public WordCounterSpliterator(String string) {
        this.string = string;
    }

    @Override
    public boolean tryAdvance(Consumer<? super Character> action) {
        action.accept(string.charAt(currentChar++)); // 현재 문자 소비
        return currentChar < string.length(); // 소비할 문자가 남아 있으면 true 반환
    }

    @Override
    public Spliterator<Character> trySplit() {
        int currentSize = string.length() - currentChar;
        if (currentSize < 10) {
            return null; // 파싱할 문자열이 순차 처리할 수 있을 만큼 충분히 작아졌으면 null 반환
        }
        // 파싱할 문자열의 중간을 분할 위치로 설정
        for (int splitPos = currentSize / 2 + currentChar; splitPos < string.length(); splitPos++) {
            // 다음 공백이 나올떄까지 분할 위치를 뒤로 이동시킨다
            if (Character.isWhitespace(string.charAt(splitPos))) {
                // 처음부터 분할 위치까지 문자열을 파싱할 새로운 WordCounterSpliterator 생성
                Spliterator<Character> spliterator = new WordCounterSpliterator(string.substring(currentChar, splitPos));
                currentChar = splitPos; // WordCounterSpliterator의 시작위치를 분할위치로 설정
                return spliterator; // 공백 찾고 문자열 분리 했으므로 반복 종료
            }
        }
        return null;
    }

    @Override
    public long estimateSize() {
        return string.length() - currentChar;
    }

    @Override
    public int characteristics() {
        return ORDERED + SIZED + SUBSIZED + NONNULL + IMMUTABLE;
    }
}
반응형
LIST