Java 25 新機能まとめ

公開日: 2026-10-01 21:00 10603文字 54 min read

issyuu avatar

issyuu

静かな湖面、揺れる湖心小築(Lakeheart Retreat)、静かな水辺で紡ぐ思考と日常の歌。

Java 25(LTS)の 18 個の JEP を、そのまま実行できるサンプルコードと実行結果で解説する。Java の書き始めを簡単にする言語機能、正式化された Scoped Values、オブジェクトヘッダーの圧縮・AOT キャッシュ・世代別 Shenandoah は実測データで掘り下げ、各機能の JDK 27 までの「その後」も整理した。

はじめに

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)
470PEM Encodings of Cryptographic ObjectsプレビューJDK 27 でも 3rd プレビュー
502Stable ValuesプレビューLazy Constants に改名、JDK 27 で 3rd プレビュー
50332-bit x86 ポートの削除削除―
505Structured Concurrency5th プレビューJDK 27 でも 7th プレビュー
506Scoped Values正式―
507Primitive Types in Patterns3rd プレビューJDK 27 でも 5th プレビュー
508Vector API10th インキュベーターJDK 27 でも 12th インキュベーター
509JFR CPU-Time Profiling実験的(Linux のみ)―
510Key Derivation Function API正式―
511Module Import Declarations正式―
512Compact Source Files and Instance Main Methods正式―
513Flexible Constructor Bodies正式―
514AOT のコマンドライン簡略化正式JDK 26 でどの GC でもオブジェクトをキャッシュ可能に
515AOT メソッドプロファイリング正式同上
518JFR Cooperative Sampling正式―
519Compact Object Headers正式(オプション)JDK 27 でデフォルトに
520JFR Method Timing & Tracing正式―
521Generational 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 つある。

  1. 暗黙のクラス:トップレベルに書いたフィールドとメソッドは、コンパイラが自動で作るクラス(クラス名はファイル名と同じになる)のメンバーになる。
  2. インスタンスの main:static も public も引数も不要になった。main がインスタンスメソッドなら、JVM がインスタンスを作ってから呼び出す。
  3. 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.List

import 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.com

OldCircle の出力に注目してほしい。親のコンストラクタ 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 = 1410065408

value 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: NoSuchElementException

ThreadLocal との違いは次のとおり。

ThreadLocalScopedValue
値の変更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 -> false

8 本のスレッドが同時に 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 = true

PEM は鍵や証明書のやり取りで最もよく使われる形式なのに、これまで 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) のメモリ上のレイアウトは、次のようになる。

マークワードクラスポインターxyパディング合計
従来8 B4 B4 B4 B4 B24 B
Compact Object Headers8 B(クラスポインターを含む)―4 B4 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 MB191 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.jfr

JFR 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 のオプションを変えて計測するところから始めるのがよい。

参考資料

© 2020 - 2026 issyuu @Lakeheart Retreat
Powered by theme astro-koharu · Inspired by Shoka
読み込み中...