1. 並列化できるものをすべて並列化する必要はない
Java にはタスクを並列化する方法がたくさんあります。しかし「並列化 = 常に速い」— これは、スープにさらに塩を入れればおいしくなると思うのと同じです。ある程度まではそうですが、それ以上は—試さないほうがよいのです。
ExecutorService は、起動して制御すべき明確なタスクがある場合に最適です:リクエスト処理、非同期データのロード、独立した計算など。プール内のスレッド数を自分で決め、タスクのライフサイクルを管理できます。
parallelStream は、操作が独立していて副作用がない場合にコレクション処理をすばやく並列化する方法です。「重い」コレクション(数万要素以上)で費用対効果が出ます。
ForkJoinPool は、サブタスクにうまく分割できるタスク(divide & conquer)に適した選択です。ソート、検索、大きな配列の集計など。parallelStream の内部でも使われますが、直接制御することもできます。
「念のため」に並列化しないでください。 タスクが小さい場合、スケジューリング、コンテキストスイッチ、同期のオーバーヘッドが利点を食いつぶします。
例: 並列化が不要な場合
List<Integer> smallList = List.of(1, 2, 3, 4, 5);
int sum = smallList.parallelStream()
.mapToInt(x -> x)
.sum(); // 5 個の数のために並列化するのはオーバーキル!
2. Thread-safety: 共有の可変データを避ける
並行の世界での最大の脅威はデータ競合(race conditions)です。複数のスレッドが同じ変数を変更すると、結果は予期せぬものになります。
- 共有の可変変数は避けましょう。 counter++ のような式でさえアトミックではありません。
- スレッドセーフなコレクションとアトミックな操作を使いましょう。 例えば、ConcurrentHashMap、CopyOnWriteArrayList、AtomicInteger、AtomicLong など。
- 並列ストリームで副作用を出さない。 parallelStream から外部の構造を変更しないでください。
悪いコードの例
List<Integer> numbers = Arrays.asList(1,2,3,4,5);
List<Integer> result = new ArrayList<>();
numbers.parallelStream().forEach(n -> result.add(n * 2)); // 危険!
ここでの result.add() はスレッドセーフではありません。結果として要素の欠落や例外が発生します。
正しいやり方
List<Integer> result = numbers.parallelStream()
.map(n -> n * 2)
.collect(Collectors.toList());
3. パフォーマンス: スレッドが多ければ常に良いとは限らない
小さなタスクを並列化するのは得策ではありません。 処理にミリ秒しかかからない場合、オーバーヘッドのため並列実行はかえって遅くなることがよくあります.
性能は計測しましょう。 手早い計測には System.nanoTime() が使えます:
long start = System.nanoTime();
// ... あなたのコード ...
long end = System.nanoTime();
System.out.println("実行時間: " + (end - start) + " ns");
本格的なマイクロベンチマークには JMH(Java Microbenchmark Harness)を使いましょう。
例: 逐次ストリームと並列ストリームの比較
List<Integer> bigList = IntStream.range(0, 1_000_000)
.boxed().collect(Collectors.toList());
long t1 = System.nanoTime();
long sum1 = bigList.stream().mapToLong(x -> x).sum();
long t2 = System.nanoTime();
long sum2 = bigList.parallelStream().mapToLong(x -> x).sum();
long t3 = System.nanoTime();
System.out.println("逐次: " + (t2 - t1) / 1_000_000 + " ms");
System.out.println("並列: " + (t3 - t2) / 1_000_000 + " ms");
自分のマシンで試してみてください。効果は本当に大きなコレクションや重い処理で顕著になります。
4. エラー処理: スレッド内の例外を無視しない
Future と例外処理
ExecutorService.submit() でタスクを起動した場合、例外は自動的に「伝播」しません — Future.get() で処理する必要があります:
Future<Integer> future = executor.submit(() -> {
if (Math.random() > 0.5) throw new RuntimeException("おっと!");
return 42;
});
try {
Integer result = future.get(); // ExecutionException をスローする可能性あり
} catch (ExecutionException e) {
System.err.println("タスク内のエラー: " + e.getCause());
}
ForkJoin と例外処理
ForkJoinPool では例外はタスク内に「包まれ」ます。join()/get() を呼ぶと表面化します:
ForkJoinPool pool = new ForkJoinPool();
RecursiveTask<Integer> task = new MyTask();
try {
int result = pool.invoke(task);
} catch (Exception e) {
System.err.println("ForkJoin のエラー: " + e);
}
InterruptedException の処理を忘れない
多くのメソッド(例えば Future.get()、Thread.sleep())は InterruptedException をスローします。これを「握りつぶさず」、適切に対応しましょう。割り込みフラグを再設定するか、タスクを終了してください。
5. 並行コードのデバッグとテスト
ロギングとデバッグ
並行バグは厄介で、しばしば不安定に再現します。スレッド名を付けてログを出しましょう: Thread.currentThread().getName()。誰がいつコードを実行しているかを理解する助けになります。
複雑なケースでは、マルチスレッド対応のデバッガ(例えば IntelliJ IDEA)を使用してください。場合によっては一時的に Thread.sleep() を入れると、まれなデータ競合を「捕まえられる」ことがあります。
マルチスレッドシナリオのテスト
並列操作に対しては専用のテストを用意し、条件待機ユーティリティ(例えば Awaitility)を使いましょう。そのようなテストは繰り返し実行してください。いくつかの問題は 100 回目や 1000 回目の実行で初めて表面化します。
6. 役立つ注意点とヒント
可読性と保守性: わかりやすい並行コードを書く
- ドキュメント化。 複雑な箇所やツール選定の理由をコメントしましょう。
- 高レベルの抽象を使う。 手動でスレッドを管理するのではなく、ExecutorService、parallelStream、ForkJoinPool を優先しましょう。
- 「魔法」を避ける。 簡単に済むなら、同期をむやみに複雑にしないでください。
表: どのツールをいつ使うか
| シナリオ | 推奨ツール |
|---|---|
| 多数の独立したタスク | |
| 大きなコレクションの処理 | parallelStream または ForkJoin |
| 「分割統治」型のタスク | |
| シンプルな非同期タスク | |
| 多数の小さなタスク | 逐次ストリーム |
| 副作用のあるタスク | スレッドセーフなコレクションのみ! |
並行プログラマの「戒律」
- スレッドセーフである確信がない限り、共有の可変変数を作らない。
- 並列化のための並列化はしない。潜在的な利益を評価する。
- プールを閉じるのを忘れない: shutdown()/shutdownNow()。
- 副作用のある操作に parallelStream を使わない。
- Future と ForkJoinTask からの例外処理を忘れない。
- InterruptedException を「握りつぶさない」— 適切にタスクを終了する。
7. 並行プログラミングでありがちなミス
ミス1: 小さなタスクの並列化。 初心者は何でも並列化しがちですが、処理がマイクロ秒レベルのときでさえ、オーバーヘッドでかえって遅くなります。
ミス2: ストリーム内の副作用。 parallelStream では外部の変数やコレクションを変更してはいけません — データ競合と予測不能なバグを招きます。
ミス3: 例外の無視。 Future.get() や ForkJoinTask のエラーを処理しないと、タスクが失敗した理由が分かりません。
ミス4: ExecutorService の shutdown() を忘れる。 明示的に終了しないと、アプリケーションが終了時に「ぶら下がる」ことがあります。
ミス5: スレッドセーフでないコレクションの使用。 通常の ArrayList に複数スレッドから書き込むのは、エラーへの近道です。
ミス6: InterruptedException の「握りつぶし」。 スレッドが割り込まれたら、それを尊重して適切に処理を終了しましょう。
ミス7: 同期ロジックの過度な複雑化。 過剰な synchronized ブロックは deadlock/livelock を引き起こします。高レベルの抽象を優先しましょう。
GO TO FULL VERSION