フォローしているかどうかに関係なく、Java Webアプリケーションはスレッドプールを使用して、リクエストを大幅に処理します。スレッドプールの実装の詳細は無視される場合がありますが、スレッドプールの使用とチューニングについて遅かれ早かれ理解する必要があります。この記事では、主にJavaスレッドプールの使用と、スレッドプールを正しく構成する方法を紹介します。
シングルスレッド
基本から始めましょう。どのアプリケーションサーバーまたはフレームワーク(Tomcat、Jettyなど)が使用されていても、同様の基本的な実装があります。 Webサービスの基礎はソケットであり、ポートを聴き、TCP接続を待機し、TCP接続の受け入れを担当します。 TCP接続が受け入れられると、新しく作成されたTCP接続からデータを読み取り、送信できます。
上記のプロセスを理解するために、アプリケーションサーバーを直接使用するのではなく、簡単なWebサービスをゼロから構築します。このサービスは、ほとんどのアプリケーションサーバーの縮図です。シンプルなシングルスレッドウェブサービスは次のようになります:
Serversocketリスナー= new Serversocket(8080); try {while(true){socket socket = ristener.accept(); try {handlerequest(socket); } catch(ioexception e){e.printstacktrace(); }}}最後に{ristener.close();}上記のコードでは、サーバーソケット(Serversocket)を作成し、ポート8080にリッスンし、ループしてソケットをチェックして新しい接続があるかどうかを確認します。新しい接続が受け入れられると、ソケットはHandLereQuestメソッドに渡されます。このメソッドは、データストリームをHTTP要求に解析し、応答し、応答データを書き込みます。この簡単な例では、HandLeRequestメソッドは、データストリームの読み取りを単純に実装し、単純な応答データを返します。一般的な実装では、この方法は、データベースなどからデータを読むなど、はるかに複雑になります。
最終的な静的文字列応答= "http/1.0 200 ok/r/n" + "content-type:text/plain/r/n" + "/r/n" + "hello world/r/n"; public static void handlerequest(socket socket)throws ioexception {//入力ストリームを読み取り、 "200 ok" try {bufferedreader in = new inputstreamreader(socket.getInputStream()); log.info(in.readline()); outputStream out = socket.getOutputStream(); out.write(Response.getBytes(StandardCharsets.utf_8)); }最後に{socket.close(); }}リクエストを処理するスレッドは1つしかないため、各リクエストは、応答する前に前のリクエストを処理するのを待つ必要があります。リクエスト応答時間が100ミリ秒であると仮定すると、このサーバーの1秒あたりの応答数(TPS)はわずか10です。
マルチスレッド
IOでHandlereQuestメソッドがブロックされる場合がありますが、CPUはさらに多くのリクエストを処理できます。しかし、単一のスレッドケースでは、これはできません。したがって、マルチスレッド方法を作成することにより、サーバーの並列処理機能を改善できます。
public static class handlerequestrunnable runnable {final socket socket; public handlerequestrunnable(socket socket){this.socket = socket; } public void run(){try {handlerequest(socket); } catch(ioexception e){e.printstacktrace(); }}} serversocketリスナー= new Serversocket(8080); try {while(true){socket socket = listener.accept();新しいスレッド(new HandLerequestrunnable(socket))。start(); }}最後に{ristener.close();}ここでは、メインスレッドではAccept()メソッドが引き続き呼び出されますが、TCP接続が確立されると、新しいスレッドを処理するために新しいスレッドが作成されます。これは、新しいスレッドの前のテキストのHandlereQuestメソッドを実行するためです。
新しいスレッドを作成することにより、メインスレッドは新しいTCP接続を引き続き受け入れることができ、これらのリクエストは並行して処理できます。この方法は、「要求ごとに1つのスレッド」と呼ばれます。もちろん、Nginxとnode.jsが使用する非同期イベント駆動型モデルなど、処理パフォーマンスを改善する他の方法がありますが、スレッドプールは使用せず、したがってこの記事ではカバーされていません。
各リクエストで1つのスレッドの実装で、JVMとオペレーティングシステムの両方がリソースを割り当てる必要があるため、スレッド(およびその後の破壊)オーバーヘッドの作成は非常に高価です。さらに、上記の実装にも問題があります。つまり、作成されたスレッドの数は制御できないため、システムリソースが迅速に使い果たされる可能性があります。
使い果たされたリソース
各スレッドには、一定量のスタックメモリスペースが必要です。最新の64ビットJVMでは、デフォルトのスタックサイズは1024kbです。サーバーが多数のリクエストを受信したり、HandlereQuestメソッドがゆっくりと実行されたりすると、多数のスレッドを作成するため、サーバーがクラッシュする場合があります。たとえば、1000個の並列リクエストがあり、作成された1000個のスレッドでは、1GBのJVMメモリをスレッドスタックスペースとして使用する必要があります。さらに、各スレッドのコードの実行中に作成されたオブジェクトも、ヒープに作成される場合があります。この状況が悪化すると、JVMヒープメモリを超えて大量のゴミ収集操作が生成され、最終的にメモリオーバーフロー(outofmemoryerrors)が生成されます。
これらのスレッドはメモリを消費するだけでなく、ファイルハンドル、データベース接続など、他の限られたリソースも使用します。したがって、リソースの疲労を回避する重要な方法は、制御できないデータ構造を避けることです。
ちなみに、スレッドスタックサイズによって引き起こされるメモリの問題により、スタックサイズは-XSSスイッチを介して調整できます。スレッドのスタックサイズを縮小した後、スレッドあたりのオーバーヘッドを減らすことができますが、スタックオーバーフロー(StackOverFlowerRors)を上げることができます。一般的なアプリケーションの場合、デフォルトの1024kbは豊かすぎて、256kbまたは512kbに減らす方が適切かもしれません。 Javaの最小許容値は160kbです。
スレッドプール
新しいスレッドの継続的な作成を避けるために、単純なスレッドプールを使用して、スレッドプールの上限を制限できます。スレッドプールはすべてのスレッドを管理します。スレッドの数が上限に達していない場合、スレッドプールは上限までスレッドを作成し、可能な限りフリースレッドを再利用します。
Serversocketリスナー= new Serversocket(8080); executorservice executor = executors.newfixedthreadpool(4); try {while(true){socket socket = ristener.accept(); executor.submit(new Handlerequestrunnable(socket)); }}最後に{ristener.close();}この例では、スレッドを直接作成する代わりに、ExecutorServiceが使用されます。実行する必要があるタスク(Runnablesインターフェイスを実装する必要がある)をスレッドプールに送信し、スレッドプールのスレッドを使用してコードを実行します。この例では、4つのスレッドを備えた固定サイズのスレッドプールを使用して、すべてのリクエストを処理します。これにより、リクエストを処理するスレッドの数が制限され、リソースの使用も制限されます。
NewFixedThreadPoolメソッドを介して固定サイズのスレッドプールを作成することに加えて、ExecutorsクラスはNewCachedThreadPoolメソッドも提供します。スレッドプールを再利用すると、制御できない数のスレッドが依然としてつながる可能性がありますが、可能な限り以前に作成されたアイドルスレッドを使用します。通常、このタイプのスレッドプールは、外部リソースによってブロックされていない短いタスクに適しています。
作業キュー
固定サイズのスレッドプールを使用した後、すべてのスレッドがビジーである場合、別のリクエストが来るとどうなりますか? ThreadPoolexecutorはキューを使用して保留中のリクエストを保持し、固定サイズのスレッドプールはデフォルトで無制限のリンクリストを使用します。これにより、リソースの消耗の問題が発生する可能性がありますが、スレッドの処理速度がキューの成長率よりも大きい限り、それは起こりません。次に、前の例では、各キュー済みの要求がソケットを保持し、一部のオペレーティングシステムではファイルハンドルを消費します。オペレーティングシステムはプロセスによって開かれたファイルハンドルの数を制限するため、作業キューのサイズを制限することが最善です。
public static executorservice newboundedfixedthreadpool(int nthreads、int capitious){return new swerchpoolexecutor(nthreads、nthreads、0l、timeunit.milliseconds、new linkedblockingqueue <runnable>(容量)、new threadpoolexecutor.discutor.discuter ioException {Serversocketリスナー= new Serversocket(8080); executorservice executor = newboundedfixedthreadpool(4、16); try {while(true){socket socket = listener.accept(); executor.submit(new Handlerequestrunnable(socket)); }}最後に{ristener.close(); }}ここでは、executors.newfixedthreadpoolメソッドを使用してスレッドプールを作成する代わりに、スレッドプールエクセクターオブジェクトを自分で構築し、作業キューの長さを16の要素に制限しました。
すべてのスレッドがビジーである場合、新しいタスクがキューに入力されます。キューはサイズを16の要素に制限するため、この制限を超えた場合、threadpoolexecutorオブジェクトを構築するときに最後のパラメーターで処理する必要があります。この例では、廃棄物が使用されます。つまり、キューが上限に達すると、新しいタスクが破棄されます。初めてに加えて、中絶ポリシー(AbortPolicy)と発信者実行ポリシー(CallerrunSpolicy)もあります。前者は例外をスローし、後者は発信者スレッドでタスクを実行します。
Webアプリケーションの場合、最適なデフォルトのポリシーは、ポリシーを放棄または中止し、クライアントにエラーを返すことである必要があります(HTTP 503エラーなど)。もちろん、作業キューの長さを増やすことでクライアントのリクエストを放棄することも避けることもできますが、ユーザーの要求は一般的に長い間待つことを嫌がり、より多くのサーバーリソースを消費します。作業キューの目的は、クライアントリクエストに制限なしに応答することではなく、スムーズとバーストのリクエストに応答することです。通常、作業キューは空でなければなりません。
スレッドカウントチューニング
前の例は、スレッドプールの作成と使用方法を示していますが、スレッドプールを使用することの中心的な問題は、使用するスレッドの数です。まず、スレッドの制限に達したときにリソースが使い果たされないようにする必要があります。ここでのリソースには、メモリ(ヒープとスタック)、オープンファイルハンドルの数、TCP接続の数、リモートデータベース接続の数、その他の限られたリソースが含まれます。特に、スレッドタスクが計算集中的である場合、CPUコアの数もリソースの制限の1つです。一般的に言えば、スレッドの数はCPUコアの数を超えてはなりません。
スレッドカウントの選択はアプリケーションのタイプに依存するため、最適な結果を得るには多くのパフォーマンステストが必要になる場合があります。もちろん、リソースの数を増やすことで、アプリケーションのパフォーマンスを向上させることもできます。たとえば、JVMヒープメモリサイズを変更するか、オペレーティングシステムのファイルハンドルの上限を変更します。その場合、これらの調整は最終的に理論上の上限に達します。
リトルの法則
Littleの法則は、安定したシステムの3つの変数間の関係を説明しています。
Lはリクエストの平均数を表し、λはリクエストの頻度を表し、Wはリクエストに応答する平均時間を表します。たとえば、1秒あたりの要求数が10で、各要求処理時間が1秒の場合、いつでも10のリクエストが処理されます。トピックに戻ると、処理するには10個のスレッドが必要です。単一のリクエストの処理時間が2倍になると、処理されたスレッドの数も2倍になり、20になります。
リクエストの処理効率に合わせて処理時間の影響を理解した後、理論上の上限がスレッドプールサイズの最適な値ではないことがわかります。スレッドプールの上限には、参照タスク処理時間も必要です。
JVMが1000のタスクを並行して処理できると仮定すると、各要求処理時間が30秒を超えない場合、最悪の場合、最大33.3秒のリクエストを処理できます。ただし、各リクエストが500ミリ秒しかかからない場合、アプリケーションは2000秒のリクエストを処理できます。
スレッドスレッドプール
マイクロサービスまたはサービス指向アーキテクチャ(SOA)では、通常、複数のバックエンドサービスへのアクセスが必要です。サービスの1つが劣化した場合、スレッドプールがスレッドを使い果たしてしまい、他のサービスへのリクエストに影響を与える可能性があります。
バックエンドサービスの失敗に対処する効果的な方法は、各サービスで使用されるスレッドプールを分離することです。このモードでは、タスクを異なるバックエンドリクエストスレッドプールにディスパッチするディスパッチされたスレッドプールがまだあります。このスレッドプールには、バックエンドが遅いため負荷がない場合があり、負担を遅いバックエンドを要求するスレッドプールに転送します。
さらに、マルチスレッドプーリングモードは、デッドロックの問題を回避する必要があります。未処理のリクエストの結果を待っている間に各スレッドがブロックしている場合、デッドロックが発生します。したがって、マルチスレッドプールモードでは、可能な限りデッドロックの問題を避けるために、各スレッドプールによって実行されるタスクとそれらの間の依存関係を理解する必要があります。
要約します
スレッドプールがアプリケーションで直接使用されていなくても、アプリケーションのアプリケーションサーバーまたはフレームワークによって間接的に使用される可能性があります。 Tomcat、Jboss、Undertow、Dropwizardなどのフレームワークはすべて、スレッドプール(サーブレット実行で使用されるスレッドプール)をチューニングするオプションを提供します。
この記事がスレッドプールの理解を向上させ、学習するのに役立つことを願っています。