はじめに
Java 25 は 2025 年 9 月にリリースされた LTS(長期サポート)版である。Java 21 から 2 年分、JDK 22〜25 の成果がまとめて入っている。
Java 21 が仮想スレッドで「並行処理の書き方」を変えたとすれば、Java 25 のテーマは大きく 2 つある。1 つは 書き始めの簡単さ:class も public static void main(String[] args) も書かずにプログラムを始められるようになった。もう 1 つは ランタイムの効率:コードを 1 行も変えずに、メモリ使用量や起動時間を減らせるオプションが増えた。
本記事では、Java 25 の 18 個の JEP を 実際に動かしながら 見ていく。サンプルコードはすべて 1 ファイルで完結しており、外部ライブラリも不要である。性能に関わる機能は、実際に計測した数値で効果を確かめる。各機能の最後には 「その後」 として、JDK 27(2026 年 9 月 GA)までの動きを補足した。
検証環境とサンプルの実行方法
- JDK:OpenJDK 25(25.0.4)
- OS / CPU:macOS(Apple Silicon、8 コア)
- 実行はソースファイルを直接起動する方式(JEP 330)。コンパイルは不要である(AOT キャッシュのサンプルだけは JAR を作る)。
java CompactSourceDemo.java # 正式機能
java --enable-preview StableValueDemo.java # プレビュー機能- JDK 25 では、ソースファイルを直接起動するときのプレビュー機能は
--enable-previewだけで有効になる(Java 21 では--source 21も必要だった)。 - サンプルコードは GitHub の smile-lxy/java-new-features にもまとめてある。コメントは日本語・中国語・英語の 3 言語で書いてあり、本記事には日本語のコメントだけを載せた。
- 各サンプルの先頭コメント(
// Run:)に実行コマンドを書いてある。 - 出力中の時間やメモリ量、鍵の値は、実行環境や実行のたびに変わる。
全体像:Java 25 の 18 個の JEP
| JEP | 機能 | Java 25 での状態 | その後(〜JDK 27) |
|---|---|---|---|
| 470 | PEM Encodings of Cryptographic Objects | プレビュー | JDK 27 でも 3rd プレビュー |
| 502 | Stable Values | プレビュー | Lazy Constants に改名、JDK 27 で 3rd プレビュー |
| 503 | 32-bit x86 ポートの削除 | 削除 | ― |
| 505 | Structured Concurrency | 5th プレビュー | JDK 27 でも 7th プレビュー |
| 506 | Scoped Values | 正式 | ― |
| 507 | Primitive Types in Patterns | 3rd プレビュー | JDK 27 でも 5th プレビュー |
| 508 | Vector API | 10th インキュベーター | JDK 27 でも 12th インキュベーター |
| 509 | JFR CPU-Time Profiling | 実験的(Linux のみ) | ― |
| 510 | Key Derivation Function API | 正式 | ― |
| 511 | Module Import Declarations | 正式 | ― |
| 512 | Compact Source Files and Instance Main Methods | 正式 | ― |
| 513 | Flexible Constructor Bodies | 正式 | ― |
| 514 | AOT のコマンドライン簡略化 | 正式 | JDK 26 でどの GC でもオブジェクトをキャッシュ可能に |
| 515 | AOT メソッドプロファイリング | 正式 | 同上 |
| 518 | JFR Cooperative Sampling | 正式 | ― |
| 519 | Compact Object Headers | 正式(オプション) | JDK 27 でデフォルトに |
| 520 | JFR Method Timing & Tracing | 正式 | ― |
| 521 | Generational Shenandoah | 正式(オプション) | ― |
これらは大きく 4 つに分けられる。
flowchart LR
J25["Java 25"] --> L["言語機能"]
J25 --> C["並行処理"]
J25 --> A["ライブラリ・<br/>セキュリティ"]
J25 --> R["ランタイム・<br/>監視"]
L --> L1["Compact Source Files(512)"]
L --> L2["Module Import(511)"]
L --> L3["Flexible Constructor(513)"]
L --> L4["Primitive Patterns(507)"]
C --> C1["Scoped Values(506)"]
C --> C2["Structured Concurrency(505)"]
A --> A1["Stable Values(502)"]
A --> A2["KDF API(510)"]
A --> A3["PEM(470)"]
A --> A4["Vector API(508)"]
R --> R1["Compact Object Headers(519)"]
R --> R2["AOT キャッシュ(514・515)"]
R --> R3["世代別 Shenandoah(521)"]
R --> R4["JFR(509・518・520)"]
classDef star fill:#fde68a,stroke:#b45309,color:#78350f
class L1,C1,R1,R2,R3 star
本記事では 言語機能 → 並行処理 → ライブラリ・セキュリティ → ランタイム・監視 の順に見ていく。正式化された機能のうち影響の大きいもの(黄色)は詳しく扱い、ランタイムの 3 つは実測で効果を確かめる。
言語機能
Compact Source Files and Instance Main Methods(JEP 512)
クラス宣言なしで、メソッドを書くだけでプログラムを始められる ようになった。Java 21 の JEP 445(Unnamed Classes and Instance Main Methods)が、名前と仕様を変えながら 4 回のプレビューを経て正式化されたものである。
public class Hello {
public static void main(String[] args) {
System.out.println("Hello");
}
}void main() {
IO.println("Hello");
}// JEP 512: Compact Source Files and Instance Main Methods
// Run: java CompactSourceDemo.java
// class 宣言も public static も String[] args も要らない。java.base のクラスは import なしで使える
final List<String> fruits = List.of("apple", "banana", "cherry");
String shout(String s) {
return s.toUpperCase() + "!";
}
void main() {
// IO.println は System.out.println の簡易版(java.lang.IO、JDK 25 で追加)
IO.println("Hello, Java 25!");
for (String f : fruits) {
IO.println(shout(f));
}
// List、Map、Collectors などは java.base モジュールの自動 import で使える
Map<Integer, List<String>> byLength = fruits.stream().collect(Collectors.groupingBy(String::length));
IO.println("by length: " + byLength);
// 実はコンパイラが暗黙のクラスを作っている
IO.println("class name: " + getClass().getName());
}Hello, Java 25!
APPLE!
BANANA!
CHERRY!
by length: {5=[apple], 6=[banana, cherry]}
class name: CompactSourceDemoポイントは 3 つある。
- 暗黙のクラス:トップレベルに書いたフィールドとメソッドは、コンパイラが自動で作るクラス(クラス名はファイル名と同じになる)のメンバーになる。
- インスタンスの
main:staticもpublicも引数も不要になった。mainがインスタンスメソッドなら、JVM がインスタンスを作ってから呼び出す。 java.baseの自動 import とIOクラス:コンパクトなソースファイルでは、次節のimport module java.base;が暗黙に行われる。ListもMapもCollectorsも import なしで使える。新しいjava.lang.IOクラスで、System.out.printlnをIO.printlnと短く書ける。
入門者向けの機能に見えるが、ちょっとしたスクリプトや検証コードを書くときにも便利である。プログラムが大きくなったら、class 宣言を足せば普通のクラスに移行できる。
JDK 23・24 のプレビューでは println などが自動で static import されていたが、正式版では IO.println と書く形に落ち着いた。また、Java 21 では「名前のないクラス(unnamed class)」と呼ばれていたが、JDK 22 で「暗黙のクラス(implicit class)」に改められた。
Module Import Declarations(JEP 511)
import module で、モジュールが公開しているすべてのパッケージをまとめて import できるようになった。
// JEP 511: Module Import Declarations
// Run: java ModuleImportDemo.java
// モジュールが公開しているパッケージを、まとめて import する
import module java.base; // java.util, java.util.stream, java.time, java.nio.file ...
import module java.net.http; // java.net.http
// java.desktop にも java.awt.List があるので、List が曖昧になる。単一型 import が優先されるので、これで解決する
import module java.desktop;
import java.util.List;
public class ModuleImportDemo {
public static void main(String[] args) {
// java.util・java.time・java.net.http のクラスを、個別の import なしで使っている
List<String> names = Stream.of("b", "a", "c").sorted().toList();
LocalDate date = LocalDate.of(2025, 9, 16);
HttpClient.Version version = HttpClient.Version.HTTP_2;
System.out.println("names = " + names);
System.out.println("JDK 25 = " + date + " (" + date.getDayOfWeek() + ")");
System.out.println("version = " + version);
// java.desktop のクラスも使える(List 以外)
Point p = new Point(3, 4);
System.out.println("point = " + p.x + "," + p.y + " (distance " + p.distance(0, 0) + ")");
System.out.println("List is " + List.class.getName());
}
}names = [a, b, c]
JDK 25 = 2025-09-16 (TUESDAY)
version = HTTP_2
point = 3,4 (distance 5.0)
List is java.util.Listimport module java.base; の 1 行で、java.util・java.util.stream・java.time・java.nio.file など 58 個のパッケージが使えるようになる。
注意点は 名前の衝突 である。java.desktop には java.awt.List があるので、java.base と一緒に import すると List が曖昧になる。サンプルの import java.util.List; を消すと、次のエラーになる。
ModuleImportDemo.java:15: error: reference to List is ambiguous
List<String> names = Stream.of("b", "a", "c").sorted().toList();
^
both class java.awt.List in java.awt and interface java.util.List in java.util match型を 1 つだけ指定する通常の import は import module より優先されるので、それで解決できる。
import module は、自分のコードがモジュール(module-info.java)でなくても使える。業務コードでは、どのクラスがどこから来たのかがわかりにくくなるので、ワイルドカード import と同じく使いどころを選ぶ。プロトタイプや、前節のコンパクトなソースファイルとの相性がよい。
Flexible Constructor Bodies(JEP 513)
コンストラクタで、super(...) や this(...) の前に処理を書ける ようになった。引数の検証や、親に渡す値の計算を先に行える。
// JEP 513: Flexible Constructor Bodies
// Run: java FlexibleConstructorDemo.java
public class FlexibleConstructorDemo {
static class Shape {
Shape() {
// 親のコンストラクタからオーバーライドされたメソッドを呼ぶ(よくある落とし穴)
System.out.println(" Shape(): " + describe());
}
String describe() { return "shape"; }
}
// 従来:super() は先頭に書く必要があり、フィールドは super() の後でしか代入できなかった
static class OldCircle extends Shape {
private final double radius;
OldCircle(double radius) {
super();
this.radius = radius;
}
@Override String describe() { return "circle r=" + radius; }
}
// JDK 25:super() の前に引数を検証し、フィールドを初期化できる
static class NewCircle extends Shape {
private final double radius;
NewCircle(double radius) {
if (radius <= 0) {
throw new IllegalArgumentException("radius must be positive: " + radius);
}
this.radius = radius;
super();
}
@Override String describe() { return "circle r=" + radius; }
}
// 親に渡す値を super() の前で計算する(従来は static メソッドに逃がす必要があった)
static class Base {
final String id;
Base(String id) { this.id = id; }
}
static class User extends Base {
User(String email) {
String normalized = email.trim().toLowerCase();
if (!normalized.contains("@")) {
throw new IllegalArgumentException("not an email: " + email);
}
super(normalized);
}
}
public static void main(String[] args) {
System.out.println("new OldCircle(2):");
new OldCircle(2);
System.out.println("new NewCircle(2):");
new NewCircle(2);
try {
new NewCircle(-1);
} catch (IllegalArgumentException e) {
System.out.println("new NewCircle(-1): " + e.getMessage());
}
System.out.println("new User(\" Alice@Example.COM \").id = " + new User(" Alice@Example.COM ").id);
}
}new OldCircle(2):
Shape(): circle r=0.0
new NewCircle(2):
Shape(): circle r=2.0
new NewCircle(-1): radius must be positive: -1.0
new User(" Alice@Example.COM ").id = alice@example.comOldCircle の出力に注目してほしい。親のコンストラクタ Shape() が、オーバーライドされた describe() を呼んでいる。このとき子のフィールド radius はまだ代入されていないので、0.0 が見えてしまう。これは Java の古典的な落とし穴だった。
NewCircle では super() の前に this.radius = radius; を書けるので、親のコンストラクタからも正しい値が見える。
flowchart TB
subgraph P["プロローグ(super の前)"]
P1["引数の検証"] --> P2["フィールドの初期化"]
P2 --> P3["親に渡す値の計算"]
end
P3 --> S["super(...)"]
S --> E["エピローグ(super の後)<br/>this を自由に使える"]
ただし、super() の前(プロローグ)では、まだ初期化されていない this を使う操作はできない。インスタンスメソッドの呼び出しや、初期化前のフィールドの読み取りはコンパイルエラーになる。できるのは、フィールドへの 代入 と、this を使わない計算である。
Primitive Types in Patterns, instanceof, and switch(JEP 507・3rd プレビュー)
パターンマッチングで プリミティブ型 を使えるようになる。Java 21 で正式化されたパターンマッチングは参照型だけが対象だった。
// JEP 507: Primitive Types in Patterns, instanceof, and switch (third preview in JDK 25)
// Run: java --enable-preview PrimitivePatternsDemo.java
public class PrimitivePatternsDemo {
// instanceof int:値を情報の欠落なしに int へ変換できるかを調べる
static String fitsInInt(long value) {
if (value instanceof int i) {
return value + " fits in int: " + i;
}
return value + " does not fit in int";
}
// double も同じ。小数部がなく範囲内なら int に一致する
static String classify(double d) {
return switch (d) {
case int i -> "integral " + i;
case float f -> "fits in float " + f;
case double x -> "needs double " + x;
};
}
// switch にプリミティブ型パターンと when ガードを組み合わせる
static String httpStatus(int code) {
return switch (code) {
case 200 -> "OK";
case 404 -> "Not Found";
case int c when c >= 500 -> "server error " + c;
case int c -> "other " + c;
};
}
// boolean・long なども switch のセレクターにできるようになった
static String onOff(boolean b) {
return switch (b) {
case true -> "ON";
case false -> "OFF";
};
}
public static void main(String[] args) {
System.out.println(fitsInInt(42L));
System.out.println(fitsInInt(10_000_000_000L));
System.out.println(classify(3.0));
System.out.println(classify(0.5));
System.out.println(classify(0.1));
System.out.println(httpStatus(200) + " / " + httpStatus(503) + " / " + httpStatus(302));
System.out.println(onOff(true) + " / " + onOff(false));
// 従来のキャストは黙って値を壊す
System.out.println("(int) 10_000_000_000L = " + (int) 10_000_000_000L);
}
}42 fits in int: 42
10000000000 does not fit in int
integral 3
fits in float 0.5
needs double 0.1
OK / server error 503 / other 302
ON / OFF
(int) 10_000_000_000L = 1410065408value instanceof int i は、「value を 情報を失わずに int に変換できるか」を調べる。10_000_000_000L は int に収まらないので一致しない。従来のキャスト (int) 10_000_000_000L は、エラーにならずに 1410065408 という壊れた値を返す。これを安全に書けるのが、この機能の価値である。
switch のセレクターにも boolean・long・float・double を使えるようになり、case int c when c >= 500 のようにガードとも組み合わせられる。
その後:JDK 26 で 4th プレビュー(JEP 530)、JDK 27 で 5th プレビュー(JEP 532)となり、まだ正式化されていない。
並行処理
Scoped Values(JEP 506)
Java 21 でプレビューとして登場した Scoped Values が、5 回目で 正式化 された。ThreadLocal に代わって、呼び出し階層の奥まで値を受け渡す仕組みである。
// JEP 506: Scoped Values
// Run: java ScopedValueDemo.java
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ScopedValueDemo {
record User(String name, String role) {}
// 「リクエスト中のユーザー」を、引数で受け渡さずに共有する
static final ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();
static final ThreadLocal<User> TL_USER = new ThreadLocal<>();
static void controller() {
service();
}
static void service() {
// 呼び出し階層の深いところで値を読む
User u = CURRENT_USER.get();
System.out.println(" service: user=" + u.name() + ", role=" + u.role());
}
public static void main(String[] args) throws Exception {
// ① where(...).run(...) の中だけで値が見える
System.out.println("(1) bind and read");
ScopedValue.where(CURRENT_USER, new User("alice", "admin")).run(ScopedValueDemo::controller);
System.out.println(" after run: isBound=" + CURRENT_USER.isBound());
// ② 入れ子で別の値を一時的に束縛できる。抜けると元に戻る
System.out.println("(2) nested rebinding");
ScopedValue.where(CURRENT_USER, new User("alice", "admin")).run(() -> {
ScopedValue.where(CURRENT_USER, new User("guest", "viewer")).run(ScopedValueDemo::service);
service();
});
// ③ call() で値を返せる
String greeting = ScopedValue.where(CURRENT_USER, new User("bob", "user"))
.call(() -> "hello, " + CURRENT_USER.get().name());
System.out.println("(3) call() -> " + greeting);
// ④ ThreadLocal は remove() を忘れると、スレッドプールで次のタスクに値が漏れる
System.out.println("(4) ThreadLocal vs ScopedValue on a pooled thread");
try (ExecutorService pool = Executors.newSingleThreadExecutor()) {
pool.submit(() -> TL_USER.set(new User("carol", "admin"))).get();
pool.submit(() -> System.out.println(" next task sees ThreadLocal: " + TL_USER.get())).get();
pool.submit(() -> ScopedValue.where(CURRENT_USER, new User("carol", "admin")).run(() -> {})).get();
pool.submit(() -> System.out.println(" next task sees ScopedValue: "
+ (CURRENT_USER.isBound() ? CURRENT_USER.get() : "(unbound)"))).get();
}
// ⑤ 束縛の外で get() すると例外
try {
CURRENT_USER.get();
} catch (java.util.NoSuchElementException e) {
System.out.println("(5) get() outside: " + e.getClass().getSimpleName());
}
}
}(1) bind and read
service: user=alice, role=admin
after run: isBound=false
(2) nested rebinding
service: user=guest, role=viewer
service: user=alice, role=admin
(3) call() -> hello, bob
(4) ThreadLocal vs ScopedValue on a pooled thread
next task sees ThreadLocal: User[name=carol, role=admin]
next task sees ScopedValue: (unbound)
(5) get() outside: NoSuchElementExceptionThreadLocal との違いは次のとおり。
| ThreadLocal | ScopedValue | |
|---|---|---|
| 値の変更 | set() でいつでも変えられる | 変えられない(入れ子で一時的に別の値を束縛するだけ) |
| 有効期間 | remove() するまで残る | run() / call() を抜けると自動で消える |
| 子スレッドへの引き継ぎ | InheritableThreadLocal はコピーが必要 | StructuredTaskScope の子にはコピーなしで引き継ぐ |
| 主な用途 | スレッドごとのキャッシュなど | リクエスト単位のコンテキスト(ユーザー、トランザクションなど) |
④ の出力が示すように、ThreadLocal は remove() を忘れると、スレッドプールで 次のタスクに値が漏れる。認証情報なら重大な事故になりうる。ScopedValue は束縛の範囲が構文で決まるので、この種のバグが起こらない。
Java 21 のプレビュー版から、API が少し変わっている。静的メソッドの runWhere()・callWhere() や、where(...).get(...) は削除され、where(...) に続けて run() か call() を呼ぶ形に統一された。call() は、チェック例外を投げられる ScopedValue.CallableOp を受け取る。
Structured Concurrency(JEP 505・5th プレビュー)
複数のサブタスクを 1 つの作業単位 として扱う API である。基本的な考え方は Java 21 から変わっていないが、JDK 25 の 5th プレビューで API が大きく作り直された。
| Java 21(JEP 453) | Java 25(JEP 505) | |
|---|---|---|
| スコープの作り方 | new StructuredTaskScope.ShutdownOnFailure() などのサブクラス | StructuredTaskScope.open(joiner) |
| 成功・失敗の方針 | サブクラスで選ぶ | Joiner で選ぶ |
| 結果の受け取り | join() の後に throwIfFailed() / result() | join() が結果を返す、または例外を投げる |
| タイムアウト | joinUntil(Instant) | open() の設定で withTimeout(Duration) |
// JEP 505: Structured Concurrency (fifth preview in JDK 25)
// Run: java --enable-preview StructuredConcurrencyDemo.java
import java.time.Duration;
import java.util.List;
import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.StructuredTaskScope.Joiner;
import java.util.concurrent.StructuredTaskScope.Subtask;
public class StructuredConcurrencyDemo {
static long t0;
static void log(String message) {
System.out.printf("%5d ms %s%n", (System.nanoTime() - t0) / 1_000_000, message);
}
static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
// ms ミリ秒かかる処理。中断されたらログを出す
static String work(String name, long ms, boolean fail) throws InterruptedException {
try {
Thread.sleep(ms);
} catch (InterruptedException e) {
log(name + ": interrupted");
throw e;
}
if (fail) {
log(name + ": failed");
throw new IllegalStateException(name + " failed");
}
log(name + ": done");
return name;
}
public static void main(String[] args) throws Exception {
// ① 既定の Joiner:全部成功するまで待つ。1 つでも失敗したら残りを中断する
System.out.println("=== 1. open(): one subtask fails ===");
t0 = System.nanoTime();
try (var scope = StructuredTaskScope.open()) {
Subtask<String> user = scope.fork(() -> work("findUser", 100, true));
Subtask<String> order = scope.fork(() -> work("fetchOrder", 1000, false));
scope.join();
log("never reached: " + user.get() + order.get());
} catch (StructuredTaskScope.FailedException e) {
log("caller: " + e.getCause().getMessage());
}
// ② 最初に成功した結果だけを使う(ミラーサーバーの競争など)
System.out.println("=== 2. anySuccessfulResultOrThrow() ===");
t0 = System.nanoTime();
try (var scope = StructuredTaskScope.open(Joiner.<String>anySuccessfulResultOrThrow())) {
scope.fork(() -> work("mirror A", 300, false));
scope.fork(() -> work("mirror B", 100, false));
scope.fork(() -> work("mirror C", 50, true));
log("result: " + scope.join());
}
// ③ 全部の結果をまとめて受け取る
System.out.println("=== 3. allSuccessfulOrThrow() ===");
t0 = System.nanoTime();
try (var scope = StructuredTaskScope.open(Joiner.<String>allSuccessfulOrThrow())) {
for (String s : List.of("price", "stock", "review")) {
scope.fork(() -> work(s, 100, false));
}
List<String> results = scope.join().map(Subtask::get).toList();
log("results: " + results);
}
// ④ スコープ全体にタイムアウトを設定する
System.out.println("=== 4. withTimeout(250 ms) ===");
t0 = System.nanoTime();
try (var scope = StructuredTaskScope.open(Joiner.<String>allSuccessfulOrThrow(),
cf -> cf.withTimeout(Duration.ofMillis(250)))) {
scope.fork(() -> work("fast", 100, false));
scope.fork(() -> work("slow", 1000, false));
scope.join();
} catch (StructuredTaskScope.TimeoutException e) {
log("caller: TimeoutException");
}
// ⑤ fork したサブタスクにも ScopedValue が引き継がれる
System.out.println("=== 5. ScopedValue inheritance ===");
t0 = System.nanoTime();
ScopedValue.where(REQUEST_ID, "req-42").call(() -> {
try (var scope = StructuredTaskScope.open()) {
Subtask<String> a = scope.fork(() -> "subtask A sees " + REQUEST_ID.get());
Subtask<String> b = scope.fork(() -> "subtask B sees " + REQUEST_ID.get());
scope.join();
log(a.get());
log(b.get());
}
return null;
});
}
}=== 1. open(): one subtask fails ===
110 ms findUser: failed
120 ms fetchOrder: interrupted
120 ms caller: findUser failed
=== 2. anySuccessfulResultOrThrow() ===
58 ms mirror C: failed
108 ms mirror B: done
108 ms mirror A: interrupted
108 ms result: mirror B
=== 3. allSuccessfulOrThrow() ===
104 ms stock: done
104 ms price: done
105 ms review: done
107 ms results: [price, stock, review]
=== 4. withTimeout(250 ms) ===
107 ms fast: done
255 ms slow: interrupted
255 ms caller: TimeoutException
=== 5. ScopedValue inheritance ===
1 ms subtask A sees req-42
2 ms subtask B sees req-42②(最初の成功を使う)と ④(タイムアウト)を、タイムラインにすると次のようになる。
② 最初の成功を使う / ④ 250 ms でタイムアウト(実測、目盛りの単位:s)
%%{init: {"gantt": {"leftPadding": 140}}}%%
gantt
dateFormat x
axisFormat %S.%L
tickInterval 100millisecond
section (2) any
mirror A 中断 :0, 108
mirror B 成功 :active, 0, 108
mirror C 失敗 :crit, 0, 58
section (4) timeout
fast 成功 :active, 0, 107
slow 中断 :0, 255
どちらも、不要になったサブタスクはスコープが自動で中断する。② では mirror B が成功した時点で mirror A が中断され、④ では 250 ms のタイムアウトで slow が中断されている。呼び出し側がキャンセル処理を書く必要はない。
用意されている主な Joiner は次のとおり。
| Joiner | 動き |
|---|---|
| (指定なし) | 全部の成功を待つ。1 つでも失敗したら残りを中断し、FailedException を投げる |
allSuccessfulOrThrow() | 同上で、全部の結果を返す |
anySuccessfulResultOrThrow() | 最初に成功した結果を返し、残りを中断する |
awaitAll() | 成功・失敗にかかわらず全部の終了を待つ |
その後:JDK 26 の 6th プレビュー(JEP 525)で、細かい API の調整が入った。anySuccessfulResultOrThrow() が anySuccessfulOrThrow() に改名され、allSuccessfulOrThrow() は結果のリストを返すようになり、タイムアウト時に結果を返せる Joiner.onTimeout() が追加された。JDK 27 でも 7th プレビュー(JEP 533)で、まだ正式化されていない。
ライブラリ・セキュリティ
Stable Values(JEP 502・プレビュー)
一度だけ設定できる値 を表す API である。final フィールドのように JIT コンパイラが定数として最適化できる一方で、初期化のタイミングを自由に遅らせられる。
これまで「重い初期化を、最初に使うときまで遅らせたい」場合は、ダブルチェックロッキングや、ホルダークラスのイディオムを使う必要があった。
// JEP 502: Stable Values (preview in JDK 25)
// Run: java --enable-preview StableValueDemo.java
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Supplier;
public class StableValueDemo {
static final AtomicInteger CREATED = new AtomicInteger();
static class Settings {
Settings() {
// 重い初期化のつもり(100 ms)
CREATED.incrementAndGet();
sleep(100);
}
}
// 「1 回だけ設定できる値」。final フィールドのように JIT が定数として扱える
static final StableValue<Settings> SETTINGS = StableValue.of();
static Settings settings() {
// 未設定なら初期化する。複数スレッドが同時に呼んでも、初期化は 1 回だけ
return SETTINGS.orElseSet(Settings::new);
}
// Supplier として使う書き方
static final Supplier<String> GREETING = StableValue.supplier(() -> {
System.out.println(" computing greeting...");
return "hello";
});
// 要素ごとに遅延初期化されるリスト
static final List<Integer> SQUARES = StableValue.list(10, i -> {
System.out.println(" computing square of " + i);
return i * i;
});
static void sleep(long ms) {
try {
Thread.sleep(ms);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
public static void main(String[] args) throws Exception {
System.out.println("(1) 8 threads call settings() at the same time");
Thread[] threads = new Thread[8];
for (int i = 0; i < threads.length; i++) {
threads[i] = Thread.ofVirtual().start(StableValueDemo::settings);
}
for (Thread t : threads) t.join();
System.out.println(" Settings created " + CREATED.get() + " time(s), isSet=" + SETTINGS.isSet());
System.out.println("(2) supplier");
System.out.println(" " + GREETING.get() + " / " + GREETING.get());
System.out.println("(3) list");
System.out.println(" SQUARES.get(3) = " + SQUARES.get(3));
System.out.println(" SQUARES.get(3) = " + SQUARES.get(3));
System.out.println(" SQUARES.get(7) = " + SQUARES.get(7));
// 2 回目の設定は失敗する
System.out.println("(4) trySet on a set value -> " + SETTINGS.trySet(new Settings()));
}
}(1) 8 threads call settings() at the same time
Settings created 1 time(s), isSet=true
(2) supplier
computing greeting...
hello / hello
(3) list
computing square of 3
SQUARES.get(3) = 9
SQUARES.get(3) = 9
computing square of 7
SQUARES.get(7) = 49
(4) trySet on a set value -> false8 本のスレッドが同時に settings() を呼んでも、Settings が作られるのは 1 回だけである。StableValue.list() では、要素ごとに必要になった時点で 1 回だけ計算される。
その後:JDK 26 の 2nd プレビュー(JEP 526)で Lazy Constants に改名され、API も大きく整理された。StableValue は LazyConstant になり、orElseSet()・trySet() などの低レベルなメソッドは削除された。遅延リストは List.ofLazy()、遅延マップは Map.ofLazy() で作る。JDK 27 でも 3rd プレビュー(JEP 531)である。上のコードは JDK 25 専用で、JDK 26 以降ではコンパイルできない。
// JDK 26 以降(プレビュー)
static final LazyConstant<Settings> SETTINGS = LazyConstant.of(Settings::new);
static final List<Integer> SQUARES = List.ofLazy(10, i -> i * i);Key Derivation Function API(JEP 510)
鍵導出関数(KDF)の標準 API である。鍵交換などで得た秘密から、用途ごとに別々の鍵を安全に作り出す。Java 21 の KEM API(JEP 452)や、耐量子暗号の HPKE などで必要になる部品として追加された。
// JEP 510: Key Derivation Function API
// Run: java KdfDemo.java
import java.nio.charset.StandardCharsets;
import java.util.HexFormat;
import javax.crypto.KDF;
import javax.crypto.SecretKey;
import javax.crypto.spec.HKDFParameterSpec;
import javax.crypto.spec.SecretKeySpec;
public class KdfDemo {
public static void main(String[] args) throws Exception {
// 鍵交換などで得た「元になる秘密」(本来はランダムな値)
byte[] ikm = HexFormat.of().parseHex("0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b");
byte[] salt = HexFormat.of().parseHex("000102030405060708090a0b0c");
KDF hkdf = KDF.getInstance("HKDF-SHA256");
// 同じ秘密から、用途(info)ごとに別々の鍵を導出する
for (String purpose : new String[] {"encryption", "mac"}) {
var params = HKDFParameterSpec.ofExtract()
.addIKM(new SecretKeySpec(ikm, "Generic"))
.addSalt(salt)
.thenExpand(purpose.getBytes(StandardCharsets.UTF_8), 32);
SecretKey key = hkdf.deriveKey(purpose.equals("mac") ? "HmacSHA256" : "AES", params);
System.out.printf("%-10s -> %s (%s, %d bytes)%n", purpose,
HexFormat.of().formatHex(key.getEncoded()), key.getAlgorithm(), key.getEncoded().length);
}
// RFC 5869 のテストケース 1 と同じ入力:OKM が仕様の値と一致するか確かめる
byte[] info = HexFormat.of().parseHex("f0f1f2f3f4f5f6f7f8f9");
byte[] okm = hkdf.deriveData(HKDFParameterSpec.ofExtract()
.addIKM(new SecretKeySpec(ikm, "Generic")).addSalt(salt).thenExpand(info, 42));
String expected = "3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865";
System.out.println("RFC 5869 test case 1 matches: " + HexFormat.of().formatHex(okm).equals(expected));
}
}encryption -> f6f274c198f909c7d84cd755655012f676a5ec8db9eefb176877df8e6120d359 (AES, 32 bytes)
mac -> 4e3cb41f6fb908cd0b5bb6927bf6b9bec5cb1dd15eb440e1ef23d7c7dcbb27a9 (HmacSHA256, 32 bytes)
RFC 5869 test case 1 matches: true同じ秘密から、info(用途)を変えるだけで、まったく別の鍵が得られる。最後の行では、HKDF の仕様である RFC 5869 のテストベクターと結果が一致することを確かめている。これまで HKDF を使うには、Bouncy Castle などの外部ライブラリか、自前の実装が必要だった。
PEM Encodings of Cryptographic Objects(JEP 470・プレビュー)
鍵や証明書を、PEM 形式(-----BEGIN PUBLIC KEY----- で始まるテキスト)と相互に変換する API である。
// JEP 470: PEM Encodings of Cryptographic Objects (preview in JDK 25)
// Run: java --enable-preview PemDemo.java
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.PEMDecoder;
import java.security.PEMEncoder;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.util.Arrays;
public class PemDemo {
public static void main(String[] args) throws Exception {
KeyPair pair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
// 公開鍵を PEM 文字列にする(これまでは Base64 と BEGIN/END 行を自分で組み立てていた)
String publicPem = PEMEncoder.of().encodeToString(pair.getPublic());
System.out.print(publicPem);
// PEM 文字列から鍵オブジェクトに戻す
PublicKey decoded = PEMDecoder.of().decode(publicPem, PublicKey.class);
System.out.println("decoded: " + decoded.getAlgorithm() + ", equal = "
+ Arrays.equals(decoded.getEncoded(), pair.getPublic().getEncoded()));
// 秘密鍵はパスワードで暗号化して PEM にできる
char[] password = "correct horse battery staple".toCharArray();
String encryptedPem = PEMEncoder.of().withEncryption(password).encodeToString(pair.getPrivate());
System.out.println(encryptedPem.lines().findFirst().orElseThrow() + " ... ("
+ encryptedPem.lines().count() + " lines)");
PrivateKey privateKey = PEMDecoder.of().withDecryption(password).decode(encryptedPem, PrivateKey.class);
System.out.println("decrypted: " + privateKey.getAlgorithm() + ", equal = "
+ Arrays.equals(privateKey.getEncoded(), pair.getPrivate().getEncoded()));
}
}-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAPvSZ3v989RaF/zb9ffrrTOJEmMck1dMTBNLpMzBYVwA=
-----END PUBLIC KEY-----
decoded: EdDSA, equal = true
-----BEGIN ENCRYPTED PRIVATE KEY----- ... (6 lines)
decrypted: EdDSA, equal = truePEM は鍵や証明書のやり取りで最もよく使われる形式なのに、これまで Java の標準 API では直接扱えなかった。Base64 のエンコードと BEGIN / END 行を自分で組み立てるか、外部ライブラリを使っていた。パスワードで暗号化した秘密鍵も扱える。
その後:JDK 26 で 2nd プレビュー(JEP 524)、JDK 27 で 3rd プレビュー(JEP 538)となった。
Vector API(JEP 508・10th インキュベーター)
CPU の SIMD 命令を使ったベクトル演算を、Java から明示的に書くための API である。2 つの配列の内積を、普通のループと Vector API で計算して比べた。
// JEP 508: Vector API (tenth incubator in JDK 25)
// Run: java --add-modules jdk.incubator.vector VectorApiDemo.java
import jdk.incubator.vector.FloatVector;
import jdk.incubator.vector.VectorOperators;
import jdk.incubator.vector.VectorSpecies;
public class VectorApiDemo {
static final VectorSpecies<Float> SPECIES = FloatVector.SPECIES_PREFERRED;
// 2 つの配列の内積(a[0]*b[0] + a[1]*b[1] + ...)を普通のループで計算する
static float scalarDot(float[] a, float[] b) {
float sum = 0;
for (int i = 0; i < a.length; i++) {
sum += a[i] * b[i];
}
return sum;
}
// 同じ計算を Vector API で、SPECIES.length() 個ずつまとめて行う
static float vectorDot(float[] a, float[] b) {
var acc = FloatVector.zero(SPECIES);
int i = 0;
int upper = SPECIES.loopBound(a.length);
for (; i < upper; i += SPECIES.length()) {
var va = FloatVector.fromArray(SPECIES, a, i);
var vb = FloatVector.fromArray(SPECIES, b, i);
// acc += va * vb(積和演算)
acc = va.fma(vb, acc);
}
float sum = acc.reduceLanes(VectorOperators.ADD);
// 端数はスカラーで処理する
for (; i < a.length; i++) {
sum += a[i] * b[i];
}
return sum;
}
public static void main(String[] args) {
System.out.println("species = " + SPECIES + ", lanes per operation = " + SPECIES.length());
float[] small = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
System.out.println("dot(small, small): scalar = " + scalarDot(small, small) + ", vector = " + vectorDot(small, small));
// 100 万要素の内積を 500 回計算して時間を比べる
float check = 0;
float[] a = new float[1_000_000];
float[] b = new float[1_000_000];
for (int i = 0; i < a.length; i++) {
a[i] = (i % 100) / 100f;
b[i] = (i % 7) / 7f;
}
// 計測の前に、両方のメソッドを JIT コンパイルさせておく(ウォームアップ)
float[] tiny = new float[1024];
for (int r = 0; r < 20_000; r++) {
check += scalarDot(tiny, tiny) + vectorDot(tiny, tiny);
}
for (int round = 1; round <= 3; round++) {
long t0 = System.nanoTime();
for (int r = 0; r < 500; r++) check += scalarDot(a, b);
long t1 = System.nanoTime();
for (int r = 0; r < 500; r++) check += vectorDot(a, b);
long t2 = System.nanoTime();
System.out.printf("round %d: scalar %4d ms | vector %4d ms%n", round, (t1 - t0) / 1_000_000, (t2 - t1) / 1_000_000);
}
// 結果を使って、計算が最適化で消されないようにする
if (check == 42) System.out.println();
}
}WARNING: Using incubator modules: jdk.incubator.vector
species = Species[float, 4, S_128_BIT], lanes per operation = 4
dot(small, small): scalar = 385.0, vector = 385.0
round 1: scalar 544 ms | vector 202 ms
round 2: scalar 544 ms | vector 200 ms
round 3: scalar 577 ms | vector 197 msこの環境(Apple Silicon)では float を 4 つずつまとめて計算し、普通のループより約 2.8 倍速い。ウォームアップを入れているのは、JIT コンパイラが FloatVector などのオブジェクトを消してベクトル命令に置き換える前に計測すると、Vector API のほうが遅く見えることがあるためである。
その後:Valhalla プロジェクト(値オブジェクト)の完成を待っているため、JDK 27 でも 12th インキュベーター(JEP 537)のままである。
ランタイム・監視
この章の機能は、どれも コードを変えずに、JVM のオプションだけで 効果が得られる。
Compact Object Headers(JEP 519)
すべての Java オブジェクトの先頭には オブジェクトヘッダー がある。ロックやハッシュコード、GC の情報を持つ「マークワード」と、クラスを指す「クラスポインター」でできている。64 ビットの JVM では、これが 12 バイト(96 ビット)あった。JEP 519 は、これを 8 バイト(64 ビット) に圧縮する。
int を 2 つ持つ record Point(int x, int y) のメモリ上のレイアウトは、次のようになる。
| マークワード | クラスポインター | x | y | パディング | 合計 | |
|---|---|---|---|---|---|---|
| 従来 | 8 B | 4 B | 4 B | 4 B | 4 B | 24 B |
| Compact Object Headers | 8 B(クラスポインターを含む) | ― | 4 B | 4 B | ― | 16 B |
オブジェクトのサイズは 8 バイト単位に切り上げられるので、従来はヘッダー 12 バイト + フィールド 8 バイト = 20 バイトが、パディングを足して 24 バイトになっていた。ヘッダーが 8 バイトになれば、ちょうど 16 バイトに収まる。
record Point(int x, int y) を 1,000 万個作って、ヒープの使用量を比べた。
// JEP 519: Compact Object Headers
// Run: java -Xmx2g CompactHeadersDemo.java
// Run: java -Xmx2g -XX:+UseCompactObjectHeaders CompactHeadersDemo.java
import java.lang.management.ManagementFactory;
public class CompactHeadersDemo {
// int を 2 つ持つだけの小さなオブジェクト
record Point(int x, int y) {}
static final int COUNT = 10_000_000;
static long usedHeap() {
for (int i = 0; i < 3; i++) System.gc();
Runtime rt = Runtime.getRuntime();
return rt.totalMemory() - rt.freeMemory();
}
public static void main(String[] args) {
boolean compact = ManagementFactory.getRuntimeMXBean().getInputArguments().contains("-XX:+UseCompactObjectHeaders");
System.out.println("UseCompactObjectHeaders = " + compact);
long before = usedHeap();
Point[] points = new Point[COUNT];
for (int i = 0; i < COUNT; i++) {
points[i] = new Point(i, -i);
}
long after = usedHeap();
// 配列自体(参照 1 個 4 バイト × 1,000 万)を引いて、Point 1 個あたりのサイズを求める
long arrayBytes = 4L * COUNT;
double perObject = (double) (after - before - arrayBytes) / COUNT;
System.out.printf("%,d Points: %,d MB on the heap, %.1f bytes per Point%n",
COUNT, (after - before) / 1024 / 1024, perObject);
System.out.println("check: " + points[COUNT - 1].y());
}
}java -Xmx2g CompactHeadersDemo.java
UseCompactObjectHeaders = false
10,000,000 Points: 267 MB on the heap, 24.1 bytes per Point
check: -9999999
java -Xmx2g -XX:+UseCompactObjectHeaders CompactHeadersDemo.java
UseCompactObjectHeaders = true
10,000,000 Points: 191 MB on the heap, 16.1 bytes per Point
check: -9999999| 従来 | Compact Object Headers | |
|---|---|---|
| Point 1 個あたり | 24.1 バイト | 16.1 バイト |
| 1,000 万個(配列を含む) | 267 MB | 191 MB(約 28% 減) |
計算どおり、Point 1 個あたり 24 バイトから 16 バイトへと 3 分の 1 減った。実際のアプリケーションでは、大きなオブジェクトも混ざるので効果はもう少し小さくなる。JEP 519 では、SPECjbb2015 ベンチマークでヒープ使用量が 22%、CPU 時間が 8% 減ったと報告されている。小さなオブジェクトを大量に扱うアプリケーション(キャッシュ、コレクション、イベント処理など)ほど効果が大きい。
その後:JDK 27 でデフォルトで有効 になった(JEP 534)。従来のヘッダーに戻したい場合は -XX:-UseCompactObjectHeaders を指定する。
AOT キャッシュの改善(JEP 514・JEP 515)
Java アプリケーションの起動が遅い理由の 1 つは、起動のたびにクラスを読み込み、検証し、リンクする作業を繰り返していることである。JDK 24 の JEP 483 で、この結果をファイルに保存して次回以降に再利用する AOT キャッシュ が導入された。Java 25 では、これが 2 つの点で改善された。
- JEP 514(コマンドラインの簡略化):JDK 24 では、記録(
-XX:AOTMode=record)と作成(-XX:AOTMode=create)の 2 回の実行が必要だった。Java 25 では-XX:AOTCacheOutputを付けて 1 回実行するだけ でキャッシュができる。 - JEP 515(メソッドプロファイリング):試運転中に集めた「どのメソッドがよく呼ばれるか」というプロファイルもキャッシュに保存する。本番の実行では、JIT コンパイラが最初からそのプロファイルを使えるので、ピーク性能に早く到達する。
flowchart LR
A["① 試運転<br/>-XX:AOTCacheOutput=app.aot"] --> B["app.aot<br/>クラス + プロファイル"]
B --> C["② 本番<br/>-XX:AOTCache=app.aot"]
CSV の解析、集計、日付の整形、HTTP リクエストの組み立て、仮想スレッドを使う小さなアプリケーションで試した。AOT キャッシュは、クラスパスにディレクトリを指定できないので、まず JAR を作る。
// JEP 514: Ahead-of-Time Command-Line Ergonomics / JEP 515: Ahead-of-Time Method Profiling
// Run: javac -d out AotCacheDemo.java && jar --create --file app.jar -C out .
// Run: java -cp app.jar AotCacheDemo
// Run: java -XX:AOTCacheOutput=app.aot -cp app.jar AotCacheDemo
// Run: java -XX:AOTCache=app.aot -cp app.jar AotCacheDemo
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.Executors;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
public class AotCacheDemo {
record Order(String id, LocalDate date, String category, int amount) {}
static final String CSV = """
A-1,2025-09-16,book,1200
A-2,2025-09-16,food,800
A-3,2025-09-17,book,2400
A-4,2025-09-18,toy,1500
""";
public static void main(String[] args) throws Exception {
// 小さな「アプリ」:CSV の解析、集計、日付の整形、HTTP リクエストの組み立て、仮想スレッド
Pattern comma = Pattern.compile(",");
List<Order> orders = CSV.lines()
.map(comma::split)
.map(f -> new Order(f[0], LocalDate.parse(f[1]), f[2], Integer.parseInt(f[3])))
.toList();
Map<String, Integer> byCategory = orders.stream()
.collect(Collectors.groupingBy(Order::category, java.util.TreeMap::new, Collectors.summingInt(Order::amount)));
HttpRequest request = HttpRequest.newBuilder(URI.create("https://example.com/report"))
.timeout(Duration.ofSeconds(5))
.POST(HttpRequest.BodyPublishers.ofString(byCategory.toString()))
.build();
HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build();
int total;
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
total = executor.submit(() -> orders.stream().mapToInt(Order::amount).sum()).get();
}
String first = orders.getFirst().date().format(DateTimeFormatter.ofPattern("yyyy/MM/dd (EEE)", Locale.US));
System.out.println(byCategory + " total=" + total + " first=" + first + " " + request.method()
+ " " + client.version());
// JVM プロセスの起動からここまでの時間
Instant start = ProcessHandle.current().info().startInstant().orElseThrow();
System.out.println("finished " + Duration.between(start, Instant.now()).toMillis() + " ms after the JVM started");
}
}javac -d out AotCacheDemo.java && jar --create --file app.jar -C out .
java -cp app.jar AotCacheDemo
{book=3600, food=800, toy=1500} total=5900 first=2025/09/16 (Tue) POST HTTP_2
finished 245 ms after the JVM started
java -XX:AOTCacheOutput=app.aot -cp app.jar AotCacheDemo
{book=3600, food=800, toy=1500} total=5900 first=2025/09/16 (Tue) POST HTTP_2
finished 338 ms after the JVM started
[0.377s][warning][aot] Skipping jdk/internal/event/Event: Not in loaded state
[0.377s][warning][aot] Skipping jdk/internal/event/Event: JFR event class
... (8 more [warning][aot] lines about JFR event classes)
Temporary AOTConfiguration recorded: app.aot.config
Launching child process /path/to/jdk-25/bin/java to assemble AOT cache app.aot using configuration app.aot.config
Picked up JAVA_TOOL_OPTIONS: -Djava.class.path=app.jar -XX:AOTCacheOutput=app.aot -XX:AOTConfiguration=app.aot.config -XX:AOTMode=create
Reading AOTConfiguration app.aot.config and writing AOTCache app.aot
AOTCache creation is complete: app.aot 22265856 bytes
Removed temporary AOT configuration file app.aot.config
java -XX:AOTCache=app.aot -cp app.jar AotCacheDemo
{book=3600, food=800, toy=1500} total=5900 first=2025/09/16 (Tue) POST HTTP_2
finished 202 ms after the JVM started試運転の後に、22 MB ほどのキャッシュファイル app.aot ができた。次に、キャッシュなし・ありをそれぞれ 15 回ずつ交互に実行し、JVM の起動から処理の完了までの時間を比べた。
起動から完了までの時間(実測、15 回の中央値、目盛りの単位:s)
%%{init: {"gantt": {"leftPadding": 140}}}%%
gantt
dateFormat x
axisFormat %S.%L
tickInterval 100millisecond
section キャッシュなし
279 ms :crit, 0, 279
section AOT キャッシュあり
169 ms :active, 0, 169
キャッシュを使うと、起動から完了までの時間が 約 4 割短くなった。コードは 1 行も変えていない。Spring Boot のように起動時に大量のクラスを読み込むアプリケーションほど、効果が大きくなる。
キャッシュは、作成したときと 同じ JDK・同じクラスパス でしか使えない。JAR を差し替えたら作り直す必要がある。条件が合わないときは警告を出してキャッシュなしで起動するので、アプリケーションが動かなくなることはない。
その後:JDK 26 の JEP 516 で、ZGC を含む どの GC でも オブジェクトをキャッシュできるようになった(それまでは一部の GC に限られていた)。Project Leyden は、引き続き起動とウォームアップの短縮に取り組んでいる。
Generational Shenandoah(JEP 521)
Shenandoah は、アプリケーションを止める時間(停止時間)を極めて短く保つ GC である。JDK 24 で実験的に導入された 世代別モード が、Java 25 で正式機能になった。Java 21 で ZGC が世代別になったのと同じ考え方である。
「ほとんどのオブジェクトはすぐ死ぬ」 という経験則(世代仮説)に基づき、ヒープを若い世代と古い世代に分けて、若い世代を頻繁に回収する。世代別でないモードでは、GC のたびに 長生きするオブジェクトも含めてヒープ全体 をたどる必要がある。
古い世代の差が出やすいように、最後まで生き残るデータを先に約 600 MB 作り、そのうえで 1 KB の短命なオブジェクトを 3,000 万個作った。
// JEP 521: Generational Shenandoah
// Run: java -XX:+UseShenandoahGC -XX:ShenandoahGCMode=generational -Xmx2g GenerationalShenandoahDemo.java
// Run: java -XX:+UseShenandoahGC -Xmx2g GenerationalShenandoahDemo.java
import java.lang.management.GarbageCollectorMXBean;
import java.lang.management.ManagementFactory;
import java.util.ArrayList;
import java.util.List;
public class GenerationalShenandoahDemo {
public static void main(String[] args) {
// 直近の 5 万個だけを保持する(すぐゴミになる短命なオブジェクト)
byte[][] recent = new byte[50_000][];
// 最後まで生き残るデータを先に約 600 MB 作っておく(キャッシュなどのつもり)
List<byte[]> longLived = new ArrayList<>();
for (int i = 0; i < 600_000; i++) {
longLived.add(new byte[1024]);
}
long start = System.nanoTime();
for (int i = 0; i < 30_000_000; i++) {
recent[i % recent.length] = new byte[1024];
}
System.out.printf("allocated ~%d GB, long-lived %,d, elapsed %,d ms%n",
30_000_000L * 1024 / 1024 / 1024 / 1024, longLived.size(), (System.nanoTime() - start) / 1_000_000);
for (GarbageCollectorMXBean gc : ManagementFactory.getGarbageCollectorMXBeans()) {
System.out.printf(" %-20s count %4d, total %,6d ms%n", gc.getName(), gc.getCollectionCount(), gc.getCollectionTime());
}
}
}java -XX:+UseShenandoahGC -XX:ShenandoahGCMode=generational -Xmx2g GenerationalShenandoahDemo.java
allocated ~28 GB, long-lived 600,000, elapsed 4,842 ms
Shenandoah Pauses count 76, total 14 ms
Shenandoah Cycles count 37, total 1,073 ms
java -XX:+UseShenandoahGC -Xmx2g GenerationalShenandoahDemo.java
allocated ~28 GB, long-lived 600,000, elapsed 8,978 ms
Shenandoah Pauses count 114, total 19 ms
Shenandoah Cycles count 56, total 4,445 ms両方のモードを 5 回ずつ実行した中央値は次のとおりである。
経過時間と GC サイクルの合計時間、5 回の中央値(実測、目盛りの単位:s)
%%{init: {"gantt": {"leftPadding": 140}}}%%
gantt
dateFormat x
axisFormat %S.%L
tickInterval 2second
section 世代別 経過
4842 ms :active, 0, 4842
section 世代別 GC
1072 ms :active, 0, 1072
section 非世代別 経過
8978 ms :crit, 0, 8978
section 非世代別 GC
4491 ms :crit, 0, 4491
世代別モードでは、GC が同時に動いている時間(Cycles)の合計が 約 4 分の 1 になり、処理全体の時間も ほぼ半分 になった。長生きする 600 MB を毎回たどらずに済むためである。停止時間(Pauses)は、どちらのモードでも合計 20 ms 程度以下と非常に短い。
計測値のばらつきは大きかった。同じ条件でも、世代別モードの経過時間は 4.5〜6.0 s、世代別でないモードは 8.3〜13.1 s の幅があった。また、長生きするデータが少ないワークロード(最初の試行では約 60 MB)では、2 つのモードの差はほとんど出なかった。世代別にするかどうかは、自分のアプリケーションで計測して決めるのがよい。
JFR(JDK Flight Recorder)
Java 25 では、JFR に 3 つの JEP が入った。JFR は、JVM に組み込まれた低負荷のプロファイラ兼イベント記録機能である。
JFR Method Timing & Tracing(JEP 520)
コードを変えずに、指定したメソッドの呼び出し回数と実行時間を計測できるようになった。JVM がバイトコードに計測処理を自動で埋め込む。
// JEP 520: JFR Method Timing & Tracing
// Run: java '-XX:StartFlightRecording:method-timing=MethodTimingDemo::parse;java.lang.Integer::parseInt,filename=timing.jfr' MethodTimingDemo.java
// Run: jfr view method-timing timing.jfr
import java.util.ArrayList;
import java.util.List;
public class MethodTimingDemo {
// 計測したいメソッド。コードには計測用の処理を一切書いていない
static List<Integer> parse(String csv) {
List<Integer> values = new ArrayList<>();
for (String s : csv.split(",")) {
values.add(Integer.parseInt(s.trim()));
}
return values;
}
public static void main(String[] args) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 1_000; i++) {
sb.append(i).append(", ");
}
sb.append(1_000);
String csv = sb.toString();
long sum = 0;
for (int i = 0; i < 2_000; i++) {
sum += parse(csv).size();
}
System.out.println("parsed " + sum + " values");
}
}java '-XX:StartFlightRecording:method-timing=MethodTimingDemo::parse;java.lang.Integer::parseInt,filename=timing.jfr' MethodTimingDemo.java
[0.373s][info][jfr,startup] Started recording 1. No limit specified, using maxsize=250MB as default.
[0.373s][info][jfr,startup]
[0.373s][info][jfr,startup] Use jcmd 42053 JFR.dump name=1 to copy recording data to file.
parsed 2002000 values
jfr view method-timing timing.jfr
Method Timing
Timed Method Invocations Minimum Time Average Time Maximum Time
------------------------------------------------------- ----------- ------------ ------------ ------------
MethodTimingDemo.parse(String) 2,000 0.078100 ms 0.124000 ms 5.410000 ms
java.lang.Integer.parseInt(CharSequence, int, int, int) 3 0.000334 ms 0.002850 ms 0.006960 ms
java.lang.Integer.parseInt(String) 2,002,001 0.000041 ms 0.000061 ms 0.098300 ms
java.lang.Integer.parseInt(String, int) 2,002,007 0.000019 ms 0.000019 ms 0.057200 ms自分で書いた parse だけでなく、JDK のメソッド(Integer.parseInt)も同じように計測できる。メソッドの指定には クラス名::メソッド名 のほか、クラス名だけ(そのクラスの全メソッド)や、@jakarta.ws.rs.GET のようなアノテーションも使える。method-trace を指定すると、呼び出しごとのスタックトレースも記録される。
これまでは、ログ出力を足して再デプロイするか、Java エージェントを使う必要があった。本番環境で「このメソッドが遅い気がする」を確かめるための強力な道具である。ただし、計測を埋め込んだメソッドは遅くなる。このサンプルでも、Integer.parseInt を計測対象に加えると、parse の平均時間が約 0.06 ms から約 0.12 ms に増えた。対象は絞って使う。
JFR CPU-Time Profiling(JEP 509・実験的)
従来の JFR のサンプリングは「一定時間ごとに、実行中のスレッドのスタックを取る」方式で、ネイティブコードの実行中などを正確に捉えられなかった。JEP 509 は Linux カーネルの CPU タイマーを使って、実際に CPU を使った時間 に比例したサンプリングを行う。Linux 専用の実験的機能なので、本記事の macOS 環境では試していない。Linux では次のように使う。
java -XX:StartFlightRecording=jdk.CPUTimeSample#enabled=true,filename=profile.jfr App.java
jfr view cpu-time-hot-methods profile.jfrJFR Cooperative Sampling(JEP 518)
JFR がスタックをサンプリングする仕組みを作り直し、スレッドが安全な地点(セーフポイント)に来たときに、スレッド自身にスタックを記録させるようにした。サンプリングの安定性と精度が上がる。JVM 内部の改善なので、使う側がすることは特にない。
32-bit x86 ポートの削除(JEP 503)
32-bit x86 向けのソースコードとビルドの仕組みが、JDK から削除された。JDK 24 で非推奨になっていた(JEP 501)。Windows 版はすでに JDK 24 で削除されており(JEP 479)、これで 32-bit x86 のサポートは完全になくなった。仮想スレッドなどの新機能を 32-bit 向けに実装する負担がなくなる。
まとめ:Java 25 プロジェクトで何を使うか
最後に、既存プロジェクトへの適用という観点で整理する。
| 区分 | 機能 | 判断 |
|---|---|---|
| すぐ使える | Compact Source Files、Module Import、Flexible Constructor、Scoped Values、KDF API | 正式機能。新しく書くコードから使う |
| オプションを試す | Compact Object Headers、AOT キャッシュ、世代別 Shenandoah | コードは変えずに効果が出る。計測して採用を決める |
| 監視で使う | JFR Method Timing & Tracing | 本番の調査で「このメソッドが遅い」を確かめるのに使う |
| 様子見 | Structured Concurrency、Stable Values、PEM、Primitive Patterns、Vector API | プレビュー・インキュベーター。API はまだ変わる(Stable Values は改名済み) |
Java 21 からの移行で特に注目したいのは、ランタイムの 3 つである。オブジェクトヘッダーの圧縮ではヒープが 3 分の 1 減り、AOT キャッシュでは起動が約 4 割速くなった。どちらも アプリケーションのコードを 1 行も変えずに 得られた効果である。まず JVM のオプションを変えて計測するところから始めるのがよい。
参考資料
- JDK 25 プロジェクトページ
- JEP 512: Compact Source Files and Instance Main Methods
- JEP 511: Module Import Declarations
- JEP 513: Flexible Constructor Bodies
- JEP 507: Primitive Types in Patterns, instanceof, and switch (Third Preview)
- JEP 506: Scoped Values
- JEP 505: Structured Concurrency (Fifth Preview) / JEP 525: Structured Concurrency (Sixth Preview)
- JEP 502: Stable Values (Preview) / JEP 526: Lazy Constants (Second Preview)
- JEP 510: Key Derivation Function API
- JEP 470: PEM Encodings of Cryptographic Objects (Preview)
- JEP 519: Compact Object Headers / JEP 534: Compact Object Headers by Default
- JEP 514: Ahead-of-Time Command-Line Ergonomics / JEP 515: Ahead-of-Time Method Profiling
- JEP 521: Generational Shenandoah
- JEP 520: JFR Method Timing & Tracing / JEP 509: JFR CPU-Time Profiling (Experimental)
- JDK 27 プロジェクトページ