parallelStream とは、コレクションの要素を複数のスレッドに分けて同時に処理する「並列ストリーム」を作るメソッドです。 list.stream() を list.parallelStream() に変えるだけで並列になり、重い計算を多くの要素に行う処理では実行時間を短くできます。一方で、要素が少ない処理ではかえって時間がかかり、処理の順序や共有する変数の扱いにも気を配る必要があります。

この記事では、要素数(100件・1万件・1000万件)と処理の重さ別に stream と parallelStream の実行時間を実測し、速くなる条件と遅くなる条件を表にまとめました。さらに、ArrayList に forEach で add して件数が欠ける様子と ArrayIndexOutOfBoundsException を再現して直し方を示し、forEach と forEachOrdered の出力差、共通プールのスレッド数の確認コード、findFirst と findAny の違いも実行結果つきで載せています。

確認環境:Ubuntu 24.04 / OpenJDK 21.0.10 / CPU 2コア(java ファイル名.java の単一ファイル実行)。掲載した出力と実行時間は、すべてこの環境で実行して得たものです。

parallelStreamの早見表

知りたいこと 答え
並列にする書き方 list.parallelStream() または stream().parallel()
順次に戻す書き方 sequential()
並列かどうかを調べる isParallel()
使われるスレッド 共通プール(ForkJoinPool.commonPool)のワーカーと、呼び出したスレッド
共通プールの並列数 この環境では CPU 2コアに対して 1(availableProcessors − 1)
forEach の順序 並列では実行のたびに変わる
順序どおりに処理したい forEachOrdered
結果を List にする toList() / collect(Collectors.toList())。並列でも元の順序のまま
外の ArrayList に add 件数が欠ける・例外になる。collect で集める
findFirst 並列でも先頭の要素
findAny 並列ではどれか1つ(実行ごとに変わることがある)

速くなる条件・遅くなる条件(この記事の実測より)

条件 stream parallelStream 結果
軽い処理(掛け算1回)× 100件 0.002 ms 0.010 ms 遅くなる(0.25倍)
軽い処理 × 1万件 0.015 ms 0.013 ms ほぼ同じ
軽い処理 × 1000万件 36.4 ms 18.8 ms 速くなる(1.94倍)
重い処理(平方根200回)× 100件 0.124 ms 0.066 ms 速くなる(1.88倍)
重い処理 × 1万件 12.6 ms 7.3 ms 速くなる(1.74倍)
重い処理 × 1000万件 13,864 ms 7,357 ms 速くなる(1.88倍)
10ミリ秒待つ処理 × 100件 1,019 ms 507 ms 速くなる(2.01倍)
全スレッドが1つのロックを取り合う処理 × 30万件 394 ms 683 ms 遅くなる(0.58倍)

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Java parallelStreamの使い方と注意点|速度比較と順序」

parallelStreamの基本の使い方

並列ストリームの作り方は2通りです。

書き方 使える対象
list.parallelStream() List・Set などのコレクション
stream().parallel() すべてのストリーム(IntStream、配列から作ったストリームなど)
import java.util.*;
import java.util.stream.*;

public class Basic {
    public static void main(String[] args) {
        List<Integer> nums = List.of(1, 2, 3, 4, 5, 6, 7, 8);

        // 1. コレクションから直接、並列ストリームを作る
        int a = nums.parallelStream().mapToInt(n -> n * n).sum();
        // 2. 途中で parallel() を呼ぶ(配列や IntStream でも使える)
        int b = nums.stream().parallel().mapToInt(n -> n * n).sum();
        int c = IntStream.rangeClosed(1, 8).parallel().map(n -> n * n).sum();
        // 3. sequential() で順次に戻す
        Stream<Integer> s = nums.parallelStream().sequential();

        System.out.println(a + " " + b + " " + c);
        System.out.println("nums.stream().isParallel()                     : " + nums.stream().isParallel());
        System.out.println("nums.parallelStream().isParallel()             : " + nums.parallelStream().isParallel());
        System.out.println("nums.parallelStream().sequential().isParallel(): " + s.isParallel());

        // 結果をListで受け取る。並列でも元の順序のまま
        System.out.println(nums.parallelStream().map(n -> n * 10).toList());
        System.out.println(nums.parallelStream().filter(n -> n % 2 == 0).collect(Collectors.toList()));
    }
}

実行結果:

204 204 204
nums.stream().isParallel()                     : false
nums.parallelStream().isParallel()             : true
nums.parallelStream().sequential().isParallel(): false
[10, 20, 30, 40, 50, 60, 70, 80]
[2, 4, 6, 8]

3通りとも合計は同じ 204 です。map・filter・sum・toList などの書き方は stream と同じで、変わるのは「どのスレッドが、どの順番で処理するか」だけです。sequential() を呼ぶと順次に戻り、isParallel() が false になります。

共通プールのスレッド数を確認する

並列ストリームは、特に指定しなければ JVM に1つある共通プール(ForkJoinPool.commonPool)のスレッドで動きます。スレッド数は次のコードで確かめられます。

import java.util.*;
import java.util.concurrent.*;
import java.util.stream.*;

public class PoolInfo {
    public static void main(String[] args) {
        System.out.println("availableProcessors : " + Runtime.getRuntime().availableProcessors());
        System.out.println("共通プールの並列数  : " + ForkJoinPool.getCommonPoolParallelism());
        System.out.println("commonPool()        : " + ForkJoinPool.commonPool().getParallelism());

        Set<String> threads = new ConcurrentSkipListSet<>();
        IntStream.range(0, 1_000_000).parallel().forEach(i -> threads.add(Thread.currentThread().getName()));
        System.out.println("使われたスレッド数  : " + threads.size());
        threads.forEach(t -> System.out.println("  " + t));

        Set<String> seq = new TreeSet<>();
        IntStream.range(0, 1_000_000).forEach(i -> seq.add(Thread.currentThread().getName()));
        System.out.println("streamのスレッド    : " + seq);
    }
}

実行結果(CPU 2コアの環境):

availableProcessors : 2
共通プールの並列数  : 1
commonPool()        : 1
使われたスレッド数  : 2
  ForkJoinPool.commonPool-worker-1
  main
streamのスレッド    : [main]

読み取れることは3つです。

  • 共通プールの並列数は 1 で、availableProcessors(2)より1つ少ない値でした
  • 実際に処理したスレッドは main と ForkJoinPool.commonPool-worker-1 の2つ。呼び出したスレッド(main)も処理に加わるので、合計は CPU のコア数と同じになります
  • stream(順次)は main スレッドだけで処理します

つまり、並列でどれだけ速くなるかの上限は、ほぼ CPU のコア数で決まります。この環境では最大でも約2倍です。

並列数を変える

公式ドキュメントに記載のあるシステムプロパティ java.util.concurrent.ForkJoinPool.common.parallelism で、共通プールの並列数を指定できます。同じコードを次のように起動しました。

$ java -Djava.util.concurrent.ForkJoinPool.common.parallelism=4 PoolInfo.java
availableProcessors : 2
共通プールの並列数  : 4
commonPool()        : 4
使われたスレッド数  : 5
  ForkJoinPool.commonPool-worker-1
  ForkJoinPool.commonPool-worker-2
  ForkJoinPool.commonPool-worker-3
  ForkJoinPool.commonPool-worker-4
  main
streamのスレッド    : [main]

ワーカーが4つに増え、main と合わせて5つのスレッドが使われました。共通プールはアプリケーション全体で共有されるので、変更するときは他の並列処理への影響も合わせて確かめます。

速度比較:要素数と処理の重さ別の実測

要素数を 100件・1万件・1000万件、処理を「軽い(掛け算1回)」「重い(平方根を200回)」に分けて、stream と parallelStream の時間を測りました。ウォームアップのあと複数回実行し、いちばん短かった時間を載せています。

import java.util.*;
import java.util.function.*;
import java.util.stream.*;

public class Bench {
    // 軽い処理: 掛け算1回
    static long light(int x) { return x * 2L; }
    // 重い処理: 平方根を200回計算
    static long heavy(int x) {
        double d = x;
        for (int i = 0; i < 200; i++) { d = Math.sqrt(d + i); }
        return (long) d;
    }

    static double best(LongSupplier f, int warm, int runs) {
        for (int i = 0; i < warm; i++) { f.getAsLong(); }
        long best = Long.MAX_VALUE;
        for (int i = 0; i < runs; i++) {
            long t = System.nanoTime();
            f.getAsLong();
            best = Math.min(best, System.nanoTime() - t);
        }
        return best / 1_000_000.0;
    }

    static void compare(String label, List<Integer> data, IntToLongFunction op, int warm, int runs) {
        double seq = best(() -> data.stream().mapToLong(x -> op.applyAsLong(x)).sum(), warm, runs);
        double par = best(() -> data.parallelStream().mapToLong(x -> op.applyAsLong(x)).sum(), warm, runs);
        System.out.printf("%-26s stream %10.3f ms / parallelStream %10.3f ms / 比 %.2f倍%n", label, seq, par, seq / par);
    }

    public static void main(String[] args) {
        System.out.println("Java " + Runtime.version() + " / CPU " + Runtime.getRuntime().availableProcessors()
            + " / 共通プールの並列数 " + java.util.concurrent.ForkJoinPool.getCommonPoolParallelism());
        for (int n : new int[]{100, 10_000, 10_000_000}) {
            List<Integer> data = IntStream.range(0, n).boxed().collect(Collectors.toCollection(ArrayList::new));
            int warm = n >= 10_000_000 ? 2 : 200, runs = n >= 10_000_000 ? 5 : 50;
            compare(String.format("軽い処理 %,d件", n), data, Bench::light, warm, runs);
            compare(String.format("重い処理 %,d件", n), data, Bench::heavy, warm, n >= 10_000_000 ? 3 : runs);
        }

        // 待ち時間のある処理: 1件ごとに 10 ミリ秒待つ
        List<Integer> small = IntStream.range(0, 100).boxed().toList();
        LongSupplier seqWait = () -> small.stream().mapToLong(Bench::sleep10).sum();
        LongSupplier parWait = () -> small.parallelStream().mapToLong(Bench::sleep10).sum();
        double s = best(seqWait, 0, 1), p = best(parWait, 0, 1);
        System.out.printf("%-26s stream %10.3f ms / parallelStream %10.3f ms / 比 %.2f倍%n", "10ms待つ処理 100件", s, p, s / p);

        // データの入れ物による違い(軽い処理 100万件)
        List<Integer> arrayList = IntStream.range(0, 1_000_000).boxed().collect(Collectors.toCollection(ArrayList::new));
        List<Integer> linkedList = new LinkedList<>(arrayList);
        compare("ArrayList 100万件 重い処理", arrayList, Bench::heavy, 3, 5);
        compare("LinkedList 100万件 重い処理", linkedList, Bench::heavy, 3, 5);
        double it1 = best(() -> Stream.iterate(0, i -> i + 1).limit(1_000_000).mapToLong(Bench::heavy).sum(), 3, 5);
        double it2 = best(() -> Stream.iterate(0, i -> i + 1).parallel().limit(1_000_000).mapToLong(Bench::heavy).sum(), 3, 5);
        System.out.printf("%-26s stream %10.3f ms / parallelStream %10.3f ms / 比 %.2f倍%n", "iterate+limit 100万件 重い処理", it1, it2, it1 / it2);
    }

    static long sleep10(int x) {
        try { Thread.sleep(10); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        return x;
    }
}

java -Xmx3g Bench.java の実行結果:

Java 21.0.10+7-Ubuntu-124.04 / CPU 2 / 共通プールの並列数 1
軽い処理 100件                  stream      0.002 ms / parallelStream      0.010 ms / 比 0.25倍
重い処理 100件                  stream      0.124 ms / parallelStream      0.066 ms / 比 1.88倍
軽い処理 10,000件               stream      0.015 ms / parallelStream      0.013 ms / 比 1.18倍
重い処理 10,000件               stream     12.649 ms / parallelStream      7.269 ms / 比 1.74倍
軽い処理 10,000,000件           stream     36.447 ms / parallelStream     18.756 ms / 比 1.94倍
重い処理 10,000,000件           stream  13864.342 ms / parallelStream   7357.329 ms / 比 1.88倍
10ms待つ処理 100件              stream   1018.700 ms / parallelStream    507.113 ms / 比 2.01倍
ArrayList 100万件 重い処理       stream   1386.812 ms / parallelStream    725.714 ms / 比 1.91倍
LinkedList 100万件 重い処理      stream   1337.592 ms / parallelStream    895.555 ms / 比 1.49倍
iterate+limit 100万件 重い処理   stream   1363.839 ms / parallelStream    870.527 ms / 比 1.57倍

「比」は stream の時間 ÷ parallelStream の時間で、1より大きいほど並列のほうが短時間です。

読み取れること 根拠となった行
軽い処理を少ない件数に行うと、並列のほうが時間がかかる 軽い処理100件は 0.25倍
軽い処理でも、件数が非常に多ければ並列が効く 軽い処理1000万件は 1.94倍
1件あたりの処理が重ければ、100件でも並列が効く 重い処理100件は 1.88倍
待ち時間のある処理は、スレッド数の分だけ短くなる 10ms待つ処理は 2.01倍
分割しやすい入れ物ほど効果が出やすい ArrayList 1.91倍、LinkedList 1.49倍、iterate+limit 1.57倍

並列にすると、要素を分割する・スレッドに渡す・結果をまとめる、という準備の時間が加わります。「1件あたりの処理時間 × 件数」がこの準備時間より十分大きいときに、並列の効果が出ます。 軽い処理100件は全体で 0.002 ms しかかからないため、準備時間のほうが大きくなりました。

なお、この数値は CPU 2コアの共有マシンで、ほかの処理も動いている状態での計測です。実行のたびに数値は揺れ、コア数が多いマシンでは比も変わります。大小関係の目安として読み、実際に使う環境とデータ量で測って決めるのが確実です。

ロックを取り合う処理は速くならない

もう1つ、全スレッドが同じロックを取り合う処理を測りました。

import java.util.*;
import java.util.function.*;
import java.util.stream.*;

public class Bench2 {
    static final Object LOCK = new Object();

    static long heavy(int x) {
        double d = x;
        for (int i = 0; i < 200; i++) { d = Math.sqrt(d + i); }
        return (long) d;
    }
    // 全スレッドで1つのロックを取り合う
    static long heavyLocked(int x) { synchronized (LOCK) { return heavy(x); } }

    static double best(LongSupplier f) {
        for (int i = 0; i < 3; i++) { f.getAsLong(); }
        long best = Long.MAX_VALUE;
        for (int i = 0; i < 5; i++) {
            long t = System.nanoTime();
            f.getAsLong();
            best = Math.min(best, System.nanoTime() - t);
        }
        return best / 1_000_000.0;
    }

    static void print(String label, double seq, double par) {
        System.out.printf("%-30s stream %9.3f ms / parallelStream %9.3f ms / 比 %.2f倍%n", label, seq, par, seq / par);
    }

    public static void main(String[] args) {
        List<Integer> data = IntStream.range(0, 300_000).boxed().collect(Collectors.toCollection(ArrayList::new));

        print("ロックなし 30万件",
            best(() -> data.stream().mapToLong(Bench2::heavy).sum()),
            best(() -> data.parallelStream().mapToLong(Bench2::heavy).sum()));
        print("共有ロックあり 30万件",
            best(() -> data.stream().mapToLong(Bench2::heavyLocked).sum()),
            best(() -> data.parallelStream().mapToLong(Bench2::heavyLocked).sum()));
    }
}

実行結果:

ロックなし 30万件                     stream   419.513 ms / parallelStream   161.095 ms / 比 2.60倍
共有ロックあり 30万件                   stream   394.017 ms / parallelStream   683.211 ms / 比 0.58倍

ロックがなければ並列で短くなりましたが、処理全体を1つの synchronized で囲むと、同時に動けるスレッドは1つだけになり、並列のほうが時間がかかりました(0.58倍)。共有するロックや、同時に1つしか使えない資源を待つ処理は、並列にしても効果が出にくいと言えます。

速くなる条件・遅くなる条件のまとめ

観点 速くなりやすい 遅くなりやすい・効果が出にくい
1件あたりの処理 重い計算、待ち時間のある処理 掛け算1回のような軽い処理
件数 多い(軽い処理は1000万件で効果が出た) 少ない(軽い処理100件では遅くなった)
データの入れ物 ArrayList、配列、IntStream.range LinkedList、Stream.iterate
共有するもの なし(要素ごとに独立した計算) 共有ロック、共有の変数への書き込み
CPU のコア数 多い 1〜2コア

注意点1:ArrayListにforEachでaddすると件数が欠ける

並列ストリームでいちばん気をつけたいのが、ラムダ式の中から、スレッドセーフでない共有オブジェクトを書き換える書き方です。ArrayList に forEach で add する例を再現しました。

import java.util.*;
import java.util.stream.*;

public class Broken {
    public static void main(String[] args) {
        for (int t = 1; t <= 8; t++) {
            List<Integer> result = new ArrayList<>();
            try {
                IntStream.range(0, 100_000).parallel().forEach(i -> result.add(i));
                System.out.println(t + "回目: 件数 " + result.size());
            } catch (Exception e) {
                System.out.println(t + "回目: " + e);
            }
        }
    }
}

実行結果:

1回目: 件数 50201
2回目: 件数 68218
3回目: 件数 80504
4回目: 件数 100000
5回目: 件数 83169
6回目: 件数 100000
7回目: 件数 76132
8回目: java.lang.ArrayIndexOutOfBoundsException: Index 2790 out of bounds for length 2776

10万件を add したはずが、件数が 50201 件や 68218 件に欠け、8回目は ArrayIndexOutOfBoundsException で止まりました。たまたま 100000 件そろう回もあるため、テストでは気づきにくいのが特徴です。公式ドキュメントのパッケージ説明でも ArrayList はスレッドセーフでないコレクションの例に挙げられており、複数のスレッドから同時に add する使い方には向きません。

直し方:collect や toList で集める

import java.util.*;
import java.util.concurrent.atomic.*;
import java.util.stream.*;

public class Fixed {
    public static void main(String[] args) {
        // 直し方1: 終端操作で集める(おすすめ)
        List<Integer> a = IntStream.range(0, 100_000).parallel().boxed().toList();
        List<Integer> b = IntStream.range(0, 100_000).parallel().boxed().collect(Collectors.toList());
        System.out.println("toList()            : 件数 " + a.size() + " 先頭 " + a.subList(0, 5));
        System.out.println("Collectors.toList() : 件数 " + b.size() + " 先頭 " + b.subList(0, 5));

        // 直し方2: スレッドセーフなリストに add する(件数はそろうが順序はばらばら)
        List<Integer> c = Collections.synchronizedList(new ArrayList<>());
        IntStream.range(0, 100_000).parallel().forEach(c::add);
        System.out.println("synchronizedList    : 件数 " + c.size() + " 先頭 " + c.subList(0, 5));

        // カウンタも同じ(100万回): int[] への加算は欠けることがある
        int[] counter = {0};
        IntStream.range(0, 1_000_000).parallel().forEach(i -> counter[0]++);
        AtomicInteger atomic = new AtomicInteger();
        IntStream.range(0, 1_000_000).parallel().forEach(i -> atomic.incrementAndGet());
        long count = IntStream.range(0, 1_000_000).parallel().count();
        System.out.println("int[] に ++         : " + counter[0]);
        System.out.println("AtomicInteger       : " + atomic.get());
        System.out.println("count()             : " + count);

        // HashMap への put も同じ。groupingBy で集める
        Map<Integer, Long> grouped = IntStream.range(0, 100_000).parallel().boxed()
            .collect(Collectors.groupingBy(i -> i % 3, Collectors.counting()));
        System.out.println("groupingBy          : " + grouped);
    }
}

実行結果:

toList()            : 件数 100000 先頭 [0, 1, 2, 3, 4]
Collectors.toList() : 件数 100000 先頭 [0, 1, 2, 3, 4]
synchronizedList    : 件数 100000 先頭 [50000, 50001, 50002, 50003, 50004]
int[] に ++         : 970521
AtomicInteger       : 1000000
count()             : 1000000
groupingBy          : {0=33334, 1=33333, 2=33333}
書き方 件数 順序 評価
toList() / collect(Collectors.toList()) そろう 元の順序のまま おすすめ。スレッドごとに集めてから結合するので、共有オブジェクトへの書き込みがない
Collections.synchronizedList に add そろう ばらばら(先頭が 50000 から始まった) 件数はそろうが順序は保たれない
int[] に ++ 欠けることがある(970521) ― 100万回のうち約3万回分が欠けた。そろう回もあった
AtomicInteger そろう ― カウンタを共有するならこちら
count() そろう ― 件数が欲しいだけなら最短
Collectors.groupingBy そろう ― Map に集めるときも collect を使う

「外の変数に書き込む」のではなく、「終端操作の戻り値として受け取る」形にするのが基本です。公式ドキュメントのパッケージ説明でも、forEach で ArrayList に add する書き方を、副作用のない集め方に置き換える例が示されています。

注意点2:forEachの順序とforEachOrdered

並列ストリームの forEach は、要素を元の順序で処理するとは限りません。

import java.util.*;
import java.util.stream.*;

public class Order {
    public static void main(String[] args) {
        List<Integer> nums = IntStream.rangeClosed(1, 12).boxed().toList();

        System.out.print("stream forEach           : ");
        nums.stream().forEach(n -> System.out.print(n + " "));
        System.out.println();

        for (int t = 1; t <= 3; t++) {
            System.out.print("parallel forEach (" + t + "回目) : ");
            nums.parallelStream().forEach(n -> System.out.print(n + " "));
            System.out.println();
        }

        System.out.print("parallel forEachOrdered  : ");
        nums.parallelStream().forEachOrdered(n -> System.out.print(n + " "));
        System.out.println();

        System.out.println("parallel map + toList    : " + nums.parallelStream().map(n -> n * 2).toList());
        System.out.println("parallel sorted + toList : " + nums.parallelStream().sorted(Comparator.reverseOrder()).toList());

        System.out.println("--- どのスレッドが処理したか(forEachOrdered でも map は並列)");
        List.of("A", "B", "C", "D", "E", "F").parallelStream()
            .map(s -> s + "@" + Thread.currentThread().getName())
            .forEachOrdered(System.out::println);
    }
}

実行結果:

stream forEach           : 1 2 3 4 5 6 7 8 9 10 11 12 
parallel forEach (1回目) : 7 8 9 10 11 12 4 5 6 1 2 3 
parallel forEach (2回目) : 7 8 9 10 11 12 4 5 6 1 2 3 
parallel forEach (3回目) : 7 4 5 6 1 2 3 10 11 12 8 9 
parallel forEachOrdered  : 1 2 3 4 5 6 7 8 9 10 11 12 
parallel map + toList    : [2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24]
parallel sorted + toList : [12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1]
--- どのスレッドが処理したか(forEachOrdered でも map は並列)
A@main
B@ForkJoinPool.commonPool-worker-1
C@main
D@main
E@main
F@main
書き方 出力の順序
stream().forEach 1 から 12 の順
parallelStream().forEach ばらばら。1回目・2回目と3回目で並びが違った
parallelStream().forEachOrdered 1 から 12 の順
parallelStream().map(...).toList() 元の順序のまま
parallelStream().sorted(...).toList() 並べ替えた順のまま

公式ドキュメントでは、forEach の動作は明示的に非決定的で、並列ストリームでは元の順序を守ることを保証しない、と説明されています。順番に意味がある出力や書き込みには forEachOrdered を使います。

最後の6行は、forEachOrdered を使っても map の部分は複数のスレッド(main と worker-1)で処理されていることを示しています。forEachOrdered は「最後の処理を順序どおりに行う」もので、途中の map や filter は並列のままです。また、toList() や collect で受け取る場合は、並列でも元の順序が保たれます。順序が必要なら「forEach で出力する」より「List で受け取ってから使う」ほうが扱いやすくなります。

注意点3:findFirstとfindAnyの違い

import java.util.*;
import java.util.stream.*;

public class Find {
    public static void main(String[] args) {
        List<Integer> nums = IntStream.rangeClosed(1, 1_000_000).boxed().toList();

        System.out.println("stream   findFirst: " + nums.stream().filter(n -> n % 7 == 0).findFirst().get());
        System.out.println("stream   findAny  : " + nums.stream().filter(n -> n % 7 == 0).findAny().get());
        for (int t = 1; t <= 5; t++) {
            int first = nums.parallelStream().filter(n -> n % 7 == 0).findFirst().get();
            int any = nums.parallelStream().filter(n -> n % 7 == 0).findAny().get();
            System.out.println("parallel " + t + "回目  findFirst: " + first + "  findAny: " + any);
        }
        System.out.println("parallel anyMatch : " + nums.parallelStream().anyMatch(n -> n == 999_999));
    }
}

実行結果:

stream   findFirst: 7
stream   findAny  : 7
parallel 1回目  findFirst: 7  findAny: 500003
parallel 2回目  findFirst: 7  findAny: 250005
parallel 3回目  findFirst: 7  findAny: 250005
parallel 4回目  findFirst: 7  findAny: 250005
parallel 5回目  findFirst: 7  findAny: 250005
parallel anyMatch : true
メソッド stream parallelStream
findFirst() 先頭の 7 5回とも先頭の 7
findAny() 7 500003 や 250005(実行ごとに変わった)

findFirst は並列でも元の順序で最初の要素を返し、findAny は見つかったどれか1つを返します。 「条件に合うものが1つあればよい」場面では findAny、「最初の1件」に意味がある場面では findFirst を選びます。存在するかどうかだけを知りたいなら anyMatch が使えます。

注意点4:reduceに渡す関数と初期値

reduce は、並列ではデータをいくつかに分けて計算し、あとで結果をまとめます。そのため、渡す関数と初期値によっては結果が変わります。

import java.util.*;
import java.util.stream.*;

public class Reduce {
    public static void main(String[] args) {
        List<Integer> nums = IntStream.rangeClosed(1, 1000).boxed().toList();

        System.out.println("足し算 stream   : " + nums.stream().reduce(0, Integer::sum));
        System.out.println("足し算 parallel : " + nums.parallelStream().reduce(0, Integer::sum));
        System.out.println("引き算 stream   : " + nums.stream().reduce(0, (a, b) -> a - b));
        System.out.println("引き算 parallel : " + nums.parallelStream().reduce(0, (a, b) -> a - b));
        System.out.println("初期値10 stream   : " + nums.stream().reduce(10, Integer::sum));
        System.out.println("初期値10 parallel : " + nums.parallelStream().reduce(10, Integer::sum));
        System.out.println("文字連結 parallel : " + Stream.of("a", "b", "c", "d", "e", "f").parallel().collect(Collectors.joining()));
    }
}

実行結果:

足し算 stream   : 500500
足し算 parallel : 500500
引き算 stream   : -500500
引き算 parallel : 0
初期値10 stream   : 500510
初期値10 parallel : 500540
文字連結 parallel : abcdef
例 stream parallelStream 理由
足し算、初期値 0 500500 500500 足し算はどこで区切って計算しても同じ結果になる
引き算、初期値 0 -500500 0 引き算は計算の区切り方で結果が変わる
足し算、初期値 10 500510 500540 分けた部分ごとに初期値が使われ、10 が複数回足された

並列で reduce を使うときは、「どこで区切っても結果が同じになる関数」と「何回使っても結果を変えない初期値(足し算なら 0)」を渡します。 合計なら mapToInt(...).sum()、文字列の連結なら Collectors.joining() のように用意されたメソッドを使うと、並列でも同じ結果になります。

parallelStreamを使うかどうかの判断表

確認すること はい いいえ
1件あたりの処理が重い、または件数が非常に多いか 次へ stream のままでよい
要素ごとの処理が互いに独立しているか(外の変数に書き込まない) 次へ collect などに書き換えてから検討
処理の順序に依存していないか(または forEachOrdered・toList で足りるか) 次へ stream のままが扱いやすい
共有ロックや同時に1つしか使えない資源を待たないか 次へ 効果が出にくい
実際のデータ量で測って、短くなったか parallelStream を使う stream に戻す

書いたあとのチェックリスト

  • ラムダ式の中で、外の ArrayList・HashMap・配列・変数に書き込んでいないか
  • 結果は toList()・collect・sum・count などの戻り値で受け取っているか
  • 順序が必要な出力に forEachOrdered を使っているか
  • findAny と findFirst を意図どおりに選んでいるか
  • reduce の関数は、区切り方を変えても同じ結果になるか
  • stream と parallelStream の両方で時間を測ったか

よくある質問

Q. parallelStream にすれば必ず速くなりますか。 A. なりません。この記事の計測では、掛け算1回の軽い処理を100件に行うと stream が 0.002 ms、parallelStream が 0.010 ms で、並列のほうが時間がかかりました。速度比較の表では、重い処理や1000万件の処理は CPU 2コアの環境で約1.7〜2倍短くなりました。

Q. parallelStream で処理した結果の順番はどうなりますか。 A. forEach は順番が実行ごとに変わります。順番どおりに処理したいときは forEachOrdered を使います。toList() や collect(Collectors.toList()) で受け取った List は、並列でも元の順序のままでした。

Q. 並列ストリームは何本のスレッドで動きますか。 A. 特に指定しなければ共通プールのスレッドと、呼び出したスレッドで動きます。この環境(CPU 2コア)では共通プールの並列数が 1 で、main と合わせて2つのスレッドが使われました。ForkJoinPool.getCommonPoolParallelism() で確かめられます。

Q. parallelStream の中で List に add してもよいですか。 A. ArrayList への add は件数が欠けたり ArrayIndexOutOfBoundsException になったりします。toList() や collect(Collectors.toList()) で集める形に書き換えてください。

まとめ

  • parallelStream() か parallel() で並列になり、書き方は stream と同じ
  • 速くなるのは「重い処理」か「非常に多い件数」。軽い処理100件では並列のほうが時間がかかった
  • 速さの上限はほぼ CPU のコア数。共通プールの並列数はこの環境で availableProcessors − 1 だった
  • 外の ArrayList に forEach で add すると件数が欠け、例外になることもある。toList() や collect で集める
  • forEach は順序が変わる。順序が必要なら forEachOrdered、または List で受け取る
  • findFirst は並列でも先頭、findAny はどれか1つ
  • reduce には区切り方で結果が変わらない関数と初期値を渡す。最後は実際のデータ量で測って決める

参考・出典

  • Oracle「Java SE 21 API仕様 Stream」https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/stream/Stream.html
  • Oracle「Java SE 21 API仕様 java.util.stream パッケージ」https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/stream/package-summary.html
  • Oracle「Java SE 21 API仕様 ForkJoinPool」https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/concurrent/ForkJoinPool.html

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Java parallelStreamの使い方と注意点|速度比較と順序」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?