Javaの並行処理は、長い間「スレッドは高価な資源」という前提で設計されてきました。
しかし、Java 21で正式導入された仮想スレッド(Virtual Threads)は、この前提を大きく変えます。
先日、Redditのr/javaで仮想スレッドに関する興味深い議論を見つけました。
投稿者の考察だけではありません。
実際にベンチマークを取った人の具体的な数値も共有されており、非常に参考になる内容でした。
本記事では、その議論の内容を整理して紹介します。
1. 仮想スレッドが変えるもの
議論の出発点はシンプルです。
仮想スレッドは、従来の「1リクエスト1スレッド」モデルを再び実用的な選択肢にします。
従来のプラットフォームスレッド(OSスレッド)は、メモリとスケジューリングのコストが高くつきます。
そのため、数千単位で作ることは現実的ではありませんでした。
だからこそ、スレッドプールでスレッド数を絞る設計が定番だったわけです。
あふれたリクエストは、キューで順番を待つことになります。
仮想スレッドは、このコストを劇的に下げます。
数千、場合によっては数百万のスレッドを作っても、同じような負担にはなりません。
この恩恵を最も受けるのが、I/Oバウンドな処理です。
HTTP呼び出し、データベースクエリ、メッセージキュー、外部サービスとの通信。
バックエンドサービスの処理時間の多くは、実は「待ち時間」です。
仮想スレッドを使えば、大量の「待っている処理」を同時に抱えられます。
しかも、そのコストは安上がりです。
さらに、コードはブロッキングスタイルのまま書けます。
2. リアクティブプログラミングとの比較
投稿者が強調していたのは、リアクティブプログラミングに対する位置づけです。
リアクティブフレームワークは強力ですが、代償も小さくありません。
スタックトレースが追いにくくなりますし、間接的なコードも増えます。
さらに、開発者は別のメンタルモデルを要求されます。
主なボトルネックが「外部システムの待ち時間」であるなら、どうでしょうか。
仮想スレッドは、ずっとシンプルな選択肢になり得ます。
この点に共感するコメントは多く見られました。
一方で、こんな指摘もありました。
JavaにはNIOやNettyがあり、ノンブロッキングなサーバーは以前から書けた。 だから、GoやNode.jsに本質的な優位性はなかったはずだ
技術的には可能でも、書きやすさが普及を左右するのかもしれません。
3. 実測ベンチマークが示した現実
議論の中で特に価値が高かったのは、Spring Bootアプリで実験した人のコメントです。
実験の内容は、次のようなものでした。
- 200msのブロッキングな下流呼び出しを注入する
- 同時実行数を50から2000まで変化させて計測する
結果は示唆に富んでいます。
まず、スレッドプールが飽和しない負荷では、仮想スレッドは何も変えませんでした。
スループットは完全に同一です。
多くのサービスは、普段この領域で動いているはずです。
だからこそ、この事実は口に出して言う価値があります。
効果が出たのは高負荷時です。
同時実行数1000あたりで、デフォルトの200スレッドのTomcatプールと比べて約4倍のスループットを記録しました。
レイテンシも良好でした。
キュー待ちで膨らむことなく、実際の下流処理時間に近い水準を維持したそうです。
ただし、意外な結果もありました。
プラットフォームスレッドのプールサイズを、負荷に合わせて増やしてみたそうです。
すると、仮想スレッドとほぼ同等(差は0.2%程度)の性能が出ました。
つまり、純粋なスループットだけなら「スレッドを増やす」だけでも戦えます。
では、仮想スレッドの本当の価値はどこにあるのでしょうか。
この方の結論は「運用面」でした。
まず、負荷レベルごとにプールサイズを手作業でチューニングする作業から解放されます。
そして、スレッドあたりのコストが低くなります。
実測では、コミット済みスタックは約135KBだったそうです。
「スレッド1本1MB」という俗説より、ずっと軽い結果でした。
4. 魔法ではない:制約と落とし穴
投稿者もコメント欄も、「仮想スレッドは魔法ではない」という点では一致していました。
CPUバウンドな処理には効果ゼロ
前述のベンチマークでも、CPUバウンドなワークロードでは性能がまったく向上しませんでした。
限界を決めるのはコア数です。
安いスレッドをいくら積んでも、計算能力は買えません。
ボトルネックは移動するだけ
スレッドが安くなると、ボトルネックは即座に次の資源へ移ります。
同じテストで、HikariCPの最大接続数を10に設定したそうです。
すると、どれだけ仮想スレッドを積んでも、スループットは「接続数÷保持時間」で頭打ちになりました。
天井を動かすことはできます。
しかし、消すことはできません。
メモリ消費は増える
ピーク時のメモリ使用量は、約2.2倍になったそうです。
理由は明快です。
全リクエストがアクセプタキューで待つのではなく、実際に「生きた」状態でメモリを占有するからです。
スパイクでOOMにならないよう、セマフォなどによる流量制限を推奨していました。
5. スレッドプールという「暗黙のリミッター」の喪失
コメント欄で最も議論を呼んだのが、この論点です。
従来のスレッドプールは、意図せずして下流システムを守るバッファとして機能していました。
例えば、プールサイズが200なら、下流への同時アクセスも自然と200に制限されます。
しかし、仮想スレッドに切り替えると、この暗黙の防波堤が消えます。
そのため、単純な1対1の置き換えとは考えないほうが安全です。
データベース接続、下流サービス、レートリミットといった共有資源には、明示的な制限を設計する必要があります。
もっとも、こんな反論もありました。
「プールサイズをセマフォ代わりに使うのは、そもそも行儀が悪かった」という意見です。
挙動は同じでも、表現している概念が違うという指摘ですね。
さらに、別の視点もあります。
どちらの仕組みも、下流の実際のキャパシティを反映していない点では同じです。
より洗練された手法として、サーキットブレーカーパターンを挙げる声もありました。
実践的な移行パターンも紹介されていました。
手順は次のとおりです。
- スレッドプール版のExecutorServiceを、仮想スレッド版に置き換える
- 元のプールサイズと同じ値のセマフォを用意する
- タスク投入前にacquireし、完了時にreleaseする
これで、並行数の挙動を保ったまま移行できます。
6. Java 24でピンニング問題は解消
Java 21時点の仮想スレッドには、有名な弱点がありました。
synchronizedブロックの中でブロッキング処理を行うと、仮想スレッドがキャリアスレッドに「ピン留め」されてしまいます。
その結果、スケールしなくなるという問題です。
このため、当初は「synchronizedをReentrantLockに書き換えよ」というアドバイスが広まりました。
この問題は、Java 24のJEP 491で解消されています。
synchronizedブロック内でも、仮想スレッドはピン留めされなくなりました。
したがって、書き換えアドバイスの大半はもう不要です。
「修正に時間がかかりすぎた」という不満の声もありました。
しかし、それに対しては「怠慢ではなく、タスクの複雑さゆえだ」という反論も出ていました。
なお、コンテナ環境特有の利点を挙げるコメントもありました。
仮想スレッドのスケジューラは、デフォルトでコンテナのCPU制限に合わせてサイズが決まります。
そのため、コンテナ側からCPUスロットリングを受けにくくなるそうです。
ただし、注意点もあります。
その代わりに、Java側のスレッドスケジューリングで待たされることになります。
自前の性能計測がないと、「なぜ遅いのか」が見えにくくなるという指摘も添えられていました。
まとめ
Redditの議論から見えてきた、仮想スレッドの実像を整理します。
仮想スレッドは、I/O待ちが支配的なサービスで力を発揮します。
シンプルなブロッキングコードのまま、スケールを実現できる現実的な手段です。
リアクティブプログラミングの複雑さを回避できる点は、多くの開発者にとって魅力でしょう。
ただし、効果が出るのはスレッドプールが飽和する高負荷域に限られます。
CPUバウンドな処理は速くなりません。
また、ボトルネックはDB接続プールなど、次の資源に移動するだけです。
メモリ消費の増加にも備えてください。
そして、最大の設計上の注意点があります。
スレッドプールが果たしていた、暗黙の流量制限が消えることです。
セマフォなどで、明示的な制限を設けましょう。
Java 24でピンニング問題が解消された今、採用のハードルは大きく下がりました。
移行を検討する価値は十分にあると思います。
ただし、「置き換えれば速くなる」という理解は誤りです。
「運用が楽になり、高負荷に強くなる」という理解で臨むのが正解のようです。
