1. 概要

スレッドプールの実装に関しては、Java標準ライブラリには豊富なオプションが用意されています。 固定およびキャッシュされたスレッドプールは、これらの実装の中でかなり遍在しています。

このチュートリアルでは、スレッドプールが内部でどのように機能しているかを確認し、これらの実装とそのユースケースを比較します。

2. キャッシュされたスレッドプール

Executors.newCachedThreadPool()を呼び出すと、Javaがキャッシュされたスレッドプールを作成する方法を見てみましょう。

public static ExecutorService newCachedThreadPool() {
    return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, 
      new SynchronousQueue<Runnable>());
}

キャッシュされたスレッドプールは、「同期ハンドオフ」を使用して新しいタスクをキューに入れています。 同期ハンドオフの基本的な考え方は単純ですが、直感に反します。別のスレッドが同時にアイテムを取得する場合にのみ、アイテムをキューに入れることができます。 つまり、SynchronousQueueはタスクを一切保持できません。

新しいタスクが入ってくるとしましょう。 キューで待機しているアイドル状態のスレッドがある場合、タスクプロデューサーはタスクをそのスレッドに渡します。 それ以外の場合、キューは常にいっぱいであるため、エグゼキュータはそのタスクを処理するための新しいスレッドを作成します

キャッシュされたプールはゼロスレッドで始まり、Integer.MAX_VALUEスレッドを持つようになる可能性があります。 実際には、キャッシュされたスレッドプールの唯一の制限は、使用可能なシステムリソースです。

システムリソースをより適切に管理するために、キャッシュされたスレッドプールは、1分間アイドル状態のままになっているスレッドを削除します。

2.1. ユースケース

キャッシュされたスレッドプール構成は、スレッド(名前の由来)を短時間キャッシュして、他のタスクに再利用します。 その結果、妥当な数の短期間のタスクを処理している場合に最適に機能します。 

ここで重要なのは「合理的」で「短命」です。 この点を明確にするために、キャッシュされたプールが適切でないシナリオを評価してみましょう。 ここでは、100万のタスクを送信します。各タスクの完了には100マイクロ秒かかります。

Callable<String> task = () -> {
    long oneHundredMicroSeconds = 100_000;
    long startedAt = System.nanoTime();
    while (System.nanoTime() - startedAt <= oneHundredMicroSeconds);

    return "Done";
};

var cachedPool = Executors.newCachedThreadPool();
var tasks = IntStream.rangeClosed(1, 1_000_000).mapToObj(i -> task).collect(toList());
var result = cachedPool.invokeAll(tasks);

これにより、不当なメモリ使用量につながる多くのスレッドが作成され、さらに悪いことに、多くのCPUコンテキストスイッチが作成されます。 これらの異常は両方とも、全体的なパフォーマンスを大幅に低下させます。

したがって、IOバウンドタスクのように実行時間が予測できない場合は、このスレッドプールを回避する必要があります。

3. 固定スレッドプール

固定スレッドプールが内部でどのように機能するかを見てみましょう。

public static ExecutorService newFixedThreadPool(int nThreads) {
    return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, 
      new LinkedBlockingQueue<Runnable>());
}

キャッシュされたスレッドプールとは対照的に、これは、期限が切れないスレッドの数が固定された無制限のキューを使用しています 。 したがって、スレッド数が増え続ける代わりに、固定スレッドプールは、固定量のスレッドで着信タスクを実行しようとします。 。 すべてのスレッドがビジー状態になると、エグゼキュータは新しいタスクをキューに入れます。  このようにして、プログラムのリソース消費をより細かく制御できます。

その結果、固定スレッドプールは、実行時間が予測できないタスクに適しています。

4. 不幸な類似点

これまでのところ、キャッシュされたスレッドプールと固定スレッドプールの違いのみを列挙してきました。

これらすべての違いはさておき、両方とも使用されています AbortPolicy 彼らのように飽和ポリシーしたがって、これらのエグゼキュータは、それ以上のタスクを受け入れることができず、キューに入れることさえできない場合に、例外をスローすることを期待しています。

現実の世界で何が起こるか見てみましょう。

キャッシュされたスレッドプールは、極端な状況でもますます多くのスレッドを作成し続けるため、実際には、飽和点に達することはありません。 同様に、固定スレッドプールは、キューにますます多くのタスクを追加し続けます。 したがって、固定プールも飽和点に達することはありません

両方のプールが飽和状態にならないため、負荷が非常に高い場合、スレッドの作成やタスクのキューイングのために大量のメモリを消費します。 負傷に侮辱を加えると、キャッシュされたスレッドプールにも多くのプロセッサコンテキストスイッチが発生します。

とにかく、でリソース消費をより細かく制御するには、カスタム ThreadPoolExecutorを作成することを強くお勧めします。

var boundedQueue = new ArrayBlockingQueue<Runnable>(1000);
new ThreadPoolExecutor(10, 20, 60, SECONDS, boundedQueue, new AbortPolicy());

ここで、スレッドプールは最大20のスレッドを持つことができ、最大1000のタスクのみをキューに入れることができます。 また、それ以上の負荷を受け入れることができない場合は、単に例外をスローします。

5. 結論

このチュートリアルでは、JDKソースコードを覗いて、さまざまなExecutorsが内部でどのように機能するかを確認しました。 次に、固定スレッドプールとキャッシュスレッドプール、およびそれらのユースケースを比較しました。

最後に、カスタムスレッドプールを使用して、これらのプールの制御不能なリソース消費に対処しようとしました。