본문 바로가기

작업 훔치기와 병렬 Stream

ForkJoinPool의 deque와 work stealing을 이해하고 병렬 Stream의 처리 순서·공유 가변 상태·Collector 특성을 실험합니다.

기본 모드의 ForkJoinPool 작업자는 보통 자기 덱의 최근 작업을 처리하고, 일이 없으면 다른 작업자의 오래된 작업을 가져옵니다. asyncMode에서는 로컬 작업을 FIFO로 처리할 수 있습니다.

이러한 작업 훔치기(work stealing)는 하나의 중앙 큐에서 모든 작업자가 경쟁하는 비용을 줄이고 불균형한 분할을 완화합니다.

하지만 어느 작업자가 어느 원소를 언제 처리하는지는 기능 규칙이 아닙니다.

JDK 25의 병렬 Stream 구현도 Fork/Join 작업을 사용하지만, Stream API가 특정 풀이나 작업자 배치를 지정하는 것은 아닙니다. 처리 순서와 결과 순서를 구분해야 합니다.


병렬 콜백의 공유 누산기 경쟁

아래 예제는 두 콜백이 같은 int 값을 모두 읽은 뒤에야 기록할 수 있도록 배리어를 둡니다. 쓰기가 같은 순간에 일어나야 하는 것은 아닙니다.

원문의 읽기·증가·쓰기 조합은 value[0]++와 마찬가지로 원자적이지 않습니다.

두 콜백이 배리어를 정상 통과해 완료하면 모두 0을 읽고 1을 쓰므로 wrong-count=1이 됩니다. 배리어 대기는 추가 작업자가 실제로 실행돼야 진행하므로, 이 예제를 모든 병렬 실행 환경의 종료 보장으로 삼지는 않습니다.

lab/ParallelSharedCounterBug.java
import java.util.List;
import java.util.concurrent.BrokenBarrierException;
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.ForkJoinPool;

public final class ParallelSharedCounterBug {
    public static void main(String[] args) throws Exception {
        int[] value = {0};
        CyclicBarrier bothRead = new CyclicBarrier(2);

        try (ForkJoinPool pool = new ForkJoinPool(2)) {
            pool.submit(
                            () ->
                                    List.of(10, 20).parallelStream()
                                            .forEach(
                                                    ignored -> {
                                                        int observed = value[0];
                                                        await(bothRead);
                                                        value[0] = observed + 1;
                                                    }))
                    .get();
        }

        System.out.println("wrong-count=" + value[0]);
    }

    private static void await(CyclicBarrier barrier) {
        try {
            barrier.await();
        } catch (InterruptedException exception) {
            Thread.currentThread().interrupt();
            throw new IllegalStateException(exception);
        } catch (BrokenBarrierException exception) {
            throw new IllegalStateException(exception);
        }
    }
}

원칙은 콜백 밖의 가변 상태를 여러 작업자가 함께 갱신하지 않는 것입니다.

예시 버그 예제에서는 두 콜백이 같은 초깃값 0을 읽은 뒤 장벽을 통과합니다. 각각 지역 계산으로 1을 만들고 같은 위치에 1을 쓰므로 두 원소를 처리해도 최종 값은 1입니다. 단순히 읽는 위치나 쓰는 위치만 바꾸는 대신, 입력 원소를 개수로 축약하는 count가 공유 증가 연산을 없애는 방법입니다. 이 설명은 예제의 장벽 조건을 따라간 것으로 일반적인 경합 상황에서 매번 같은 결과를 예측한 것이 아닙니다.

개수는 count(), 합계는 sum() 또는 결합 법칙을 만족하는 reduce, 여러 결과는 수집기로 모읍니다.

AtomicInteger로 데이터 경합만 막더라도 모든 원소가 하나의 원자적 변수에서 경쟁하므로 병렬 처리의 장점을 잃을 수 있습니다.


작업 스레드 덱과 훔치기 과정 관찰

작업 훔치기의 목적은 작업 분배이지 결과 순서 보장이 아닙니다.

다음 작업은 크기가 서로 다른 리프를 만들고 작업자 이름별 처리 개수를 기록합니다.

정확한 분포는 실행마다 달라도 합계는 항상 동일해야 합니다.

src/WorkStealingDistribution.java
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.RecursiveTask;
import java.util.concurrent.atomic.LongAdder;

public final class WorkStealingDistribution {
    private static final class SumTask extends RecursiveTask<Long> {
        private final int from;
        private final int to;
        private final Map<String, LongAdder> counts;

        private SumTask(int from, int to, Map<String, LongAdder> counts) {
            this.from = from;
            this.to = to;
            this.counts = counts;
        }

        @Override
        protected Long compute() {
            if (to - from <= 4) {
                counts.computeIfAbsent(Thread.currentThread().getName(), key -> new LongAdder())
                        .increment();
                long sum = 0;
                for (int value = from; value < to; value++) {
                    sum += value;
                }
                return sum;
            }
            int middle = from + Math.max(1, (to - from) / 3);
            SumTask left = new SumTask(from, middle, counts);
            SumTask right = new SumTask(middle, to, counts);
            left.fork();
            return right.compute() + left.join();
        }
    }

    public static void main(String[] args) {
        Map<String, LongAdder> counts = new ConcurrentHashMap<>();
        try (ForkJoinPool pool = new ForkJoinPool(4)) {
            long sum = pool.invoke(new SumTask(1, 65, counts));
            System.out.println("sum=" + sum);
        }
        counts.forEach((worker, leaves) -> System.out.println(worker + "=" + leaves.sum()));
    }
}

계측 Map은 동시 갱신을 지원하지만 계산 결과는 작업 반환값으로 합칩니다.

작업자 분포를 단언으로 고정하면 스케줄러와 머신 차이에 따라 깨집니다.

확인할 결과는 1~64 합인 2080입니다. 인덱스가 겹치지 않는지는 분할 코드를 함께 확인해야 하며, 출력 합계 하나가 누락·중복의 부재까지 증명하지는 않습니다. 작업자별 카운터는 실행한 리프 수이고 작업 훔치기 횟수는 아닙니다.


처리 순서와 결과 순서

List는 만남 순서(encounter order)를 가진 원본이며, 그 원소들이 크기순으로 정렬돼 있어야 하는 것은 아닙니다.

src/ParallelEncounterOrder.java
import java.util.List;
import java.util.concurrent.ConcurrentLinkedQueue;

public final class ParallelEncounterOrder {
    public static void main(String[] args) {
        List<Integer> source = List.of(5, 1, 4, 2, 3);

        List<Integer> collected = source.parallelStream().map(value -> value * 10).toList();

        ConcurrentLinkedQueue<Integer> completionOrder = new ConcurrentLinkedQueue<>();
        source.parallelStream().forEach(completionOrder::add);

        StringBuilder orderedText = new StringBuilder();
        source.parallelStream().forEachOrdered(value -> orderedText.append(value).append(' '));

        System.out.println("collected=" + collected);
        System.out.println("completion-elements=" + completionOrder.size());
        System.out.println("ordered=" + orderedText.toString().trim());
    }
}

입력 [5, 1, 4, 2, 3]의 만남 순서를 각 최종 연산이 어떻게 다루는지 비교합니다.

병렬 콜백과 최종 결과의 순서 계약

병렬 콜백과 최종 결과의 순서 계약

결과 순서와 콜백 순서를 구분

병렬 콜백과 최종 결과의 순서 계약

입력은 [5, 1, 4, 2, 3]입니다. 세 개의 별도 병렬 스트림에서 최종 연산에 따라 보존하는 순서가 달라집니다.

같은 원본의 세 최종 연산은 서로 다른 순서 계약을 가진다원ParallelEncounterOrder의별도세파이프라인이다. maptoList결과50,10,40,20,30은만남순서를보존하고forEach큐는원소수5만기대한다. forEachOrdered문자열은5 1 4 2 3이다. 7노드6원본→연산→결과관계이며mobile도동일하다. 입력만남순서·삽입순서·콜백시간순서를구별하고작업자완료시각을표현하지않는다.원본 만남 순서5 · 1 · 4 · 2 · 3map ×10 → toList만남 순서 보존결과 List50 · 10 · 4020 · 30forEach → 큐 삽입순서 보장 없음큐 원소 수5삽입 순서는 가변forEachOrdered콜백 순서 보존붙인 문자열5 1 4 2 3
같은 원본의 세 최종 연산은 서로 다른 순서 계약을 가진다원ParallelEncounterOrder의별도세파이프라인이다. maptoList결과50,10,40,20,30은만남순서를보존하고forEach큐는원소수5만기대한다. forEachOrdered문자열은5 1 4 2 3이다. 7노드6원본→연산→결과관계이며mobile도동일하다. 입력만남순서·삽입순서·콜백시간순서를구별하고작업자완료시각을표현하지않는다.원본 만남 순서5 · 1 · 4 · 2 · 3map ×10 → toList만남 순서 보존결과 List50 · 10 · 4020 · 30forEach → 큐 삽입순서 보장 없음큐 원소 수5삽입 순서는 가변forEachOrdered콜백 순서 보존붙인 문자열5 1 4 2 3

toList()

순서 계약
만남 순서로 결과 목록 구성
확인할 결과
[50, 10, 40, 20, 30]

forEach()

순서 계약
콜백 순서 보장 없음
확인할 결과
큐 크기 5 · 삽입 순서는 고정하지 않음

forEachOrdered()

순서 계약
만남 순서로 콜백을 하나씩 실행
확인할 결과
5 1 4 2 3

forEach의 큐는 삽입 순서가 정해지지 않습니다. 큐 크기 5는 확인하지만 콜백 완료 시각의 순서를 측정한 값은 아닙니다.

원문의 completionOrder는 큐에 값을 삽입한 순서를 담습니다. 이름과 달리 콜백 전체의 완료 시각을 계측한 기록은 아니며, 출력도 큐 크기만 보여 줍니다.

예시 map 뒤 toList는 원본의 만남 순서를 유지해 [50,10,40,20,30]입니다. forEach의 큐에는 다섯 값이 모두 들어가지만 삽입 순서를 원본과 같게 요구하지 않습니다. forEachOrdered가 StringBuilder에 붙인 결과는 5 1 4 2 3입니다. 결과 순서가 같다는 사실과 각 작업자가 계산을 시작하거나 끝낸 시간 순서는 따로 판단합니다. Stream의 forEach·forEachOrdered 계약은 이 콜백 순서의 범위를 설명합니다.

StringBuilder는 forEachOrdered가 앞 콜백의 동작을 다음 콜백보다 happens-before로 보장하는 경로에서만 사용했습니다. 모든 콜백이 같은 스레드에서 실행된다는 뜻은 아니며, 일반 병렬 콜백에서는 스레드 안전하지 않습니다.


unordered 적용 조건

unordered()는 원소를 임의로 섞는 연산이 아니라 이후 파이프라인에서 만남 순서를 유지해야 한다는 제약을 해제합니다.

distinct, limit, 수집기가 순서 제약에서 벗어나 최적화될 여지가 생깁니다.

결과 순서가 API 사용자에게 보이면 적용하지 않습니다.


Collector 특성과 병렬 축약

Collector의 공급자, 누산기, 결합 함수, 마무리 함수는 32장에서 살펴본 축약 규칙입니다.

병렬 실행에서는 수집기 특성이 추가 정보를 제공합니다.

CONCURRENT를 선언하려면 컨테이너와 누산기가 실제로 동시 갱신에 안전해야 합니다.

일반 HashMap에 선언만 붙이면 데이터 경합을 프레임워크에 숨기는 셈입니다.

다음 사용자 정의 수집기는 ConcurrentHashMap과 LongAdder를 사용하고 완성 시 불변 개수 Map으로 바꿉니다.

src/ConcurrentFrequencyCollector.java
import java.util.EnumSet;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.LongAdder;
import java.util.function.BiConsumer;
import java.util.function.BinaryOperator;
import java.util.function.Function;
import java.util.function.Supplier;
import java.util.stream.Collector;

public final class ConcurrentFrequencyCollector {
    private static final class Frequencies
            implements Collector<String, ConcurrentHashMap<String, LongAdder>, Map<String, Long>> {

        @Override
        public Supplier<ConcurrentHashMap<String, LongAdder>> supplier() {
            return ConcurrentHashMap::new;
        }

        @Override
        public BiConsumer<ConcurrentHashMap<String, LongAdder>, String> accumulator() {
            return (map, word) -> map.computeIfAbsent(word, key -> new LongAdder()).increment();
        }

        @Override
        public BinaryOperator<ConcurrentHashMap<String, LongAdder>> combiner() {
            return (left, right) -> {
                right.forEach(
                        (word, count) ->
                                left.computeIfAbsent(word, key -> new LongAdder())
                                        .add(count.sum()));
                return left;
            };
        }

        @Override
        public Function<ConcurrentHashMap<String, LongAdder>, Map<String, Long>> finisher() {
            return mutable ->
                    mutable.entrySet().stream()
                            .collect(
                                    java.util.stream.Collectors.toUnmodifiableMap(
                                            Map.Entry::getKey, entry -> entry.getValue().sum()));
        }

        @Override
        public Set<Characteristics> characteristics() {
            return EnumSet.of(Characteristics.CONCURRENT, Characteristics.UNORDERED);
        }
    }

    public static void main(String[] args) {
        Map<String, Long> counts =
                java.util.stream.Stream.of("java", "stream", "java", "fork", "stream", "java")
                        .parallel()
                        .collect(new Frequencies());
        System.out.println(counts);
    }
}

빈도 수집기의 특성과 구현 근거

빈 ConcurrentMap 공급에서 LongAdder 누산을 거쳐 Long 합계를 담은 수정 불가 Map을 완성합니다. 병렬 누산 순서를 꾸미지 않고 입력 빈도와 finisher의 타입·변경 계약 전환을 보여 줄 수 있습니다.

빈도 수집기의 특성과 구현 근거

빈도 수집기의 특성과 구현 근거

누산 동시성과 마무리 변환

빈도 수집기의 특성과 구현 근거

CONCURRENT와 UNORDERED를 선언합니다. LongAdder가 든 누산 맵을 Long 값의 수정 불가 맵으로 바꾸므로 IDENTITY_FINISH는 없습니다.

동시 누산 컨테이너와 완성 결과 Map을 구별한다원사용자정의frequencycollector의단어6개와supplier가만든빈ConcurrentHashMap이accumulator에각각도달한다. LongAdder누산뒤finisher가Long값을복사해변경불가결과Map을만든다. 5노드4데이터·초기상태·완성관계이며supplier가단어로부터Map을계산한다는관계는없다. CONCURRENT는같은누산컨테이너동시갱신허용,UNORDERED는순서비보존이고실행자수는지정하지않는다. finisher가새Map을만들기때문에IDENTITY_FINISH는없다. mobile동일하다.원 단어6개java3 · stream2fork1supplier 초기 상태빈 ConcurrentMapaccumulatorLongAdder로 누적동시 누적 허용finisherLong 합계를 복사결과 Mapjava3 · stream2fork1 · 변경 불가
동시 누산 컨테이너와 완성 결과 Map을 구별한다원사용자정의frequencycollector의단어6개와supplier가만든빈ConcurrentHashMap이accumulator에각각도달한다. LongAdder누산뒤finisher가Long값을복사해변경불가결과Map을만든다. 5노드4데이터·초기상태·완성관계이며supplier가단어로부터Map을계산한다는관계는없다. CONCURRENT는같은누산컨테이너동시갱신허용,UNORDERED는순서비보존이고실행자수는지정하지않는다. finisher가새Map을만들기때문에IDENTITY_FINISH는없다. mobile동일하다.원 단어6개java3 · stream2fork1supplier 초기 상태빈 ConcurrentMapaccumulatorLongAdder로 누적동시 누적 허용finisherLong 합계를 복사결과 Mapjava3 · stream2fork1 · 변경 불가
빈도 수집기의 특성과 구현 근거
특성의 의미원문의 선언구현 근거
같은 컨테이너에 동시 누적CONCURRENTConcurrentHashMap + LongAdder
결과의 순서 무관UNORDERED같은 단어의 개수는 입력 순서와 무관
항등 마무리 함수IDENTITY_FINISH 없음누산 맵을 수정 불가 Map<String, Long>으로 변환
같은 컨테이너에 동시 누적
원문의 선언
CONCURRENT
구현 근거
ConcurrentHashMap + LongAdder
결과의 순서 무관
원문의 선언
UNORDERED
구현 근거
같은 단어의 개수는 입력 순서와 무관
항등 마무리 함수
원문의 선언
IDENTITY_FINISH 없음
구현 근거
누산 맵을 수정 불가 Map<String, Long>으로 변환

동시 누적이 가능한 수집기라는 뜻입니다. 특정 작업 스레드 배치나 최종 맵의 키 순서를 정하지는 않습니다.

이 예의 조건

  • CONCURRENT는 동시 누산 허용, UNORDERED는 순서 비보존이며 실행자 수를 지정하지 않습니다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2원 사용자 정의 frequency collector의 단어 6개와 supplier가 만든 빈 ConcurrentHashMap이 accumulator에 각각 도달한다. LongAdder 누산 뒤 finisher가 Long 값을 복사해 변경 불가 결과 Map을 만든다. 5개 노드와 4개 관계는 데이터·초기 상태·완성의 관계이며 supplier가 단어로부터 Map을 계산한다는 관계는 없다. CONCURRENT는 같은 누산 컨테이너의 동시 갱신을 허용하고, UNORDERED는 순서를 보존하지 않으며 실행자 수는 지정하지 않는다. finisher가 새 Map을 만들기 때문에 IDENTITY_FINISH는 없다. mobile도 동일하다.
3원 단어 6개
5fork 1
15fork 1 · 변경 불가
원 도표의 설명과 주석
구분내용
설명원 사용자 정의 frequency collector의 단어 6개와 supplier가 만든 빈 ConcurrentHashMap이 accumulator에 각각 도달한다. LongAdder 누산 뒤 finisher가 Long 값을 복사해 변경 불가 결과 Map을 만든다. 5개 노드와 4개 관계는 데이터·초기 상태·완성의 관계이며 supplier가 단어로부터 Map을 계산한다는 관계는 없다. CONCURRENT는 같은 누산 컨테이너의 동시 갱신을 허용하고, UNORDERED는 순서를 보존하지 않으며 실행자 수는 지정하지 않는다. finisher가 새 Map을 만들기 때문에 IDENTITY_FINISH는 없다. mobile도 동일하다.
전체 읽기 정보빈도 수집기의 특성과 구현 근거 빈도 수집기의 특성과 구현 근거 빈도 수집기의 특성과 구현 근거 누산 동시성과 마무리 변환 빈도 수집기의 특성과 구현 근거 CONCURRENT와 UNORDERED를 선언합니다. LongAdder가 든 누산 맵을 Long 값의 수정 불가 맵으로 바꾸므로 IDENTITY_FINISH는 없습니다. 빈도 수집기의 특성과 구현 근거 특성의 의미 원문의 선언 구현 근거 같은 컨테이너에 동시 누적 CONCURRENT ConcurrentHashMap + LongAdder 결과의 순서 무관 UNORDERED 같은 단어의 개수는 입력 순서와 무관 항등 마무리 함수 IDENTITY_FINISH 없음 누산 맵을 수정 불가 Map<String, Long>으로 변환 같은 컨테이너에 동시 누적 원문의 선언 CONCURRENT 구현 근거 ConcurrentHashMap + LongAdder 결과의 순서 무관 원문의 선언 UNORDERED 구현 근거 같은 단어의 개수는 입력 순서와 무관 항등 마무리 함수 원문의 선언 IDENTITY_FINISH 없음 구현 근거 누산 맵을 수정 불가 Map<String, Long>으로 변환 동시 누적이 가능한 수집기라는 뜻입니다. 특정 작업 스레드 배치나 최종 맵의 키 순서를 정하지는 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
초기 컨테이너

supplier가 빈 ConcurrentHashMap을 만듭니다.

  • 단어: java 3, stream 2, fork 1
  • 초기 Map: 빈 상태
  • supplier는 단어에서 결과 Map을 계산하지 않습니다.
누산

accumulator가 LongAdder로 키별 횟수를 모읍니다.

  • java: 3
  • stream/fork: 2/1
  • 누산 컨테이너에는 가변 합계가 보관됩니다.
마무리 복사

finisher가 Long 값을 복사해 변경 불가 Map을 만듭니다.

  • 결과: java 3, stream 2, fork 1
  • IDENTITY_FINISH: 없음
  • 새 결과 Map이므로 누산 컨테이너와 완성 값이 다릅니다.

빈도 수집기의 특성과 구현 근거

빈 ConcurrentMap 공급에서 LongAdder 누산을 거쳐 Long 합계를 담은 수정 불가 Map을 완성합니다. 병렬 누산 순서를 꾸미지 않고 입력 빈도와 finisher의 타입·변경 계약 전환을 보여 줄 수 있습니다.

빈도 수집기의 특성과 구현 근거

빈도 수집기의 특성과 구현 근거

누산 동시성과 마무리 변환

빈도 수집기의 특성과 구현 근거

CONCURRENT와 UNORDERED를 선언합니다. LongAdder가 든 누산 맵을 Long 값의 수정 불가 맵으로 바꾸므로 IDENTITY_FINISH는 없습니다.

동시 누산 컨테이너와 완성 결과 Map을 구별한다원사용자정의frequencycollector의단어6개와supplier가만든빈ConcurrentHashMap이accumulator에각각도달한다. LongAdder누산뒤finisher가Long값을복사해변경불가결과Map을만든다. 5노드4데이터·초기상태·완성관계이며supplier가단어로부터Map을계산한다는관계는없다. CONCURRENT는같은누산컨테이너동시갱신허용,UNORDERED는순서비보존이고실행자수는지정하지않는다. finisher가새Map을만들기때문에IDENTITY_FINISH는없다. mobile동일하다.원 단어6개java3 · stream2fork1supplier 초기 상태빈 ConcurrentMapaccumulatorLongAdder로 누적동시 누적 허용finisherLong 합계를 복사결과 Mapjava3 · stream2fork1 · 변경 불가
동시 누산 컨테이너와 완성 결과 Map을 구별한다원사용자정의frequencycollector의단어6개와supplier가만든빈ConcurrentHashMap이accumulator에각각도달한다. LongAdder누산뒤finisher가Long값을복사해변경불가결과Map을만든다. 5노드4데이터·초기상태·완성관계이며supplier가단어로부터Map을계산한다는관계는없다. CONCURRENT는같은누산컨테이너동시갱신허용,UNORDERED는순서비보존이고실행자수는지정하지않는다. finisher가새Map을만들기때문에IDENTITY_FINISH는없다. mobile동일하다.원 단어6개java3 · stream2fork1supplier 초기 상태빈 ConcurrentMapaccumulatorLongAdder로 누적동시 누적 허용finisherLong 합계를 복사결과 Mapjava3 · stream2fork1 · 변경 불가
빈도 수집기의 특성과 구현 근거
특성의 의미원문의 선언구현 근거
같은 컨테이너에 동시 누적CONCURRENTConcurrentHashMap + LongAdder
결과의 순서 무관UNORDERED같은 단어의 개수는 입력 순서와 무관
항등 마무리 함수IDENTITY_FINISH 없음누산 맵을 수정 불가 Map<String, Long>으로 변환
같은 컨테이너에 동시 누적
원문의 선언
CONCURRENT
구현 근거
ConcurrentHashMap + LongAdder
결과의 순서 무관
원문의 선언
UNORDERED
구현 근거
같은 단어의 개수는 입력 순서와 무관
항등 마무리 함수
원문의 선언
IDENTITY_FINISH 없음
구현 근거
누산 맵을 수정 불가 Map<String, Long>으로 변환

동시 누적이 가능한 수집기라는 뜻입니다. 특정 작업 스레드 배치나 최종 맵의 키 순서를 정하지는 않습니다.

이 예의 조건

  • CONCURRENT는 동시 누산 허용, UNORDERED는 순서 비보존이며 실행자 수를 지정하지 않습니다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2원 사용자 정의 frequency collector의 단어 6개와 supplier가 만든 빈 ConcurrentHashMap이 accumulator에 각각 도달한다. LongAdder 누산 뒤 finisher가 Long 값을 복사해 변경 불가 결과 Map을 만든다. 5개 노드와 4개 관계는 데이터·초기 상태·완성의 관계이며 supplier가 단어로부터 Map을 계산한다는 관계는 없다. CONCURRENT는 같은 누산 컨테이너의 동시 갱신을 허용하고, UNORDERED는 순서를 보존하지 않으며 실행자 수는 지정하지 않는다. finisher가 새 Map을 만들기 때문에 IDENTITY_FINISH는 없다. mobile도 동일하다.
3원 단어 6개
5fork 1
15fork 1 · 변경 불가
원 도표의 설명과 주석
구분내용
설명원 사용자 정의 frequency collector의 단어 6개와 supplier가 만든 빈 ConcurrentHashMap이 accumulator에 각각 도달한다. LongAdder 누산 뒤 finisher가 Long 값을 복사해 변경 불가 결과 Map을 만든다. 5개 노드와 4개 관계는 데이터·초기 상태·완성의 관계이며 supplier가 단어로부터 Map을 계산한다는 관계는 없다. CONCURRENT는 같은 누산 컨테이너의 동시 갱신을 허용하고, UNORDERED는 순서를 보존하지 않으며 실행자 수는 지정하지 않는다. finisher가 새 Map을 만들기 때문에 IDENTITY_FINISH는 없다. mobile도 동일하다.
전체 읽기 정보빈도 수집기의 특성과 구현 근거 빈도 수집기의 특성과 구현 근거 빈도 수집기의 특성과 구현 근거 누산 동시성과 마무리 변환 빈도 수집기의 특성과 구현 근거 CONCURRENT와 UNORDERED를 선언합니다. LongAdder가 든 누산 맵을 Long 값의 수정 불가 맵으로 바꾸므로 IDENTITY_FINISH는 없습니다. 빈도 수집기의 특성과 구현 근거 특성의 의미 원문의 선언 구현 근거 같은 컨테이너에 동시 누적 CONCURRENT ConcurrentHashMap + LongAdder 결과의 순서 무관 UNORDERED 같은 단어의 개수는 입력 순서와 무관 항등 마무리 함수 IDENTITY_FINISH 없음 누산 맵을 수정 불가 Map<String, Long>으로 변환 같은 컨테이너에 동시 누적 원문의 선언 CONCURRENT 구현 근거 ConcurrentHashMap + LongAdder 결과의 순서 무관 원문의 선언 UNORDERED 구현 근거 같은 단어의 개수는 입력 순서와 무관 항등 마무리 함수 원문의 선언 IDENTITY_FINISH 없음 구현 근거 누산 맵을 수정 불가 Map<String, Long>으로 변환 동시 누적이 가능한 수집기라는 뜻입니다. 특정 작업 스레드 배치나 최종 맵의 키 순서를 정하지는 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
초기 컨테이너

supplier가 빈 ConcurrentHashMap을 만듭니다.

  • 단어: java 3, stream 2, fork 1
  • 초기 Map: 빈 상태
  • supplier는 단어에서 결과 Map을 계산하지 않습니다.
누산

accumulator가 LongAdder로 키별 횟수를 모읍니다.

  • java: 3
  • stream/fork: 2/1
  • 누산 컨테이너에는 가변 합계가 보관됩니다.
마무리 복사

finisher가 Long 값을 복사해 변경 불가 Map을 만듭니다.

  • 결과: java 3, stream 2, fork 1
  • IDENTITY_FINISH: 없음
  • 새 결과 Map이므로 누산 컨테이너와 완성 값이 다릅니다.

이 수집기는 CONCURRENT와 UNORDERED를 함께 선언하므로 같은 컨테이너에 동시 누적할 수 있지만, 구현이 반드시 그 경로를 택해야 하는 것은 아닙니다. 출력은 java=3, stream=2, fork=1의 매핑으로 읽고 키의 인쇄 순서는 고정하지 않습니다.

표준 groupingByConcurrent가 요구를 충족하면 사용자 정의 수집기보다 먼저 선택합니다.

이 사용자 정의 수집기에서 supplier는 빈 Map을 만들고 accumulator가 각 키의 LongAdder를 증가시킵니다. finisher는 합계를 Long 값으로 읽어 새 변경 불가 Map에 담습니다. 누산 Map과 반환 Map의 타입·역할이 달라 IDENTITY_FINISH를 선언할 수 없습니다. CONCURRENT는 같은 누산 컨테이너의 동시 갱신을 허용하는 계약이지, 작업자 수나 실제 실행 경로를 지정하는 지시가 아닙니다. Collector 특성의 정의를 함수의 실제 동작과 함께 대조하세요.

forEachOrdered를 쓰면 콜백이 병렬 실행에서도 안전하도록 바꿀 필요가 없나요?

순서를 보존하는 최종 연산이라는 사실만으로 외부 상태 변경이 좋은 설계가 되지는 않습니다.

콜백 이전의 중간 연산은 병렬로 실행될 수 있고 예외 뒤에는 일부 부수 효과가 남을 수 있습니다.

결과를 컬렉션으로 축약한 뒤 단일 스레드에서 외부 시스템에 반영하면 재시도와 롤백 경계가 더 명확합니다.


연습 문제

여러 문서의 태그를 병렬로 펼치고 태그별 개수를 세세요.

공유 HashMap을 직접 수정하지 말고 표준 동시성 수집기를 사용하며, 결과 키 순서를 요구하지 않습니다.

해설 보기
exercise/ParallelTagFrequencySolution.java
import java.util.List;
import java.util.concurrent.ConcurrentMap;
import java.util.function.Function;
import java.util.stream.Collectors;

public final class ParallelTagFrequencySolution {
    private record Document(String title, List<String> tags) {}

    public static void main(String[] args) {
        List<Document> documents =
                List.of(
                        new Document("stream", List.of("java", "api")),
                        new Document("pool", List.of("java", "concurrency")),
                        new Document("collector", List.of("api", "java")));

        ConcurrentMap<String, Long> counts =
                documents.parallelStream()
                        .flatMap(document -> document.tags().stream())
                        .unordered()
                        .collect(
                                Collectors.groupingByConcurrent(
                                        Function.identity(), Collectors.counting()));

        System.out.println("java=" + counts.get("java"));
        System.out.println("api=" + counts.get("api"));
        System.out.println("concurrency=" + counts.get("concurrency"));
    }
}

종료 기준은 각 태그의 개수가 3, 2, 1이고 실행 순서나 작업자 이름을 기대값에 넣지 않는 것입니다.

결과가 화면에서 정렬돼야 한다면 병렬 집계가 끝난 뒤 항목을 정렬합니다.

독립 확인: 태그 예제 연습의 첫 문서에 java 태그를 한 번 더 추가하고 다른 두 문서는 그대로 둡니다. 태그 빈도와 java가 들어 있는 문서 수를 각각 구하세요. 각 문서의 tags를 distinct로 만든 뒤 flatMap하면 어떤 요구를 계산하나요? 빈 문서를 추가했을 때 개수는 바뀌나요?

풀이 확인

태그 등장 빈도는 java 4·api 2·concurrency 1이고 java가 있는 문서는 여전히 3개입니다. 문서별 distinct 뒤에 펼치면 같은 문서 안의 중복 java를 한 번으로 세어 java 3입니다. 문서마다 한 번 등장한 태그의 빈도를 계산하는 것이며 전체 입력에서 태그를 한 번씩만 남긴 결과와도 다릅니다. 태그가 없는 문서는 펼칠 원소가 없어 현재 개수를 바꾸지 않습니다. 키 인쇄 순서나 콜백 실행 순서는 이 기대값에 포함하지 않습니다.