JUC 復習編(一)スレッドの基礎
Java の JUC の章は知識ポイントが非常に多いため、自分が身につけた知識をより良く整理する目的で、ブログを書くという形で低レイヤーの原理から順に整理し、並行プログラミングに対する自分なりの理解をまとめていきます。
並行プログラミングとは何か
その理由はコンピュータの発展の歴史から語る必要があります。ムーアの法則によれば、CPU の演算能力は時間とともに向上していきますが、ムーアの法則はすぐに物理的な限界に近づき、シングルコア CPU の演算能力はボトルネックにぶつかりました。人々はより高い演算能力を求めて、マルチコア CPU アーキテクチャの設計へと舵を切り、マルチコアとハイパースレッディング技術によって CPU の演算能力は大幅に向上しました。原理としては、複数の人が同時に異なる命令を実行するようなもので、全体の効率は当然ひとりで実行するよりも高くなります。(あくまで全体の効率が高くなるという意味であり、マルチスレッドを使えば必ずシングルスレッドのアプリケーションより命令実行効率が高くなるという意味ではありません)
マルチコア CPU の計算リソースをより有効に活用する(CPU を絞り尽くす)ために、マルチスレッド開発が生まれました。マルチスレッドは特定のシナリオでは確かにプログラム全体の効率を高めますが、銀の弾丸ではありません。スレッドセーフティ、ロック機構、デッドロック、スレッドコンテキストスイッチのオーバーヘッドといった一連の問題も同時に付いてきます。
さて、ここまでスレッドについて色々と述べてきましたが、そもそもスレッドとは何なのでしょうか?
スレッドとは、オペレーティングシステムが演算スケジューリングを行える最小単位です。プロセスの中に含まれ、プロセスにおける実際の実行単位です。1 本のスレッドとは、プロセス内の単一の順序を持つ制御フローのことで、1 つのプロセス内では複数のスレッドを並行して動かすことができ、各スレッドは並列に異なるタスクを実行します。そして 1 つのプロセスには少なくとも 1 本のスレッドが実行されています。
JVM におけるスレッドの位置

JVM メモリモデルの図から、スレッド Thread が JVM 全体のどこに位置するのかを知ることができます。VM Stack(仮想マシンスタック)空間の中で、各スレッドは自分専用のスレッドスタックを持ち、スレッドスタックには多数のスタックフレーム Stack Frame が格納され、スタックフレームの中にはさらに LVA、OS、FD などの情報が格納されています。
LVA:ローカル変数配列(Local Variable Array)
- スタックフレームのローカル変数部分は、インデックス 0 から始まるバイト配列で構成されます。
- すべてのメソッド引数とローカル変数が含まれます。
- 配列内の各スロット(エントリ)のサイズは 4 バイトです。
- int、float および参照型は、配列内でそれぞれ 1 つのエントリ(スロット)、すなわち 4 バイトを占有します。
- double と long 型のデータは、配列内で連続する 2 つのエントリ、合計 8 バイトを占有します。
- byte、short、char 型は格納前に int 型へ変換され、1 スロット、すなわち 4 バイトを占有します。
- ただし boolean 型の値の格納方法は JVM によって実装が異なります。ほとんどの JVM 実装では、boolean 型はローカル変数配列内で 1 スロットを占有します。
- 引数は、メソッドで宣言された順序どおりに、最初にローカル変数配列へ配置されます。
OS:オペランドスタック(Operand Stack)
- JVM はオペランドスタックを実行時のワークスペースとして、言い換えれば計算の中間結果を格納する場所として使用します。
- オペランドスタックもローカル変数配列と同様に配列として構成されます。ただしインデックスでアクセスするのではなく、いくつかの命令によってアクセスされます。これらの命令は値をスタックにプッシュしたり、スタックから値をポップして必要な操作を行ったりできます。
FD:フレームデータ領域(Frame Data)
- フレームデータ領域には、すべてのシンボル参照(定数プールの解決)と、通常メソッドの return 位置が含まれます。この return は特定のメソッドに関連付けられ、呼び出し元へ戻るために使われます。
- また、例外テーブルへの参照も含まれており、例外発生時に catch ブロックの情報を提供できます。
JDK1.8 では、デフォルトのスレッドスタック空間は 1MB で、-XX:ThreadStackSize=256k によってスレッドスタックのメモリ空間サイズを調整できます。スレッドスタック空間が不足すると、StackOverflowError 例外がスローされます。
スレッドスタック
**スレッドスタック:**スレッドが関数を実行するたびにスタックフレームが 1 つプッシュされます。スレッドスタックは先入れ後出し(FILO)に従い、先に入ったものがスタックの底に、後から入ったものがスタックの頂上に置かれ、スタックの頂上から順に実行されます。例えば、A 関数が B 関数を呼び出し、B 関数がさらに C 関数を呼び出す場合、スタック構造は次の図のようになります。
スタック構造はシンプルで、ガベージコレクタも必要ありません。スタックフレームは実行が完了すると即座にメモリが破棄されるためです。
スレッドモデル
1 対 1 スレッドモデル(1 つの LWP が 1 つの KLT に対応)
JDK1.8 で使用されているのはこのモデルです。
**KLT:カーネルレベルスレッド(Kernel Level Thread)**このスレッド実装は、オペレーティングシステムのカーネルが直接スレッドのサポート、スレッド切り替え、およびスケジューラの操作によるスケジューリングを行うもので、スレッドが各プロセッサにマッピングされます。
**LWP:軽量プロセス(Light Weight Process)**プログラムは通常、カーネルスレッドを直接使うのではなく、カーネルスレッドの高級インターフェースである LWP を介してカーネルスレッドをスケジューリング・操作します。LWP と通常のプロセスの違いは、最小限の実行コンテキストとスケジューラに必要な統計情報しか持たないことにあり、これが「軽量」と呼ばれる理由です。また、LWP と KLT の関係は 1:1 で存在します。

メリット:
1)実装がシンプルで、大部分のマルチスレッドシナリオに適用でき、現在主流の JVM はいずれもこの方式を採用しています。
デメリット:
1)ユーザースレッドのブロックとウェイクアップが直接カーネルスレッドにマッピングされるうえ、スレッド数の増加に伴い、スレッド間の頻繁な切り替えによって CPU のオーバーヘッドが大きくなります。JDK1.8 では CAS アルゴリズムが導入され、スレッド間の頻繁な切り替えとスレッドのロック取得を回避することで、JVM の並行性能は確かに大幅に向上しました。
2)オペレーティングシステムのカーネルが作成できるスレッド数には限りがあり、アプリケーションが過剰なスレッドを作成すると、システムの性能が大幅に低下します。CPU のタイムスライスの大部分が、過剰なスレッドの切り替えによって消費されてしまうのです。
多対多スレッドモデル(LWP の上に仮想的なユーザースレッド UT を構築)
現在、Go 言語と JDK19 バージョンで使用されているのはこのモデルです。
**UT:ユーザースレッド(User Thread)**ユーザー空間に構築され、システムカーネルはユーザースレッドの存在を感知できません。スレッドの作成、破棄、切り替えのオーバーヘッドが小さいのが特徴です。

多対多モデルは 2 レベルスレッドモデルとも呼ばれ、各スレッドモデルの長所を十分に取り入れつつ、それらの短所を可能な限り回避しています。このモデルでは、ユーザースレッドとカーネルスレッドは多対多(M : N、通常 M >= N)のマッピングモデルになります。
メリット:
1)アプリケーション自身が UT 間の切り替えを管理するため、下層の CPU は頻繁にスレッドを切り替える必要がなく、現在のスレッドをより連続的に実行でき、プログラムの効率が向上します。
2)UT のスケジューリング優先度をプログラム自身でコントロールできます。
3)並行数を大幅に高められます。このモデルなら UT を 1 万本以上起動しても十分にサービスを提供でき、高並行シナリオに生まれつき適しています。
デメリット:
1)実装が比較的複雑です。例えば Go 言語が採用している GMP スレッドモデルはこの多対多方式で実装されており、これが goroutine で高い並行性を実現できる理由のひとつでもあります。現在、Java の Loom プロジェクトもこの方面を探求しており、JDK19 ですでに実装されているので、ぜひ試してみてください。JEP 425:仮想スレッド(プレビュー版) (openjdk.org)
スレッドの 5 つの状態

1) 新規(New)
スレッドオブジェクトが作成されると、新規状態に入ります。
2) 実行可能(Runnable)
「実行可能状態」とも呼ばれます。スレッドオブジェクトが作成された後、他のスレッドがそのオブジェクトの start() メソッドを呼び出すことでスレッドが起動されます(例:thread.start())。実行可能状態にあるスレッドは、いつでも CPU にスケジューリングされて実行される可能性がありますが、具体的にいつ実行されるかは CPU 自身が決定します。
3) 実行中(Running)
スレッドが CPU の実行権を取得して実行します。注意すべき点は、スレッドは実行可能状態からのみ実行中状態に入れるということです。
4) ブロック(Blocked)
- (01) 待機ブロック -- スレッドの wait() メソッドを呼び出して、ある処理の完了をスレッドに待たせます。notify() メソッドでそのスレッドをウェイクアップでき、ウェイクアップ後にスレッドは実行可能状態に入ります。
- (02) 同期ブロック -- スレッドが synchronized 同期ロックの取得に失敗した場合(ロックが他のスレッドに占有されているため)、同期ブロック状態に入ります。
- (03) その他のブロック -- スレッドの sleep() や join() を呼び出したり、I/O リクエストを発行したりすると、スレッドはブロック状態に入ります。sleep() のタイムアウト、join() の対象スレッドの終了またはタイムアウト、あるいは I/O 処理の完了時に、スレッドは再び実行可能状態に戻ります。
5) 終了(Dead)
スレッドの実行が完了したか、例外によって run() メソッドを抜けた場合、そのスレッドはライフサイクルを終えます。
Java スレッドプールの活用
上記のスレッド状態からわかるように、スレッドの作成と破棄を絶えず繰り返すと、新規と終了の 2 つの状態でより多くの計算リソースが消費されます。スレッドをより有効に使うために、スレッドリソースを管理するスレッドプールが登場しました。スレッドプールでは、まず指定した数のスレッドを事前に作成しておき、使用が必要になったときにそこからスレッドを取り出して実行し、対応するタスクの実行が終わったらスレッドプールに戻します。こうすることで、作成と破棄の繰り返しをうまく回避し、プログラムの効率を高めることができます。

メリット:
1)アプリケーション内のスレッド数とタスク数をより良く管理できます。
2)タスクの実行速度が上がり、つまりリクエストの応答時間が短縮されます。(作成と破棄のステップがないため)
3)マルチスレッド並列計算による効率向上や、その他の拡張機能を利用できます。
4)スレッドを再利用することで、システムの計算リソースのオーバーヘッドを削減できます。
スレッドプールの作成方法
- ThreadPoolExecutor で手動でスレッドプールを作成する。
- Executors エグゼキュータで自動的にスレッドプールを作成する。
スレッドプールを作成する 7 つの方法
1)Executors.newFixedThreadPool:固定サイズのスレッドプールを作成します。並行するスレッド数を制御でき、超過したスレッドはキューで待機します。 2)Executors.newCachedThreadPool:キャッシュ可能なスレッドプールを作成します。スレッド数が処理に必要な数を超えた場合、一定期間キャッシュした後に回収され、スレッド数が足りない場合は新しいスレッドを作成します。 3)Executors.newSingleThreadExecutor:単一スレッドのスレッドプールを作成します。先入れ先出しの実行順序を保証できます。 4)Executors.newScheduledThreadPool:遅延タスクを実行できるスレッドプールを作成します。 5)Executors.newSingleThreadScheduledExecutor:遅延タスクを実行できる単一スレッドのスレッドプールを作成します。 6)Executors.newWorkStealingPool:プリエンプティブに実行するスレッドプールを作成します(タスクの実行順序は不定)。 7)ThreadPoolExecutor:手動でスレッドプールを作成する方式で、作成時に最大 7 つのパラメータを設定できます:コアスレッド数、最大スレッド数、ブロッキングキューの種類(有界、無界)、生存時間、時間の単位、スレッドファクトリ、拒否ポリシー。
int corePoolSize, // コアスレッド数
int maximumPoolSize, // 最大スレッド数
long keepAliveTime, // 非コアスレッドのアイドル生存時間
TimeUnit unit, // 生存時間の単位
BlockingQueue<Runnable> workQueue, // ブロッキングキュー(有界 / 無界)
ThreadFactory threadFactory, // スレッドファクトリ
RejectedExecutionHandler handler // 拒否ポリシー
スレッドプールの作成には、最後の ThreadPoolExecutor 方式の使用をおすすめします。これを使えばスレッドプールの動作ルールを明確にでき、リソース枯渇のリスクを回避できるからです。
拒否ポリシー
拒否ポリシーは全部で 4 種類あります:
1)ThreadPoolExecutor.AbortPolicy:デフォルトの拒否ポリシーで、タスクを拒否して例外をスローします。 2)ThreadPoolExecutor.CallerRunsPolicy:呼び出し元のスレッドで直接タスクを実行します。 3)ThreadPoolExecutor.DiscardPolicy:タスクをそのまま拒否し、エラーはスローしません。 4)ThreadPoolExecutor.DiscardOldestPolicy:拒否ポリシーが発動した後、新しいタスクが追加され続ける限り、ブロッキングキュー内の最も古いタスクを破棄し続け、新しいタスクを追加します。
スレッドの最大実行タスク数 = スレッド数 + キューの長さ。
スレッドプールの最大スレッド数が 10、キューの長さが 20 であれば、そのスレッドプールは最大で同時に 30 個のタスクを受け付けられることを意味し、超過したタスクには対応する拒否ポリシーが適用されます。もちろん、RejectedExecutionHandler インターフェースを自分で実装して拒否処理をハンドリングすることもできます。
まとめ
今回は JVM メモリモデルを入り口に、スレッドスタックの内部構造、1 対 1 と多対多という 2 つのスレッドモデルのトレードオフ、そしてスレッドの 5 つの状態を整理しました。スレッドの作成と破棄のコストを理解すれば、スレッドプールが存在する意味も理解できます。実際の開発では ThreadPoolExecutor で手動でスレッドプールを作成し、コアパラメータと拒否ポリシーを明確にして、Executors のデフォルト設定がもたらすリソースリスクを回避することをおすすめします。これらの基礎概念は、この後のロック機構や JUC の各種シンクロナイザを理解するための前提となります。
COMMENTS