Java | Java 詳細・モダン文法:並行・非同期 – ExecutorService 詳細

Java Java
スポンサーリンク

ExecutorService を一言でいうと

ExecutorService は、
スレッドを自分で new せずに、“仕事(タスク)だけ渡して実行してもらう”ための仕組み」です。

new Thread(...).start() を直接使うと、
スレッドの数やライフサイクル管理がぐちゃぐちゃになりがちです。
ExecutorService は、スレッドのプールを内部に持ち、
「この処理を実行して」「結果を教えて」と“依頼”するスタイルにしてくれます。


なぜ ExecutorService が必要になるのか

Thread を直接 new する世界のつらさ

まず、素朴に Thread を直接使うコードを見てみます。

public class DirectThreadExample {
    public static void main(String[] args) {
        Runnable task = () -> {
            System.out.println("タスク実行: " + Thread.currentThread().getName());
        };

        for (int i = 0; i < 10; i++) {
            new Thread(task).start();
        }
    }
}
Java

これでも一応動きますが、問題が見えてきます。

同時に大量のタスクを投げたいとき、
new Thread を無制限に呼ぶと、スレッドが増えすぎて逆に遅くなったり、
OS のリソースを食い尽くしたりします。

また、

  • スレッドをいつ終わらせるか
  • 例外が起きたときどうするか
  • 結果をどう受け取るか

といった管理を、全部自分でやらないといけません。

ExecutorService の発想

そこで出てくるのが ExecutorService です。

発想はこうです。

スレッドは「自分で直接 new して管理するもの」ではなく、
裏側でプールしておいてもらい、“仕事(タスク)”だけ投げるもの」にしよう。

これによって、

  • スレッド数の上限を決められる
  • タスクのキューイング(順番待ち)ができる
  • 結果を Future で受け取れる
  • 最後にきちんと終了させる API がある

といった“まともな並行実行の土台”が手に入ります。


ExecutorService の基本的な使い方

1. 生成する(Executors からもらう)

一番よく使うのは、Executors クラスのファクトリメソッドです。

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class ExecutorBasic {
    public static void main(String[] args) {
        ExecutorService executor = Executors.newFixedThreadPool(4);

        // ここにタスクを投げていく
    }
}
Java

newFixedThreadPool(4) は、
「最大 4 本のスレッドを使い回しながらタスクを実行するプール」を作ります。

2. タスクを投げる(execute / submit)

タスクは RunnableCallable として渡します。

Runnable task = () -> {
    System.out.println("タスク実行: " + Thread.currentThread().getName());
};

executor.execute(task);
Java

execute は「投げっぱなし」で、戻り値はありません。
結果が欲しいときは submit を使います。

import java.util.concurrent.Callable;
import java.util.concurrent.Future;

Callable<Integer> task2 = () -> {
    System.out.println("計算中: " + Thread.currentThread().getName());
    Thread.sleep(1000);
    return 42;
};

Future<Integer> future = executor.submit(task2);

try {
    Integer result = future.get(); // 終わるまで待って結果を受け取る
    System.out.println("結果: " + result);
} catch (Exception e) {
    e.printStackTrace();
}
Java

ここでのポイントは、

  • Callable<V> は「戻り値を返すタスク」
  • submitFuture<V> を返し、get() で結果を待てる

という構造になっていることです。

3. 終了させる(shutdown / shutdownNow)

ExecutorService は、使い終わったら必ず終了させる必要があります。

executor.shutdown(); // 新しいタスクの受付を止め、今あるタスクが終わったら終了
Java

shutdownNow() を呼ぶと、
「今動いているタスクも含めて、できるだけ早く止めようとする」
より強い終了要求になりますが、
初心者のうちはまず shutdown() を覚えておけば十分です。


代表的な ExecutorService の種類

固定スレッドプール(newFixedThreadPool)

ExecutorService executor = Executors.newFixedThreadPool(4);
Java

常に最大 4 本のスレッドを使い回します。

タスクが 100 個あっても、
同時に動くのは 4 個までで、残りはキューに溜まって順番待ちになります。

CPU コア数に合わせてスレッド数を決めると、
「スレッドを増やしすぎて逆に遅くなる」ことを防げます。

シングルスレッド(newSingleThreadExecutor)

ExecutorService executor = Executors.newSingleThreadExecutor();
Java

常に 1 本のスレッドだけでタスクを順番に実行します。

「並列にはしたくないけど、別スレッドで順番に処理したい」
というときに使えます。

例えば、ログの書き込みや、
「順番が重要な処理」を 1 本のワーカーに任せるイメージです。

キャッシュスレッドプール(newCachedThreadPool)

ExecutorService executor = Executors.newCachedThreadPool();
Java

必要に応じてスレッドを増やし、
しばらく使われなかったスレッドは破棄する、
「伸び縮みするプール」です。

短時間で大量の短いタスクを捌きたいときに向きますが、
スレッド数が増えすぎる可能性もあるので、
初心者のうちは固定プールの方が扱いやすいです。


ExecutorService と Future の関係をもう少し深掘りする

Future は「あとで結果を取りに行くための約束」

submit が返す Future<V> は、
このタスクの結果を、あとで取りに行くためのハンドル」です。

Future<Integer> future = executor.submit(() -> {
    Thread.sleep(1000);
    return 42;
});

// ここで別の処理をしてもよい

Integer result = future.get(); // ここで待つ
Java

get() を呼ぶと、

  • まだ終わっていなければ、終わるまで待つ
  • 終わっていれば、すぐ結果を返す
  • タスク内で例外が起きていれば、その例外をラップして投げてくる

という動きをします。

タイムアウト付きで待つ

「永遠には待ちたくない」という場合は、
タイムアウト付きの get も使えます。

import java.util.concurrent.TimeUnit;

Integer result = future.get(2, TimeUnit.SECONDS);
Java

2 秒待っても終わらなければ、TimeoutException が投げられます。


ExecutorService を使うときに意識してほしいこと

「スレッドを直接触らない」癖をつける

まず大事なのは、
新しい並行処理を書き始めるとき、new Thread から入らない
という癖をつけることです。

「何かを並行でやりたい」と思ったら、

  • どんなタスクを実行したいか(Runnable / Callable)
  • どれくらいの並列度が欲しいか(スレッド数)
  • 結果が必要かどうか(Future)

を考え、ExecutorService を前提に設計する。

これだけで、
「スレッド地獄」に落ちる確率がかなり下がります。

必ず shutdown する

ExecutorService は、
shutdown() しない限りスレッドを持ち続けます。

main メソッドの最後で shutdown() を呼ばないと、
プログラムが終了しない、ということも普通に起こります。

try-with-resourcesExecutorService を扱えるようにするラッパーを自作する人もいるくらい、
「作ったら必ず閉じる」は重要な習慣です。

CPU バウンドか I/O バウンドかを意識する

スレッド数を決めるとき、

  • CPU をガリガリ使う処理(CPU バウンド)
  • ネットワークやディスク待ちが多い処理(I/O バウンド)

で最適な数が変わります。

CPU バウンドなら「コア数と同じか、少し多いくらい」。
I/O バウンドなら「コア数より多め」でも回ることが多いです。

最初はざっくりで構いませんが、
「なんとなく 100 スレッド」とかにしないで、
「なぜこの数なのか」を一言で説明できるようにしておくと、
設計の質が一段上がります。


まとめ:ExecutorService を自分の言葉で説明するなら

あなたの言葉で ExecutorService を説明すると、こうなります。

ExecutorService は、スレッドを自分で new する代わりに、
“タスクを投げると裏でスレッドプールがうまく実行してくれる”仕組み。
Executors.newFixedThreadPool(n) などでプールを作り、
executesubmit でタスクを渡し、
結果が必要なら Futureget() で受け取る。
使い終わったら shutdown() で必ず閉じる。

これを使うことで、
スレッド数の管理やライフサイクルを自分で抱え込まずに済み、
“スレッドを意識する”のではなく“タスクをどう並行に流すか”というレベルで
並行処理を設計できるようになる。」

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