導入
Java 8で導入されたStream APIは、.parallel()を呼ぶだけで簡単に並列処理を実現できます。しかし、何も考えずに適用すると、かえってシングルスレッドよりもパフォーマンスが低下することが多々あります。本記事では、並列ストリームが抱える「オーバーヘッド」と、いつ使うべきかの「スレッショルド(閾値)」について解説します。
基礎知識
並列ストリームは、内部的にForkJoinPoolを使用してタスクを分割・並列実行します。ここで重要な用語が二つあります。
オーバーヘッド:データを分割し、各スレッドに割り当て、最後に結果をマージするために発生するコストです。
スレッショルド:並列化の恩恵がオーバーヘッドを上回るために必要なデータ量や処理負荷の基準です。
単純な計算や小規模なコレクションに対して並列処理を行うと、スレッドの管理コストが実際の処理時間を上回ってしまうのです。
実装/解決策
並列化を検討する際は、以下の「N×Qモデル」を参考にしてください。
N = データ要素数
Q = 要素ごとの処理コスト
この「N × Q」の値が大きい場合にのみ、並列化のメリットが出ます。
具体的には、以下の条件が揃っている場合にのみ.parallel()を検討してください。
1. データソースが分割容易であること(ArrayListや配列は最適ですが、LinkedListやHashSetは分割コストが高いため不向きです)。
2. 各要素の処理が独立しており、かつCPU負荷が高いこと。
サンプルプログラム
以下のコードは、単純な足し算(低負荷)と、重い計算(高負荷)で並列ストリームの挙動を比較する例です。
import java.util.stream.LongStream;
public class ParallelPerformanceDemo {
public static void main(String[] args) {
long n = 10_000_000;
// パターンA: 非常に軽い処理(並列化のオーバーヘッドが勝つ可能性大)
long start = System.currentTimeMillis();
long sum1 = LongStream.rangeClosed(1, n).parallel().sum();
System.out.println(“軽量処理(並列): ” + (System.currentTimeMillis() – start) + “ms”);
// パターンB: 重い処理(並列化の恩恵を受けやすい)
start = System.currentTimeMillis();
long sum2 = LongStream.rangeClosed(1, n).parallel().map(i -> heavyProcess(i)).sum();
System.out.println(“重量処理(並列): ” + (System.currentTimeMillis() – start) + “ms”);
}
// ダミーの重い処理
private static long heavyProcess(long i) {
long result = 0;
for (int j = 0; j < 100; j++) {
result += Math.sqrt(i);
}
return result;
}
}
応用・注意点
現場で最も注意すべきは、「共有状態の変更」です。並列ストリーム内で外部の変数に書き込みを行うと、スレッドセーフではなくなり、予期せぬバグを引き起こします。
また、並列ストリームはデフォルトでJVM全体の共通ForkJoinPoolを利用します。もし、特定の処理で重いI/O待ちが発生する場合、他のアプリケーションの並列処理まで巻き込んでスレッド枯渇を引き起こすリスクがあります。
結論として、「まずは逐次ストリーム(stream())で実装し、パフォーマンス測定を行い、ボトルネックが明確な場合にのみparallel()を適用する」という慎重な姿勢が、シニアエンジニアとしての正しいアプローチです。

コメント