Java | Java 標準ライブラリ:parallelStream の注意点

Java Java
スポンサーリンク

parallelStream を一言でいうと何か

parallelStream() は、

「Stream の処理を、CPU の複数コアを使って“自動で並列実行してくれるモード”」

です。

list.stream()
list.parallelStream() に変えるだけで、

データを複数の塊に分けて、
複数スレッドで同時に filtermap を走らせてくれます。

ただし、「速くなることもある」が、「逆に遅くなったり、バグの原因になったりする」ことも多い、ちょっとクセの強い機能です。

なので、どんなときに使ってはいけないか、何に気をつけるべきかを理解してから触るのが大事です。


基本の使い方と「シーケンシャルとの違い」

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);
    }
}
Java

ArrayList に対して 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);
    }
}
Java

collect(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() を検討する

という順番が、健全でバグりにくい流儀です。

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