【Java】Javaの並列Stream(parallelStream)の仕組みと使いどころ

Java

こんにちは、かつコーチです。
stream()parallelStream()に変えるだけで速くなると聞いて安易に使い、かえって遅くなった経験はありませんか。
今回はStreamAPIの基礎を理解している前提で、並列Streamの仕組みと「使ってよい場面・避けるべき場面」を解説します。

並列Streamの仕組み

ForkJoinPoolの上で動く

parallelStream()stream().parallel()は、内部的にForkJoinPool(分割統治タスクを複数スレッドで処理する共通のスレッドプール)を使って要素をスレッドに分配します。

import java.util.List;
import java.util.stream.IntStream;

List<Integer> numbers = IntStream.rangeClosed(1, 10)
        .boxed()
        .toList();

int sum = numbers.parallelStream()
        .mapToInt(Integer::intValue)
        .sum();

System.out.println(sum); // 55

コード上の変更はstream()parallelStream()にするだけですが、実行時の挙動は大きく変わります。
デフォルトではForkJoinPool.commonPool()(JVM全体で共有される共通プール)が使われ、スレッド数はおおよそ「CPUコア数-1」になります。

分割・処理・結合の3段階

並列Streamの処理は「要素をチャンクに分割する(Fork)」「各チャンクを別スレッドで処理する」「結果を結合する(Join)」という3段階で進みます。
このオーバーヘッド(分割・結合にかかるコスト)があるため、要素数が少ない・1要素あたりの処理が軽い場合は、並列化しない方が速いケースが多々あります。

並列Streamを使ってよい場面・避けるべき場面

使ってよい場面

  • 要素数が数万〜数百万のオーダーで多い
  • 1要素あたりの計算コストが高い(重い計算処理など)
  • 各要素の処理が独立しており、他の要素の結果に依存しない
  • 元のデータ構造がArrayListや配列など、分割コストの低いもの

避けるべき場面

  • 要素数が少ない(数百〜数千程度)
  • I/O処理(DB・API呼び出しなど)を含む
  • 共有の状態(外部変数やコレクション)を書き換える処理を含む
  • LinkedListのように分割コストが高いデータ構造を使っている

つまずきポイント:共有リストへの書き込みで結果が壊れた

実際に私が検証中にハマったのが、並列Streamの中で通常のArrayListaddしていくコードです。

// ❌Before:非スレッドセーフなListに並列で書き込む
import java.util.ArrayList;
import java.util.List;

List<Integer> results = new ArrayList<>();

IntStream.rangeClosed(1, 10000)
        .parallel()
        .forEach(i -> results.add(i * 2)); // スレッドセーフではない

System.out.println(results.size()); // 10000にならないことがある

ArrayListスレッドセーフではない(複数スレッドから同時に書き込むとデータ破損や例外が起きうる)ため、並列実行時に要素数がずれたり、まれにArrayIndexOutOfBoundsExceptionが発生したりします。
実際に手元の環境では、数回に1回9998のように件数が欠けることがありました。

// ✅After:collectでスレッドセーフに集約する
List<Integer> results = IntStream.rangeClosed(1, 10000)
        .parallel()
        .mapToObj(i -> i * 2)
        .toList();

System.out.println(results.size()); // 10000

並列Streamで結果を集めるときは、外部のコレクションに副作用として書き込むのではなく、collecttoListのようなStream標準の終端操作を使うのが鉄則です。
これらの終端操作は内部でスレッドセーフな結合処理を保証しています。

実務での判断軸

並列Streamを使うかどうかは「速くなりそうだから」ではなく、計測して判断するべきです。

long start = System.nanoTime();
long sum = numbers.stream().mapToLong(Integer::longValue).sum();
long sequentialTime = System.nanoTime() - start;

start = System.nanoTime();
long parallelSum = numbers.parallelStream().mapToLong(Integer::longValue).sum();
long parallelTime = System.nanoTime() - start;

System.out.println("逐次: " + sequentialTime + "ns");
System.out.println("並列: " + parallelTime + "ns");

要素数1万件程度の単純な合計計算では、並列化のオーバーヘッドの方が大きく、逐次の方が速いという結果になることが珍しくありません。
「並列Streamにすれば速くなる」という思い込みで導入せず、実際のデータ量・処理内容でベンチマークを取ってから判断することをおすすめします。
また、ForkJoinPool.commonPool()はアプリケーション全体で共有されるため、Webアプリケーションなど他の処理でも同じプールを使っている環境では、並列Streamの多用がリクエスト処理全体のスループットに影響することも念頭に置いておく必要があります。

まとめ

この記事のポイント

  • 並列StreamはForkJoinPoolを使い、要素を分割・並列処理・結合する3段階で動く
  • 要素数が多く、1要素の処理が重く、各要素が独立している場合に効果が出やすい
  • 非スレッドセーフなコレクションへの書き込みなど副作用を伴う処理は結果が壊れるため避ける
  • 使うかどうかは思い込みではなく、実測のベンチマークで判断する

次に読むべき記事

  • Collectorsの活用
  • ExecutorServiceでスレッドプールを使う

タグ: Java, 上級者向け, 関数型プログラミング

タイトルとURLをコピーしました