What's New in Java 25

Published 2026-10-01 21:00 7996 words 40 min read

issyuu avatar

issyuu

Quiet waters, shimmering Lakeheart Retreat; thoughts and daily musings woven by the peaceful waterside.

All 18 JEPs of Java 25 (LTS), explained with runnable samples and their real output. Covers the language features that make Java easier to start with and the now-standard scoped values. Compact object headers, the AOT cache and generational Shenandoah are examined with measurements, and each feature's later changes up to JDK 27 are summarized.

Introduction

Java 25 is the LTS (long-term support) release that shipped in September 2025. It brings together two years of work since Java 21, from JDK 22 to JDK 25.

If Java 21 changed how we write concurrent code with virtual threads, Java 25 has two broad themes. One is an easier start: you can begin a program without class or public static void main(String[] args). The other is runtime efficiency: there are more options that cut memory use and startup time without changing a single line of code.

This article walks through the 18 JEPs of Java 25 by running them. Every sample is a single file with no external libraries. For the performance features, we check the effect with real measurements. Each feature also ends with a "Since then" note that covers what happened up to JDK 27 (GA in September 2026).

Test environment and how to run the samples

  • JDK: OpenJDK 25 (25.0.4)
  • OS / CPU: macOS (Apple Silicon, 8 cores)
  • The samples are launched directly from source (JEP 330). No compilation needed (only the AOT cache sample builds a JAR).
java CompactSourceDemo.java                        # standard features
java --enable-preview StableValueDemo.java         # preview features
  • On JDK 25, --enable-preview alone enables preview features when you launch a source file (Java 21 also needed --source 21).
  • The samples are also on GitHub at smile-lxy/java-new-features. Their comments are written in Japanese, Chinese and English; this article shows only the English comments.
  • The first comment of each sample (// Run:) gives the command to run it.
  • Times, memory sizes and key values in the output vary by environment and from run to run.

Overview: the 18 JEPs of Java 25

JEPFeatureStatus in Java 25Since then (up to JDK 27)
470PEM Encodings of Cryptographic ObjectsPreviewStill a preview in JDK 27 (third)
502Stable ValuesPreviewRenamed Lazy Constants; third preview in JDK 27
50332-bit x86 port removedRemoved—
505Structured ConcurrencyFifth previewStill a preview in JDK 27 (seventh)
506Scoped ValuesStandard—
507Primitive Types in PatternsThird previewStill a preview in JDK 27 (fifth)
508Vector APITenth incubatorStill an incubator in JDK 27 (twelfth)
509JFR CPU-Time ProfilingExperimental (Linux only)—
510Key Derivation Function APIStandard—
511Module Import DeclarationsStandard—
512Compact Source Files and Instance Main MethodsStandard—
513Flexible Constructor BodiesStandard—
514AOT command-line ergonomicsStandardJDK 26 caches objects with any GC
515AOT method profilingStandardSame as above
518JFR Cooperative SamplingStandard—
519Compact Object HeadersStandard (opt-in)On by default in JDK 27
520JFR Method Timing & TracingStandard—
521Generational ShenandoahStandard (opt-in)—

They fall into four groups.

flowchart LR
    J25["Java 25"] --> L["Language"]
    J25 --> C["Concurrency"]
    J25 --> A["Libraries /<br/>security"]
    J25 --> R["Runtime /<br/>monitoring"]
    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 cache (514 / 515)"]
    R --> R3["Generational Shenandoah (521)"]
    R --> R4["JFR (509 / 518 / 520)"]
    classDef star fill:#fde68a,stroke:#b45309,color:#78350f
    class L1,C1,R1,R2,R3 star

We go in this order: language → concurrency → libraries and security → runtime and monitoring. The standard features with the biggest impact (in yellow) get the most detail, and we measure the three runtime features.

Language

Compact Source Files and Instance Main Methods (JEP 512)

You can now start a program by writing just a method, with no class declaration. This is Java 21's JEP 445 (Unnamed Classes and Instance Main Methods), which became standard after four previews and a change of name and design.

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

// No class declaration, no public static, no String[] args. Classes from java.base need no import

final List<String> fruits = List.of("apple", "banana", "cherry");

String shout(String s) {
    return s.toUpperCase() + "!";
}

void main() {
    // IO.println is a short form of System.out.println (java.lang.IO, added in JDK 25)
    IO.println("Hello, Java 25!");
    for (String f : fruits) {
        IO.println(shout(f));
    }

    // List, Map, Collectors and so on come from the automatic import of the java.base module
    Map<Integer, List<String>> byLength = fruits.stream().collect(Collectors.groupingBy(String::length));
    IO.println("by length: " + byLength);

    // Behind the scenes, the compiler creates an implicit class
    IO.println("class name: " + getClass().getName());
}
Hello, Java 25!
APPLE!
BANANA!
CHERRY!
by length: {5=[apple], 6=[banana, cherry]}
class name: CompactSourceDemo

There are three points.

  1. Implicit class: fields and methods written at the top level become members of a class that the compiler creates (named after the file).
  2. Instance main: no static, no public, no parameters. If main is an instance method, the JVM creates an instance and then calls it.
  3. Automatic import of java.base, and the IO class: a compact source file implicitly does import module java.base; (next section), so List, Map and Collectors work without imports. The new java.lang.IO class shortens System.out.println to IO.println.

It looks like a feature for beginners, but it is also handy for small scripts and quick experiments. When the program grows, add a class declaration and it becomes an ordinary class.

In the JDK 23 and 24 previews, methods such as println were imported statically and automatically, but the final version settled on writing IO.println. Also, Java 21 called this an "unnamed class"; JDK 22 renamed it an "implicit class".

Module Import Declarations (JEP 511)

With import module, you can import every package that a module exports, at once.

// JEP 511: Module Import Declarations
// Run: java ModuleImportDemo.java

// Import every package that a module exports, in one line
import module java.base;      // java.util, java.util.stream, java.time, java.nio.file ...
import module java.net.http;  // java.net.http

// java.desktop also has java.awt.List, which makes List ambiguous. A single-type import wins, so this resolves it
import module java.desktop;
import java.util.List;

public class ModuleImportDemo {

    public static void main(String[] args) {
        // Classes from java.util, java.time and java.net.http, with no single-type imports
        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);

        // Classes from java.desktop work too (except 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

The single line import module java.base; makes 58 packages available, including java.util, java.util.stream, java.time and java.nio.file.

Watch out for name clashes. java.desktop contains java.awt.List, so importing it together with java.base makes List ambiguous. If you remove import java.util.List; from the sample, you get this error.

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

An ordinary import of a single type wins over import module, so that resolves it.

You can use import module even if your own code is not a module (no module-info.java). In production code it makes it harder to see where a class comes from, so, like wildcard imports, use it with care. It works well for prototypes and for the compact source files of the previous section.

Flexible Constructor Bodies (JEP 513)

A constructor can now run code before super(...) or this(...). You can validate arguments or compute the value to pass to the parent first.

// JEP 513: Flexible Constructor Bodies
// Run: java FlexibleConstructorDemo.java

public class FlexibleConstructorDemo {

    static class Shape {
        Shape() {
            // Call an overridden method from the parent constructor (a classic pitfall)
            System.out.println("  Shape(): " + describe());
        }
        String describe() { return "shape"; }
    }

    // Before: super() had to come first, and fields could only be assigned after it
    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: validate arguments and initialize fields before 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; }
    }

    // Compute the value for the parent before super() (previously this needed a static helper)
    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

Look at the output of OldCircle. The parent constructor Shape() calls the overridden describe(). At that moment the child's field radius has not been assigned yet, so it shows 0.0. This is a classic Java pitfall.

In NewCircle, this.radius = radius; can come before super(), so even the parent constructor sees the correct value.

flowchart TB
    subgraph P["Prologue (before super)"]
        P1["Validate arguments"] --> P2["Initialize fields"]
        P2 --> P3["Compute values for the parent"]
    end
    P3 --> S["super(...)"]
    S --> E["Epilogue (after super)<br/>use this freely"]

However, the code before super() (the prologue) cannot use this, which is not initialized yet. Calling an instance method or reading a field that has not been initialized is a compile error. You can only assign fields and do computations that do not involve this.

Primitive Types in Patterns, instanceof, and switch (JEP 507, third preview)

Pattern matching can use primitive types. The pattern matching that became standard in Java 21 only covered reference types.

// 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: checks whether the value converts to int without losing information
    static String fitsInInt(long value) {
        if (value instanceof int i) {
            return value + " fits in int: " + i;
        }
        return value + " does not fit in int";
    }

    // Same for double: it matches int if it has no fraction and is in range
    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;
        };
    }

    // Combine primitive type patterns with when guards in a switch
    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 and others can now be switch selectors
    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));

        // A plain cast silently corrupts the value
        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 checks whether value can be converted to int without losing information. 10_000_000_000L does not fit in an int, so it does not match. A plain cast (int) 10_000_000_000L raises no error and returns 1410065408, a corrupted value. Being able to write this check safely is what this feature offers.

switch selectors can now be boolean, long, float or double, and primitive patterns combine with guards, as in case int c when c >= 500.

Since then: fourth preview in JDK 26 (JEP 530) and fifth preview in JDK 27 (JEP 532); it is not standard yet.

Concurrency

Scoped Values (JEP 506)

Scoped values, which first appeared as a preview in Java 21, became standard on the fifth round. They replace ThreadLocal for passing a value deep down a call chain.

// 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) {}

    // Share "the user of this request" without passing it as an argument
    static final ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();
    static final ThreadLocal<User> TL_USER = new ThreadLocal<>();

    static void controller() {
        service();
    }

    static void service() {
        // Read the value deep in the call chain
        User u = CURRENT_USER.get();
        System.out.println("  service: user=" + u.name() + ", role=" + u.role());
    }

    public static void main(String[] args) throws Exception {
        // (1) The value is visible only inside 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());

        // (2) Rebind temporarily in a nested scope; the old value comes back on exit
        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();
        });

        // (3) call() returns a value
        String greeting = ScopedValue.where(CURRENT_USER, new User("bob", "user"))
                .call(() -> "hello, " + CURRENT_USER.get().name());
        System.out.println("(3) call() -> " + greeting);

        // (4) If you forget remove(), a ThreadLocal leaks into the next task on a pooled thread
        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();
        }

        // (5) get() outside a binding throws
        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

How they differ from ThreadLocal:

ThreadLocalScopedValue
Changing the valueset() changes it at any timeCannot change it (you can only rebind temporarily in a nested scope)
LifetimeStays until remove()Disappears automatically when run() / call() returns
Passing to child threadsInheritableThreadLocal has to copyInherited by StructuredTaskScope subtasks without copying
Typical usePer-thread cachesPer-request context (user, transaction and so on)

As output (4) shows, if you forget remove(), a ThreadLocal leaks its value into the next task on a pooled thread. With credentials, that can be a serious incident. The scope of a ScopedValue binding is set by the code structure, so this kind of bug cannot happen.

The API changed a little since the Java 21 preview. The static methods runWhere() and callWhere(), and where(...).get(...), were removed; you now always call run() or call() after where(...). call() takes a ScopedValue.CallableOp, which may throw checked exceptions.

Structured Concurrency (JEP 505, fifth preview)

An API that treats several subtasks as one unit of work. The core idea is the same as in Java 21, but the fifth preview in JDK 25 redesigned the API substantially.

Java 21 (JEP 453)Java 25 (JEP 505)
Creating a scopeSubclasses such as new StructuredTaskScope.ShutdownOnFailure()StructuredTaskScope.open(joiner)
Success and failure policyChosen by subclassChosen by a Joiner
Getting resultsthrowIfFailed() / result() after join()join() returns the result or throws
TimeoutjoinUntil(Instant)withTimeout(Duration) in the open() configuration
// 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();

    // A task that takes ms milliseconds; it logs when interrupted
    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 {
        // (1) The default joiner waits for all to succeed and interrupts the rest on the first failure
        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());
        }

        // (2) Use only the first successful result (racing mirror servers, for example)
        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());
        }

        // (3) Receive every result at once
        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);
        }

        // (4) Set a timeout for the whole scope
        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");
        }

        // (5) Forked subtasks inherit ScopedValue bindings
        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

Here are (2) (first success wins) and (4) (timeout) as a timeline.

(2) first success wins / (4) 250 ms timeout (measured, axis in s)

%%{init: {"gantt": {"leftPadding": 140}}}%%
gantt
    dateFormat x
    axisFormat %S.%L
    tickInterval 100millisecond
    section (2) any
    mirror A interrupted :0, 108
    mirror B done :active, 0, 108
    mirror C failed :crit, 0, 58
    section (4) timeout
    fast done :active, 0, 107
    slow interrupted :0, 255

In both cases, the scope interrupts the subtasks that are no longer needed. In (2), mirror A is interrupted when mirror B succeeds; in (4), slow is interrupted by the 250 ms timeout. The caller writes no cancellation code at all.

The main joiners:

JoinerBehavior
(none given)Waits for all to succeed. On the first failure, interrupts the rest and throws FailedException
allSuccessfulOrThrow()Same, and returns all the results
anySuccessfulResultOrThrow()Returns the first successful result and interrupts the rest
awaitAll()Waits for all to finish, whether they succeed or fail

Since then: the sixth preview in JDK 26 (JEP 525) made small API adjustments. anySuccessfulResultOrThrow() was renamed anySuccessfulOrThrow(), allSuccessfulOrThrow() now returns a list of results, and Joiner.onTimeout() was added so a joiner can return a result on timeout. It is still a preview in JDK 27 (seventh, JEP 533).

Libraries and security

Stable Values (JEP 502, preview)

An API for a value that can be set only once. Like a final field, the JIT compiler can treat it as a constant, yet you are free to delay its initialization.

Until now, delaying an expensive initialization until first use needed double-checked locking or the holder-class idiom.

// 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() {
            // Pretend this is expensive (100 ms)
            CREATED.incrementAndGet();
            sleep(100);
        }
    }

    // A value that can be set only once; the JIT can treat it as a constant, like a final field
    static final StableValue<Settings> SETTINGS = StableValue.of();

    static Settings settings() {
        // Initialize if not set yet; even with many threads calling at once, it runs only once
        return SETTINGS.orElseSet(Settings::new);
    }

    // The same idea as a Supplier
    static final Supplier<String> GREETING = StableValue.supplier(() -> {
        System.out.println("  computing greeting...");
        return "hello";
    });

    // A list whose elements are initialized lazily, one by one
    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));

        // A second set fails
        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

Even when 8 threads call settings() at the same time, Settings is created only once. With StableValue.list(), each element is computed once, when it is first needed.

Since then: the second preview in JDK 26 (JEP 526) renamed it Lazy Constants and trimmed the API. StableValue became LazyConstant, and low-level methods such as orElseSet() and trySet() were removed. Lazy lists are created with List.ofLazy() and lazy maps with Map.ofLazy(). It is still a preview in JDK 27 (third, JEP 531). The code above works only on JDK 25 and does not compile on JDK 26 or later.

// JDK 26 and later (preview)
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)

A standard API for key derivation functions (KDFs). From a secret obtained, for example, through a key exchange, it safely derives separate keys for separate purposes. It was added as a building block for Java 21's KEM API (JEP 452) and for post-quantum schemes such as 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 {
        // The "input keying material", e.g. from a key exchange (random in real use)
        byte[] ikm = HexFormat.of().parseHex("0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b");
        byte[] salt = HexFormat.of().parseHex("000102030405060708090a0b0c");

        KDF hkdf = KDF.getInstance("HKDF-SHA256");

        // Derive separate keys from the same secret, one per purpose (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);
        }

        // The same input as RFC 5869 test case 1: check that the OKM matches the spec
        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

Changing only info (the purpose) gives completely different keys from the same secret. The last line checks the result against the test vector of RFC 5869, the HKDF specification. Until now, HKDF needed an external library such as Bouncy Castle or your own implementation.

PEM Encodings of Cryptographic Objects (JEP 470, preview)

An API to convert keys and certificates to and from PEM (the text that starts with -----BEGIN PUBLIC KEY-----).

// 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();

        // Turn a public key into a PEM string (previously you assembled Base64 and the BEGIN/END lines yourself)
        String publicPem = PEMEncoder.of().encodeToString(pair.getPublic());
        System.out.print(publicPem);

        // Turn the PEM string back into a key object
        PublicKey decoded = PEMDecoder.of().decode(publicPem, PublicKey.class);
        System.out.println("decoded: " + decoded.getAlgorithm() + ", equal = "
                + Arrays.equals(decoded.getEncoded(), pair.getPublic().getEncoded()));

        // A private key can be encrypted with a password before encoding
        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 is the most common format for exchanging keys and certificates, yet Java's standard API could not handle it directly. You had to Base64-encode and add the BEGIN / END lines yourself, or use a library. Password-encrypted private keys are supported too.

Since then: second preview in JDK 26 (JEP 524) and third preview in JDK 27 (JEP 538).

Vector API (JEP 508, tenth incubator)

An API for writing vector computations that use the CPU's SIMD instructions explicitly from Java. We computed the dot product of two arrays with a plain loop and with the Vector API, and compared them.

// 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;

    // Compute the dot product of two arrays (a[0]*b[0] + a[1]*b[1] + ...) with a plain loop
    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;
    }

    // The same computation with the Vector API, SPECIES.length() elements at a time
    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 (fused multiply-add)
            acc = va.fma(vb, acc);
        }
        float sum = acc.reduceLanes(VectorOperators.ADD);
        // Handle the remaining elements one by one (scalar)
        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));

        // Time 500 dot products of 1,000,000 elements
        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;
        }
        // Let the JIT compile both methods before timing (warm-up)
        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);
        }
        // Use the result so the computation is not optimized away
        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

In this environment (Apple Silicon), it processes four floats at a time and is about 2.8 times as fast as the plain loop. The warm-up is there because if you time the code before the JIT compiler has removed the FloatVector objects and replaced them with vector instructions, the Vector API can look slower.

Since then: it is waiting for Project Valhalla (value objects), so it is still an incubator in JDK 27, the twelfth (JEP 537).

Runtime and monitoring

Every feature in this chapter takes effect with JVM options alone, without changing code.

Compact Object Headers (JEP 519)

Every Java object starts with an object header. It consists of the "mark word", which holds lock, hash code and GC information, and the "class pointer", which points to the object's class. On a 64-bit JVM it took 12 bytes (96 bits). JEP 519 compresses it to 8 bytes (64 bits).

Here is the memory layout of record Point(int x, int y), which holds just two ints.

Mark wordClass pointerxyPaddingTotal
Before8 B4 B4 B4 B4 B24 B
Compact Object Headers8 B (includes the class pointer)—4 B4 B—16 B

Object sizes are rounded up to multiples of 8 bytes. So before, a 12-byte header plus 8 bytes of fields made 20 bytes, padded to 24. With an 8-byte header, the object fits in exactly 16 bytes.

We created 10 million record Point(int x, int y) objects and compared the heap usage.

// 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 {

    // A small object with just two ints
    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();

        // Subtract the array itself (4 bytes per reference x 10 million) to get the size per Point
        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
BeforeCompact Object Headers
Per Point24.1 bytes16.1 bytes
10 million (including the array)267 MB191 MB (about 28% less)

As calculated, each Point shrank from 24 bytes to 16 bytes, one third less. Real applications also hold large objects, so the effect is smaller. JEP 519 reports that the SPECjbb2015 benchmark used 22% less heap and 8% less CPU time. Applications that handle many small objects (caches, collections, event processing and so on) benefit the most.

Since then: on by default in JDK 27 (JEP 534). To go back to the old headers, pass -XX:-UseCompactObjectHeaders.

AOT cache improvements (JEP 514 / JEP 515)

One reason Java applications start slowly is that every startup repeats loading, verifying and linking classes. JEP 483 in JDK 24 introduced the AOT cache, which saves those results to a file and reuses them on later runs. Java 25 improves it in two ways.

  • JEP 514 (command-line ergonomics): JDK 24 needed two runs, one to record (-XX:AOTMode=record) and one to create (-XX:AOTMode=create). In Java 25, a single run with -XX:AOTCacheOutput produces the cache.
  • JEP 515 (method profiling): the cache also stores the profiles collected during the training run, that is, which methods are called often. In production, the JIT compiler can use those profiles from the start and reaches peak performance sooner.
flowchart LR
    A["(1) Training run<br/>-XX:AOTCacheOutput=app.aot"] --> B["app.aot<br/>classes + profiles"]
    B --> C["(2) Production<br/>-XX:AOTCache=app.aot"]

We tried it with a small application that parses CSV, aggregates, formats dates, builds an HTTP request and uses virtual threads. The AOT cache does not accept directories on the class path, so we build a JAR first.

// 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 {
        // A tiny "app": parse CSV, aggregate, format dates, build an HTTP request, use virtual threads
        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());

        // Time from the start of the JVM process to this point
        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

The training run left a cache file app.aot of about 22 MB. We then ran the app 15 times without and 15 times with the cache, alternating, and compared the time from JVM start to the end of the work.

Time from JVM start to finish (measured, median of 15 runs, axis in s)

%%{init: {"gantt": {"leftPadding": 140}}}%%
gantt
    dateFormat x
    axisFormat %S.%L
    tickInterval 100millisecond
    section No cache
    279 ms :crit, 0, 279
    section AOT cache
    169 ms :active, 0, 169

With the cache, the time from startup to finish was about 40% shorter, without changing any code. Applications that load many classes at startup, such as Spring Boot apps, gain even more.

A cache only works with the same JDK and the same class path it was created with. If you replace a JAR, you need to create it again. When the conditions do not match, the JVM prints a warning and starts without the cache, so the application still runs.

Since then: JEP 516 in JDK 26 lets any GC, including ZGC, cache objects (previously limited to some GCs). Project Leyden keeps working on shorter startup and warm-up.

Generational Shenandoah (JEP 521)

Shenandoah is a GC that keeps the time the application is stopped (pause time) extremely short. Its generational mode, introduced as experimental in JDK 24, is a standard feature in Java 25. It is the same idea as ZGC becoming generational in Java 21.

Based on the rule of thumb that most objects die young (the generational hypothesis), it splits the heap into a young and an old generation and collects the young one often. Without generations, every GC cycle has to walk the whole heap, including long-lived objects.

To bring out the difference in the old generation, we first created about 600 MB of data that lives until the end, then 30 million short-lived 1 KB objects.

// 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) {
        // Keep only the latest 50,000 objects (short-lived ones that soon become garbage)
        byte[][] recent = new byte[50_000][];
        // First create about 600 MB of data that lives until the end (think of a cache)
        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

Here are the medians of 5 runs of each mode.

Elapsed time and total GC cycle time, median of 5 runs (measured, axis in s)

%%{init: {"gantt": {"leftPadding": 140}}}%%
gantt
    dateFormat x
    axisFormat %S.%L
    tickInterval 2second
    section Gen. elapsed
    4842 ms :active, 0, 4842
    section Gen. GC
    1072 ms :active, 0, 1072
    section Non-gen. elapsed
    8978 ms :crit, 0, 8978
    section Non-gen. GC
    4491 ms :crit, 0, 4491

In generational mode, the total time of concurrent GC work (Cycles) dropped to about a quarter, and the whole run took almost half as long. It no longer walks the long-lived 600 MB every cycle. Pauses stayed very short in both modes, about 20 ms in total or less.

The measurements varied a lot. Under the same conditions, generational mode took 4.5-6.0 s and non-generational mode 8.3-13.1 s. Also, with little long-lived data (about 60 MB in our first attempt), the two modes were almost the same. Measure with your own application before deciding whether to go generational.

JFR (JDK Flight Recorder)

Java 25 has three JFR JEPs. JFR is the low-overhead profiler and event recorder built into the JVM.

JFR Method Timing & Tracing (JEP 520)

You can now time how often a method is called and how long it takes without changing code. The JVM injects the timing code into the bytecode automatically.

// 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 {

    // The method we want to time; the code itself contains no timing logic at all
    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

It times not only our own parse but also a JDK method (Integer.parseInt). Besides ClassName::methodName, a filter can be a class name alone (all its methods) or an annotation such as @jakarta.ws.rs.GET. With method-trace, JFR also records a stack trace for each call.

Before, you had to add logging and redeploy, or use a Java agent. This is a powerful tool for checking "this method seems slow" in production. However, instrumented methods get slower: in this sample, adding Integer.parseInt to the filter raised the average time of parse from about 0.06 ms to about 0.12 ms. Keep the filter narrow.

JFR CPU-Time Profiling (JEP 509, experimental)

JFR's usual sampling takes the stacks of running threads at fixed intervals, which cannot capture time spent in native code accurately. JEP 509 uses the Linux kernel's CPU timers to sample in proportion to the CPU time actually used. It is an experimental, Linux-only feature, so we did not try it on the macOS environment of this article. On Linux you use it like this:

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's stack sampling was redesigned so that each thread records its own stack when it reaches a safe point (a safepoint). Sampling becomes more stable and more accurate. It is an internal JVM improvement, so there is nothing to do on your side.

32-bit x86 port removed (JEP 503)

The source code and build support for 32-bit x86 were removed from the JDK. They had been deprecated in JDK 24 (JEP 501). The Windows version had already been removed in JDK 24 (JEP 479), so 32-bit x86 support is now completely gone. New features such as virtual threads no longer have to be implemented for 32-bit.

Summary: what to use in a Java 25 project

Finally, here is a summary from the point of view of existing projects.

CategoryFeatureVerdict
Use nowCompact Source Files, Module Import, Flexible Constructor, Scoped Values, KDF APIStandard features. Use them in new code
Try the optionsCompact Object Headers, AOT cache, Generational ShenandoahThey work without code changes. Measure, then decide
Use for monitoringJFR Method Timing & TracingFor checking "this method is slow" in production
WaitStructured Concurrency, Stable Values, PEM, Primitive Patterns, Vector APIPreviews or incubators. The APIs are still changing (Stable Values has already been renamed)

When moving from Java 21, the three runtime features deserve the most attention. Compact object headers cut the heap by a third, and the AOT cache made startup about 40% faster. Both gains came without changing a single line of application code. Start by changing JVM options and measuring.

References

© 2020 - 2026 issyuu @Lakeheart Retreat
Powered by theme astro-koharu · Inspired by Shoka
Loading...