1. ExecutorService: 大人の流儀でスレッドを管理する
なぜ単純に new Thread でスレッドを作るべきではないのか
マルチスレッドを始めたばかりの頃は、すべてが簡単に見えます:
Thread t = new Thread(() -> {
// 何かをする
});
t.start();
このやり方でも動きますが、タスクが増えるとすぐに負担になります。各 new Thread() 呼び出しで新しいスレッドが作られ、数十〜数百スレッドになるとシステムを圧迫します。さらに管理も不便です。いつ終了するのかの監視、エラー時の対応、停止や再利用の方法などを自分で面倒を見る必要があります.
そこで登場するのが ExecutorService —— 賢いスレッドディスパッチャです。タスクを渡すだけで、どのスレッドでいつ実行するかを自動で決めてくれます。結果として、より高速・安定に、そして頭を悩ませることなく動作します。
ExecutorService の仕組み
ExecutorService はシンプルだが効率的な原理で動作します。
- 内部には スレッドプール —— あらかじめ作られたワーカースレッドの集合(固定または動的) —— があります。
- タスクは キュー に入れられ、空いているスレッドが拾って実行します。
- サービスは ライフサイクル を管理します。完了を待機し、プールを正しく停止し、リソースを解放できます。
ExecutorService の作成
最も一般的なのは、Executors クラスのファクトリメソッドを使う方法です:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
ExecutorService executor = Executors.newFixedThreadPool(4); // 4 スレッド
- newFixedThreadPool(N) — N 本のスレッドを持つプール(多くの用途に適します)。
- newCachedThreadPool() — 必要に応じてスレッドを作成する動的プール(注意: タスクが雪崩のように来るとメモリ枯渇に陥る可能性があります)。
- newSingleThreadExecutor() — 1 本のスレッド(順次実行)。
例: Runnable を ExecutorService で起動
executor.submit(() -> {
System.out.println("スレッドプールからこんにちは!");
});
ExecutorService の使用が終わったら、必ず正しく終了させます:
executor.shutdown(); // 新しいタスクの追加を禁止し、現在のタスクの完了を待つ
重要: shutdown() を呼ばないと、プログラムが終了しない場合があります —— プール内のスレッドが新しいタスクを待ち続けるためです。
2. Runnable vs Callable: タスクには種類がある
Java 5 以前は、スレッドで何かを実行したいときはインターフェース Runnable を実装しました。これは戻り値がなく、チェック例外をスローしないタスクです。
Runnable task = () -> {
System.out.println("ただ作業するだけで、何も返しません!");
};
executor.submit(task);
Callable: 結果(と例外)を持つタスク
ときには、単に処理するだけでなく結果を返してほしいことがあります。たとえば合計値、計算結果、サーバーからのデータなどです。そのために用意されているのが Callable<T> インターフェースです.
import java.util.concurrent.Callable;
Callable<Integer> sumTask = () -> {
int sum = 0;
for (int i = 1; i <= 100; i++) sum += i;
return sum;
};
- メソッド call() は型 T の結果を返します。
- メソッド call() はチェック例外をスローすることがあります。
たとえ話: Runnable は「皿洗いしてきて」(結果は重要ではない)、Callable は「お茶を持ってきて、温度も教えて」(結果が重要)。
Callable の実行: 結果を受け取るには executor.submit(...) を使います。戻り値は Future<T> です。
3. Future: 結果の「約束」
Future は将来結果を返すという「約束」です。ExecutorService にタスクを送ると、Future が返り、後で結果の取得、完了確認、キャンセルができます。
Future の主なメソッド
- T get() — 結果を取得(タスクが終わるまで待機)。
- boolean isDone() — タスクが完了したか。
- boolean cancel(boolean mayInterruptIfRunning) — タスクのキャンセルを試みる。
- boolean isCancelled() — キャンセルされたか。
例: Callable の起動と結果の取得
import java.util.concurrent.*;
public class ParallelSumApp {
public static void main(String[] args) throws Exception {
ExecutorService executor = Executors.newFixedThreadPool(2);
Callable<Integer> sumTask = () -> {
int sum = 0;
for (int i = 1; i <= 100; i++) sum += i;
return sum;
};
Future<Integer> future = executor.submit(sumTask);
System.out.println("タスクを起動しました。ほかのことをしていて構いません...");
// 結果を取得(タスクがまだ終わっていない場合はこの呼び出しがブロックされる)
Integer result = future.get();
System.out.println("計算結果: " + result);
executor.shutdown();
}
}
- タスクはスレッドプールに送られます。
- タスク実行中、メインスレッドは別の作業をできます。
- 結果が必要になったら future.get() を呼びます —— タスクがまだ動いていればスレッドは待機します。
- タスクが完了するとすぐに結果が返ります。
4. 実践: 複数タスクの起動と完了待ち
複数のタスクを一度に起動し、すべての完了を待つ必要がよくあります。たとえば配列を分割し、各部分の合計を個別のタスクで計算するようなケースです。
例: 配列要素の部分合計
import java.util.*;
import java.util.concurrent.*;
public class ParallelArraySum {
public static void main(String[] args) throws Exception {
int[] array = new int[1000];
Arrays.setAll(array, i -> i + 1); // 1 から 1000 までの値で埋める
ExecutorService executor = Executors.newFixedThreadPool(4);
int chunkSize = array.length / 4;
List<Future<Integer>> futures = new ArrayList<>();
for (int i = 0; i < 4; i++) {
int from = i * chunkSize;
int to = (i == 3) ? array.length : (i + 1) * chunkSize;
Callable<Integer> sumTask = () -> {
int sum = 0;
for (int j = from; j < to; j++) sum += array[j];
System.out.println("合計: " + from + " から " + (to - 1) + " まで = " + sum);
return sum;
};
futures.add(executor.submit(sumTask));
}
int totalSum = 0;
for (Future<Integer> f : futures) {
totalSum += f.get(); // 各タスクを順に待つ
}
System.out.println("総和: " + totalSum);
executor.shutdown();
}
}
ここでは配列を 4 つに分割しています。各部分ごとにタスク(Callable)を作成して合計を計算し、すべてを ExecutorService に送って Future を受け取ります。最後に各タスクの結果を集めて加算します。
実務では すべてのタスクの完了を一括で待つには invokeAll を使うと便利です。
5. Future 利用時のエラーハンドリング
future.get() を呼ぶと、タスクが例外で終了していた場合、その例外は ExecutionException としてスローされます。重要なのは、タスク内で問題が起きても、それがわかるのは get() を呼んだ時点だということです。
例: 例外の処理
Callable<Integer> errorTask = () -> {
throw new IllegalArgumentException("うまくいきませんでした!");
};
Future<Integer> badFuture = executor.submit(errorTask);
try {
badFuture.get();
} catch (ExecutionException e) {
System.out.println("タスクはエラーで終了しました: " + e.getCause());
}
- タスク内部で例外が投げられます。
- get() を呼ぶと、それは ExecutionException に「包まれて」スローされます。
- 実際の原因は getCause() で取得できます。
6. 便利な細かなポイント
タスクのキャンセル方法
Future<?> f = executor.submit(() -> {
while (true) {
// 無限ループの作業
if (Thread.currentThread().isInterrupted()) {
System.out.println("終了するよう依頼されました!");
break;
}
}
});
Thread.sleep(100); // 少し待つ
f.cancel(true); // タスクのキャンセルを試みる
- cancel(true) は、未完了であればタスクの割り込みを試みます。
- タスク内では Thread.currentThread().isInterrupted() をチェックし、適切に終了するのが望ましいです。
shutdown vs shutdownNow
shutdown() — ソフトな停止: 新しいタスクの追加を禁止し、進行中のタスクに穏やかに終了させます。最もよく使われます。
shutdownNow() — ハードな停止: 実行中のスレッドに割り込みを試み、開始前のタスクの一覧を返します。慎重に使用してください。
invokeAll と invokeAny
invokeAll(Collection<Callable<T>> tasks) は渡したすべてのタスクを起動し、全タスクの完了まで待ちます。戻り値は Future のリストです。
invokeAny(Collection<Callable<T>> tasks) は最初に成功したタスクだけを待ち、その結果を返して残りをキャンセルします。最初の成功応答が重要な場合に便利です。
7. ExecutorService、Callable、Future でよくあるミス
エラー 1: ExecutorService を閉じない。 shutdown() を呼び忘れると、main の終了後もプログラムが「ぶら下がる」ことがあります。プールのスレッドが新しいタスクを待ち続けるためです。
エラー 2: タスク送信直後に結果を待ってしまう。 submit() の直後に get() を呼ぶと非同期の利点がなくなり、結局待つことになります。有用な処理を並行して進め、結果が本当に必要になった時に取得しましょう。
エラー 3: タスク中の例外を無視する。 get() 呼び出し時に ExecutionException を適切に処理しないと、タスク内で発生した重要なエラーを見落とす恐れがあります。
エラー 4: 共有の可変変数を同期なしで使う。 複数のタスクが同じデータにアクセスする場合は、同期やスレッドセーフなコレクションが必要です。
エラー 5: スレッドを作り過ぎる。 CPU コア数を大きく上回るスレッド数のプールは避けましょう。かえって実行が遅くなることがあります。
エラー 6: タスクのキャンセルを忘れる。 タスクが不要になったら cancel() でキャンセルし、リソースを無駄にしないようにしましょう。
GO TO FULL VERSION