はじめに
こんにちは、もんすんです。
前回のJava11→17編では switch 式・テキストブロック・record・sealed と、書き方を変える機能が一気に登場しました。
今回は Java17→21編です。
Java21も LTS で、その目玉は仮想スレッド(Virtual Threads)です。
JavaでのWeb開発の多くは Spring Boot を介しているため、Virtual Threads を直接書かなくても恩恵を受けられる場面が多いです。
でもその仕組みを知っておくことで、パフォーマンス問題に対する見方が変わります。
また、前回登場した sealed と今回のパターンマッチングが合わさると、switch の書き方がさらに進化します。
この接続も最後に見ていきましょう。
対象読者
- Java11→17編を読んだ方(または Java17 の主要機能を知っている方)
- Virtual Threads に興味があるが、何がどう変わるのか掴めていない方
Java17からJava21へのレベルアップ
1. Virtual Threads(仮想スレッド)(Java 21 で正式へ)
「スレッドが多いと重い」の常識を変える機能です。
まず前提を整理します。
従来のスレッド(プラットフォームスレッド)
Javaでスレッドを1本立てると、OSのスレッドが1本対応します。
OSスレッドはメモリ(スタックで数百KB〜数MB)を消費し、生成・切り替えにコストがかかります。
// 従来のスレッド
var thread = new Thread(() -> {
System.out.println("スレッドで実行");
});
thread.start();
Web サーバーでは「リクエスト1件 = スレッド1本」というモデルが一般的でした。
しかしスレッドは重いので、高負荷時にスレッドが枯渇する問題がありました。
これを回避するために非同期プログラミング(CompletableFuture や WebFlux / Reactor)が使われてきました。
しかし非同期コードは難しい。
コールバック地獄、エラーハンドリングの複雑さ、デバッグのしにくさ...など、慣れていないと一気に詰まります。
Virtual Threads が解決すること
// Virtual Thread の作成
var vThread = Thread.ofVirtual().start(() -> {
System.out.println("仮想スレッドで実行");
});
// Thread.startVirtualThread() でも同じ
Thread.startVirtualThread(() -> {
System.out.println("仮想スレッドで実行");
});
Virtual Threads は JVM が管理する軽量スレッドです。
| プラットフォームスレッド | Virtual Threads | |
|---|---|---|
| 対応するOSスレッド | 1:1 | 多数のVirtualThreadが少数のOSスレッドを共有 |
| メモリ | 数百KB〜数MB | 数KB(必要に応じて伸縮) |
| 生成コスト | 高い | 非常に低い |
| 数の上限 | 数千程度が現実的 | 数百万も可能 |
Virtual Threads が I/O 待ちになると、JVM が自動的に別の Virtual Thread に切り替えます。スレッドがブロックされたまま OSスレッドを占有し続けないので、少ない OSスレッドで大量の並行処理をさばくことができます!
書き方は同期コードのまま
// Virtual Threads を使っても、書き方は普通の同期コード
Thread.startVirtualThread(() -> {
var response = httpClient.send(request, bodyHandler); // ここでブロックされても OK
System.out.println(response.body());
});
非同期で書いたような結果を、同期コードの読みやすさで達成できます。
Spring Boot での活用(実務の本題)
Spring Boot 3.2 以降では、Virtual Threads を1行の設定で有効化できます。
# application.yml
spring:
threads:
virtual:
enabled: true
これだけで、Tomcat が Virtual Threads を使うようになります。
従来のスレッドプールサイズの調整が不要になり、高スループットを同期コードで実現できます。
注意点もある
-
スレッドローカル変数の使い方に注意
- Virtual Threads は大量に生成される
- そのため、
ThreadLocalに大きいオブジェクトを詰め込むとメモリ問題になりうる
-
synchronized ブロックのピン留め問題
-
synchronizedブロック内で Virtual Thread がブロックされると、対応する OSスレッドも固定されてしまい、Virtual Threads の恩恵が減る -
ReentrantLockを使うと回避できる
-
-
CPU バウンドな処理には効かない
- Virtual Threads が輝くのは I/O 待ち(データベースアクセス・HTTP 通信等)の多い処理
- 逆にCPU を使い続ける処理は従来のスレッドと変わらない
2. パターンマッチング for switch(Java 21 から正式へ)
前回の sealed クラスとここで合流します。instanceof のパターンマッチング(Java16)がそのまま switch 式で使えるようになりました。
Before(Java 17 以前)
static String format(Object obj) {
if (obj instanceof Integer i) {
return "整数: " + i;
} else if (obj instanceof String s) {
return "文字列: " + s;
} else if (obj instanceof Double d) {
return "小数: " + d;
} else {
return "その他: " + obj;
}
}
After(Java 21 以降)
static String format(Object obj) {
return switch (obj) {
case Integer i -> "整数: " + i;
case String s -> "文字列: " + s;
case Double d -> "小数: " + d;
default -> "その他: " + obj;
};
}
型で分岐するコードがスッキリします。
sealed クラスとの組み合わせ
// 前回の sealed クラス
sealed interface Shape permits Circle, Rectangle, Triangle {}
record Circle(double radius) implements Shape {}
record Rectangle(double w, double h) implements Shape {}
record Triangle(double base, double height) implements Shape {}
// Java 21 以降のパターンマッチング switch
static double area(Shape shape) {
return switch (shape) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Rectangle r -> r.w() * r.h();
case Triangle t -> t.base() * t.height() / 2;
// sealed で型が限定されているため default 不要 → コンパイラが網羅を確認
};
}
sealed で継承先が限定されているので、default なしで全ケースを網羅できます!
新しい型を追加したとき、switch のコンパイルエラーで追加漏れに気づけます。安全です。![]()
3. record パターン(Java 21 から正式へ)
record の中身をパターンマッチングで分解できる構文です。
record Point(int x, int y) {}
Object obj = new Point(3, 5);
// record の中身を switch 内で分解
if (obj instanceof Point(int x, int y)) {
System.out.println("x=" + x + ", y=" + y); // x=3, y=5
}
// switch 式でも同様
String result = switch (obj) {
case Point(int x, int y) -> "座標: (" + x + ", " + y + ")";
default -> "不明";
};
ネストした record でも使えます。
record Line(Point start, Point end) {}
if (obj instanceof Line(Point(int x1, int y1), Point(int x2, int y2))) {
// x1, y1, x2, y2 がそのまま使える
}
4. Sequenced Collections(Java 21)
「順序を持つコレクション」を統一的に扱うインターフェースが追加されました。
// 先頭・末尾の取得が統一的に
List<String> list = new ArrayList<>(List.of("A", "B", "C"));
list.getFirst(); // "A"
list.getLast(); // "C"
list.addFirst("Z"); // 先頭に追加
list.addLast("D"); // 末尾に追加
list.removeFirst(); // 先頭を削除
list.removeLast(); // 末尾を削除
// reversed() で逆順ビュー
for (var item : list.reversed()) {
System.out.println(item);
}
LinkedList の getFirst() / getLast() は以前からありましたが、ArrayList や Deque でも同じ API で扱えるようになりました。コレクションの種類を変えてもコードを変えなくてよくなります。
5. String Templates — 文字列テンプレート(Java 21 プレビュー)
※ Java 21 時点ではプレビュー機能です。
正式採用は見送られ、現在(Java25で)も検討中です。
本番コードには使わないことを推奨します。
参考として紹介だけします。
将来こういう書き方ができるかもしれない、という話です。
// 現在は文字列フォーマットに様々な方法がある
var name = "田中";
var age = 30;
// printf スタイル
String.format("名前: %s, 年齢: %d", name, age);
// String Templates(プレビュー・将来の可能性)
// STR."名前: \{name}, 年齢: \{age}" ← こう書けるかもしれない
実務でJava17→21に上げるときの注意
まず Spring Boot のバージョンを確認
Java 21 の Virtual Threads を Spring Boot で活用するなら、Spring Boot 3.2 以降が必要です。Spring Boot 3 は Jakarta EE 10(jakarta.* 名前空間)ベースです。Spring Boot 2.x のままで Java21 に上げることは不可能ではありませんが、推奨されません。
私の実務では Java8→21 と Spring Boot 2→3 を同時に行いました。
名前空間の変更(javax.* → jakarta.*)が一番ボリュームのある作業でしたが、IntelliJ の一括置換を使うとかなりの部分は機械的に処理できます。
(とはいえ、私の実務においては、実際は手動で変更する必要がありました)
synchronized + Virtual Threads のピン留め問題
前述のとおり、既存コードに synchronized が多い場合は注意が必要です。
-Djdk.tracePinnedThreads=full JVM オプションでピン留めが発生している箇所をログに出せるので、Virtual Threads を有効化する前に確認すると安心です。
おわりに
Java は古くてつまらない言語と思われがちです。
しかし振り返ってみると、record 1行のシンプルさ、Virtual Threads のエレガントな解決、パターンマッチングの表現力と、確実に良くなっています。
Java は止まっていません。
一緒にレベルアップを目指していきましょう!