parallelStream を一言でいうと何か
parallelStream() は、
「Stream の処理を、CPU の複数コアを使って“自動で並列実行してくれるモード”」
です。
list.stream() をlist.parallelStream() に変えるだけで、
データを複数の塊に分けて、
複数スレッドで同時に filter や map を走らせてくれます。
ただし、「速くなることもある」が、「逆に遅くなったり、バグの原因になったりする」ことも多い、ちょっとクセの強い機能です。
なので、どんなときに使ってはいけないか、何に気をつけるべきかを理解してから触るのが大事です。
基本の使い方と「シーケンシャルとの違い」
stream と parallelStream の見た目の違い
まずは単純な例から。
import java.util.List;
public class ParallelBasic {
public static void main(String[] args) {
List<Integer> list = List.of(1, 2, 3, 4, 5);
System.out.println("通常の stream:");
list.stream()
.forEach(n ->
System.out.println(Thread.currentThread().getName() + " : " + n)
);
System.out.println("parallelStream:");
list.parallelStream()
.forEach(n ->
System.out.println(Thread.currentThread().getName() + " : " + n)
);
}
}
Java通常の stream() では、ほぼ必ず main スレッドだけが表示されます。
parallelStream() の方は、ForkJoinPool.commonPool-worker-1 のような、複数のスレッド名が混じって出てくるはずです。
つまり、
stream() … 1 スレッドで順番に処理parallelStream() … 複数スレッドで“バラバラに”処理
ということです。
ここから、注意点が色々生まれます。
注意点1:処理が速くなるとは限らない(むしろ遅くなることも多い)
並列化には「オーバーヘッド(余計なコスト)」がある
parallelStream は、内部で「データを分割して、複数スレッドに分配して、結果をマージする」という追加処理を行っています。
この準備・分割・結合のコストがあるので、
一つ一つの処理が軽いとき(足し算、ちょっとした変換だけ)
データ量が少ないとき(数十〜数百件程度)
だと、「並列にするためのコストの方が重い」ことが多いです。
つまり、
並列化のオーバーヘッド > 並列化で得られる時間短縮
になり、結果として「普通の stream より遅くなる」ことも普通にあります。
どういうときに効果が出やすいか
逆に、parallelStream が効果を発揮しやすいのは、
要素数がかなり多い(何万〜何十万〜)
各要素の処理が重い(CPU 計算がっつり、I/O の待ち時間が少ない)
マシンにコアがたくさんある(サーバーなど)
という条件がそろっているときです。
初心者のうちは、
「とりあえず parallelStream にしておけば速くなるでしょ」
という発想は危険です。
基本は stream() で書いておき、本当に必要な箇所だけ計測の上で並列化する、が正しいスタンスです。
注意点2:副作用を持つ処理と相性が悪い(スレッドセーフではないコードが危険)
共有変数に書き込むコードはほぼアウト
parallelStream で一番やってはいけないのが、
「複数スレッドから同じ変数・オブジェクトをいじる」ことです。
悪い例を見てみます。
import java.util.ArrayList;
import java.util.List;
public class ParallelSideEffectBad {
public static void main(String[] args) {
List<Integer> list = List.of(1, 2, 3, 4, 5);
List<Integer> results = new ArrayList<>();
list.parallelStream()
.forEach(n -> results.add(n * 2)); // 非スレッドセーフな操作
System.out.println(results);
}
}
JavaArrayList に対して add しているので、一見よくありそうですが、parallelStream では複数スレッドから同時に add が呼ばれます。
ArrayList はスレッドセーフではないので、
要素が欠ける
順番がバラバラ
最悪 ConcurrentModification のような壊れ方
など、挙動が不定になります。
しかも、いつも壊れるわけではなく、「たまにしか壊れない」ことも多いので、
バグが非常に見つけづらいです。
正しい書き方:collect を使って「集約」してもらう
同じことを安全にやるなら、こんな感じにします。
import java.util.List;
import java.util.stream.Collectors;
public class ParallelSideEffectGood {
public static void main(String[] args) {
List<Integer> list = List.of(1, 2, 3, 4, 5);
List<Integer> results =
list.parallelStream()
.map(n -> n * 2)
.collect(Collectors.toList());
System.out.println(results);
}
}
Javacollect(Collectors.toList()) は、並列実行でも正しく動くように設計されています。
つまり、
自分で外部のリストに add するのではなく
collect や sum、count など「Stream API に備わった集約処理」に任せる
のが安全なスタイルです。
絶対に守りたい感覚
parallelStream を使うときは、
「ラムダの中で外部の状態を書き換えない」
という原則を強く意識してください。
外部の変数に代入する
外部のリスト・マップに追加する
クラスのフィールドを書き換える
こういう処理は、並列にすると途端に危険になります。
注意点3:順序保証が崩れる(forEach と forEachOrdered)
forEach は「順番を保証しない」
parallelStream() の forEach(...) は、
元の順番通りに実行されることを保証しません。
例えば、
List<Integer> list = List.of(1, 2, 3, 4, 5);
list.parallelStream()
.forEach(System.out::println); // 3,1,4,2,5 のように順序バラバラ
Java実行するたびに、出力順がコロコロ変わる可能性があります。
もし「順番通りに処理したい」「順番通りに出力したい」なら、forEach ではなく forEachOrdered を使う必要があります。
list.parallelStream()
.forEachOrdered(System.out::println); // 1,2,3,4,5 と順序を守る
Javaただし、forEachOrdered を使うと「順序を守るための制約」が増える分、
並列処理のメリットが削がれ、速度面での恩恵が小さくなりやすいです。
sorted / limit など「順序依存な中間操作」にも注意
sorted, limit, skip など、順序に意味がある操作は、parallelStream との相性に注意が必要です。
Stream API 自体はそれなりに頑張って順序を保ってくれますが、
複雑な処理で「順序を前提にしたロジック」を書き始めると、読みにくく・バグりやすくなります。
「順序が重要なロジック」なら、素直に stream()(シーケンシャル)で書いたほうが
思考負荷もバグリスクも低くなります。
注意点4:I/O 処理との相性(本当に速くなるか?)
ディスク・ネットワーク I/O は「並列にすると速い」とは限らない
ファイル読み書きやネットワークアクセスなどの I/O は、
サーバーや OS 側に「同時アクセスできる数や帯域の上限」があります。
例えば、大量のファイルを parallelStream で同時に読み始めると、
ディスク I/O が飽和して、むしろ遅くなる
他の処理が巻き込まれて全体が遅くなる
といった事態も起こり得ます。
また、外部 API を叩くコードを parallelStream で回すと、
一瞬で大量アクセスを叩き込んでしまい、向こう側のレートリミットに引っかかる
アプリ全体のスレッドが占有されてしまい、他の処理に悪影響が出る
など、予想外のトラブルにも繋がります。
CPU バウンドか I/O バウンドかを見極める
parallelStream でメリットが出やすいのは、
単純に CPU 計算だけ重い処理
(例:画像処理、数値計算、暗号化など)
のほうです。
I/O が絡む場合は、「並列度を自分で制御できる別の仕組み」(ExecutorService やリミット付きの非同期処理など)のほうが向いていることも多いです。
注意点5:ForkJoinPool.commonPool の“共有プール”問題
parallelStream は「共通のスレッドプール」を使う
parallelStream() は、デフォルトで ForkJoinPool.commonPool() という「共通のスレッドプール」を使います。
これは、アプリ全体で共有されるプールです。
そのため、どこか一箇所で heavy な parallelStream を回すと、
他の parallelStream や、CompletableFuture など commonPool を使う処理にも影響が出る
ことがあります。
例えば、Web アプリで、
リクエスト処理の中で parallelStream で重い処理を回していると
同じマシン上の他の処理(別リクエスト)も巻き込まれて遅くなる
といったことが起きます。
プールをカスタマイズする方法もあるが、初心者向けではない
必要なら
ForkJoinPool を自前で作るpool.submit(() -> list.parallelStream()....) のように流す
といった方法で「専用プール」を使うこともできますが、
ここから先はスレッドプール設計の話になってきます。
初心者の段階では、
parallelStream をアプリ全体で多用しない
本当に必要な箇所だけ、影響範囲を意識しながら使う
くらいに留めておくほうが安全です。
まとめ:parallelStream をどう扱うか(初心者用の指針)
parallelStream の注意点を、初心者向けに整理するとこうなります。
通常の stream() を書けるのが前提。そのうえで、「本当に必要なときだけ」使う。
速くなる保証はない。軽い処理・少ない件数なら、むしろ遅くなることも普通にある。
ラムダの中で外部状態を書き換えない(副作用禁止)。collect / sum など“集約”は API に任せる。
順序は基本的に保証されない。順序が大事な処理は素直に stream() を使う。
I/O 処理や Web アプリのリクエスト内での乱用は危険。共通スレッドプールを占有しやすい。
実務感覚でいうと、
- まずシンプルな
stream()で正しく・読みやすく・テストしやすく書く - 性能的に本当に困っていて、かつ処理が並列向き(独立・CPU重い)だと分かったところだけ
- 計測しながら
parallelStream()を検討する
という順番が、健全でバグりにくい流儀です。
