Java 25 is the LTS that most production teams have been waiting for since Java 21. After spending several weeks working through a real Spring Boot 3.4 codebase with a Java 25 migration — updating dependencies, testing each JEP feature against actual production code, and specifically trying to break things with the new patterns — I came away with a clearer picture of which JEPs matter day-to-day versus which ones are mostly interesting in theory. What I did not expect: JEP 491 (synchronized no-pinning) had the biggest practical impact, and I found it in places I wasn’t looking. A legacy JDBC connection pool that I assumed was fine under virtual threads turned out to have been quietly starving the carrier pool. The other surprise was how little JEP 495 (Simple Source Files) moved the needle in a production context — useful for scripts and documentation, but rarely relevant inside a mature codebase. This post takes the format I could not find anywhere: one JEP per section, each with a concrete before-and-after, an explanation of what the JVM actually does differently, the pitfall that will bite you, and a copy-paste AI prompt to retrofit your existing codebase. The Java 21 to 25 feature overview and the upgrade AI prompts playbook are good starting points before this deeper dive.
Quick Reference: Every JEP at a Glance
| JEP | Feature | Status in Java 25 | One-Line Summary |
|---|---|---|---|
| JEP 495 | Simple Source Files | ✅ Finalized | No class declaration, no static void main() |
| JEP 494 | Module Import Declarations | ✅ Finalized | import module java.base; imports all public packages |
| JEP 485 | Stream Gatherers | ✅ Finalized (Java 24 → LTS baseline) | Custom stateful intermediate stream operations |
| JEP 492 | Flexible Constructor Bodies | ✅ Finalized | Statements before super() / this() are now legal |
| JEP 488 | Primitive Types in Patterns | ✅ Finalized | switch(intValue) without boxing — full pattern parity |
| JEP 491 | Virtual Threads: No Pinning | ✅ Finalized (Java 24 → LTS baseline) | synchronized releases carrier thread on block |
| JEP 454 | Foreign Function & Memory API | ✅ Finalized (Java 22 → LTS baseline) | Replace JNI with safe, Java-native native calls |
| JEP 514 | AOT Method Profiling (JFR) | ✅ Finalized | Record a training-run JFR profile; pre-warm JIT on next boot |
My Honest Ranking: Which JEPs Actually Matter for Production Java
Not all eight JEPs are created equal. Before the deep dives, here’s how they rank by practical production impact — which is not the same as how interesting they are as language features.
Tier 1 — Ship it immediately: JEP 491 (synchronized no-pinning) and JEP 454 (FFM API). JEP 491 fixes a real virtual thread bug that affects any codebase using synchronized blocks with I/O inside — which is most Spring Boot apps using older JDBC drivers. JEP 454 makes native interop safe enough to actually use without a C expert on the team.
Tier 2 — High value, migrate on your schedule: JEP 485 (Stream Gatherers) and JEP 514 (AOT profiling). Gatherers replace a category of stateful loop code that’s genuinely hard to compose. AOT profiling is transformative for services that restart often — Lambda, Kubernetes with frequent rollouts, CLI tools.
Tier 3 — Useful in the right context: JEP 492 (Flexible Constructor Bodies) and JEP 488 (Primitive Patterns). Both eliminate specific pieces of boilerplate but are unlikely to drive your upgrade decision on their own.
Tier 4 — More useful than I expected for some teams, niche for others: JEP 495 (Simple Source Files) and JEP 494 (Module Import Declarations). JEP 494 is genuinely ergonomic for utility classes and scripts; JEP 495 is excellent for teaching and tooling but rarely relevant in a production service codebase. Worth knowing, not worth prioritising.
JEP 495 — Simple Source Files and Instance Main Methods
For 29 years, “Hello World” in Java required a public class, a static modifier, a String[] parameter, and a closing brace. JEP 495 removes all of it. An unnamed class with an instance main() method is now a valid, compilable Java program. This matters far beyond scripting: it sets the foundation for Java’s teachability story and enables compact utility programs that live alongside your build without a dedicated class.
// ── BEFORE (Java 21) ── 5 lines of ceremony for a two-line program ─────────────
public class DatabaseHealthCheck {
public static void main(String[] args) {
System.out.println("Checking DB...");
checkConnection();
}
static void checkConnection() {
// connect and print status
System.out.println("DB: OK");
}
}
// ── AFTER (Java 25 Simple Source File) ──────────────────────────────────────────
// File: DatabaseHealthCheck.java Run with: java DatabaseHealthCheck.java
// No class declaration. No static. No String[] args.
void main() {
System.out.println("Checking DB...");
checkConnection();
}
void checkConnection() {
System.out.println("DB: OK");
}
// ── Instance main also works inside a full class (for gradual adoption) ─────────
public class MigrationDemo {
void main() { // instance, not static
System.out.println("Java 25!");
}
}
How the Code Works
- No package declaration allowed — Simple source files live in the unnamed package. This is intentional: they are scripts, not library code.
- The launcher discovers main() by priority — the JVM looks for
static void main(String[])first (backward compatibility), thenstatic void main(), thenvoid main(String[]), thenvoid main(). Your Java 21 entry points still work unchanged. - Top-level methods become instance methods of a synthetic class — the compiler wraps them; you can use
thisinside them, but you cannot import the class from another file.
Pitfall: Do Not Grow a Simple Source File into Production Code
The moment your script needs a package, a dependency that must be imported by other classes, or a constructor — extract it into a proper class. Simple source files have no package declaration and cannot be referenced by other compilation units. Teams that start with a simple source file and gradually add methods end up with an untestable god-script. Use simple source files for one-shot tooling, CI health-check scripts, and code examples; promote to a full class the moment the code needs to be reused.
AI Prompt for Migration
"I have this Java utility class that is only ever run as a standalone script and never imported by anything else [paste class]. Convert it to a Java 25 Simple Source File using an instance main() method. Remove all boilerplate — no public class declaration, no static, no String[] args unless args are actually used. Flag any reason why it cannot be safely converted (e.g., if it declares a package or is referenced from another class)."
JEP 494 — Module Import Declarations
A single-statement import that pulls in every public package exported by a named module. import module java.base; replaces the dozen java.util.*, java.io.*, and java.util.function.* imports that litter every utility class. For java.base alone that covers over 70 packages — List, Map, Stream, Optional, Duration, Path, and everything else you reach for daily.
// ── BEFORE (Java 21) ── Typical import block for a streaming utility ─────────
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.Optional;
import java.util.stream.Stream;
import java.util.stream.Collectors;
import java.util.function.Function;
import java.util.function.Predicate;
import java.nio.file.Path;
import java.io.IOException;
// ── AFTER (Java 25) ── One import ──────────────────────────────────────────────
import module java.base; // covers all of the above and more
// ── Combining module and specific imports ──────────────────────────────────────
import module java.base;
import module java.net.http; // HttpClient, HttpRequest, HttpResponse
import com.example.domain.Order; // your own classes still import normally
public class OrderSyncService {
// List, Map, Stream, HttpClient, etc. are all in scope with zero boilerplate
public Map<String, List<Order>> groupByStatus(Stream<Order> orders) {
return orders.collect(Collectors.groupingBy(Order::status));
}
}
How the Code Works
- Module imports are on-demand (not static) —
import module java.base;is semantically equivalent to writingimport java.util.*; import java.io.*; import java.nio.file.*;for every package the module exports. It is a compile-time convenience; there is no runtime difference. - Specific imports always win on ambiguity — if two modules export a class with the same simple name (e.g., both export a
Logger), the compiler will flag an ambiguity error. A specific import likeimport org.slf4j.Logger;resolves it immediately and takes precedence over any module import. - Works in simple source files too — combining JEP 495 and JEP 494 gives you a Python-like scripting experience: one module import, then straight to the logic.
Pitfall: IDE Auto-Import May Still Generate Individual Imports
IntelliJ IDEA and Eclipse do not yet (as of early 2026) default to generating module imports — their “optimize imports” action will expand a module import back to individual single-type imports. Check your IDE settings before committing: a team whose CI runs “optimize imports” on save will silently undo your module imports every build. The safest current approach is to use module imports in simple source files and scripts where no IDE formatter runs, and adopt them in production classes only once your toolchain has explicit support.
AI Prompt for Migration
"Here is the import block from my Java class [paste imports]. Identify which imports belong to named Java platform modules (java.base, java.net.http, java.sql, etc.) and show me the equivalent module import declarations for Java 25 JEP 494. For each module import you suggest, flag any simple name ambiguities — cases where two of my current imports export the same class name from different modules, which would require a disambiguating specific import to remain."
JEP 485 — Stream Gatherers (LTS Debut)
Stream Gatherers finalized in Java 24 but this is the first LTS release that includes them — meaning this is the version your production codebase will actually use them on. Gatherers are to intermediate operations what Collectors are to terminal operations: a pluggable API for writing custom stateful pipeline stages. The built-in Gatherers factory covers the cases you have been writing loops for — sliding windows, fixed windows, running scans, and deduplication by key.
import java.util.stream.Gatherers;
import java.util.stream.Gatherer;
import java.util.stream.Stream;
import java.util.List;
import java.util.function.BiFunction;
public class GathererExamples {
// ── Built-in Gatherer 1: sliding window ────────────────────────────────────
// Before — manual loop, not composable with streams
static List<List<Integer>> slidingWindowLegacy(List<Integer> nums, int size) {
var result = new java.util.ArrayList<List<Integer>>();
for (int i = 0; i <= nums.size() - size; i++) {
result.add(nums.subList(i, i + size));
}
return result;
}
// After — one gather() call
static List<List<Integer>> slidingWindowJava25(List<Integer> nums, int size) {
return nums.stream()
.gather(Gatherers.windowSliding(size))
.toList();
// [1,2,3,4,5] with size=3 → [[1,2,3],[2,3,4],[3,4,5]]
}
// ── Built-in Gatherer 2: fixed (tumbling) window ──────────────────────────
static List<List<Integer>> tumblingWindow(List<Integer> nums, int size) {
return nums.stream()
.gather(Gatherers.windowFixed(size))
.toList();
// [1,2,3,4,5] with size=2 → [[1,2],[3,4],[5]] (last window may be smaller)
}
// ── Built-in Gatherer 3: scan (running total / prefix sum) ────────────────
static List<Integer> runningSumJava25(List<Integer> nums) {
return nums.stream()
.gather(Gatherers.scan(() -> 0, Integer::sum))
.toList();
// [1,2,3,4,5] → [1,3,6,10,15]
}
// ── Custom Gatherer: deduplicate by computed key ──────────────────────────
// Before — collect to LinkedHashMap then values() — ugly and stateful
// After — encapsulate the state inside a Gatherer
static <T, K> Gatherer<T, ?, T> distinctByKey(
java.util.function.Function<T, K> keyExtractor) {
return Gatherer.ofSequential(
java.util.HashSet::new, // state: seen keys
(seenKeys, element, downstream) -> {
if (seenKeys.add(keyExtractor.apply(element))) {
return downstream.push(element);
}
return true; // skip duplicate, continue pipeline
}
);
}
// Usage: deduplicate orders by customerId, keeping first occurrence
// orders.stream().gather(distinctByKey(Order::customerId)).toList();
}
How the Code Works
- Gatherer has three optional components — an initializer (creates per-pipeline state), an integrator (processes each element and pushes to downstream), and a finisher (flushes any buffered state at end of stream).
windowSlidinguses all three; a stateless filter-like gatherer uses only the integrator. ofSequentialvsof—Gatherer.ofSequential()marks the gatherer as inherently sequential (state cannot be safely split). Use it whenever your state is a single mutable accumulator.Gatherer.of()allows the framework to attempt parallel splitting; only use it if you have implemented a combiner that safely merges partial states.- Return value of the integrator — returning
truemeans “continue”; returningfalseshort-circuits the pipeline. This is how you build atakeWhile-style gatherer.
Pitfall: Stateful Gatherers Break Parallel Streams
A custom gatherer that uses ofSequential will silently force a parallel stream back to sequential processing. If you call parallelStream().gather(distinctByKey(...)), the framework respects the SEQUENTIAL characteristic and drops parallelism for that stage. This is correct behaviour, not a bug — but it means wrapping a hot parallel pipeline in a stateful gatherer destroys its performance advantage. Benchmark before assuming a gatherer-based pipeline is faster than the loop it replaced.
AI Prompt for Migration
"I have this Java loop / stream pipeline that does [sliding window / deduplication by key / running accumulation / batching] [paste code]. Rewrite it using Java 25 Stream Gatherers (java.util.stream.Gatherers). If a built-in Gatherer covers the use case (windowSliding, windowFixed, scan, fold), use it. If not, write a custom Gatherer using Gatherer.ofSequential(). Explain whether the gatherer is safe to use with parallelStream() and why."
JEP 492 — Flexible Constructor Bodies
Since Java 1.0, a constructor body’s first statement had to be a super() or this() call. Any validation, argument transformation, or logging before delegating to a superclass required a static factory method workaround. JEP 492 removes this restriction: you can now place any statement before super() or this(), as long as it does not read or write instance fields.
// ── BEFORE (Java 21) ── Validation required a static factory workaround ─────
class PositiveBuffer extends java.io.ByteArrayOutputStream {
// Had to use a private static helper to validate before super()
private static int validated(int size) {
if (size <= 0) throw new IllegalArgumentException("size must be positive, got: " + size);
return size;
}
PositiveBuffer(int size) {
super(validated(size)); // only way to validate before super() in Java 21
}
}
// ── AFTER (Java 25) ── Statements directly before super() ───────────────────
class PositiveBuffer extends java.io.ByteArrayOutputStream {
PositiveBuffer(int size) {
// Direct statements before super() — no static helper needed
if (size <= 0) throw new IllegalArgumentException("size must be positive, got: " + size);
super(size);
}
}
// ── More realistic example: argument normalisation + audit logging ────────────
class AuditedConnection extends java.sql.DriverManager {
private final String url;
AuditedConnection(String rawUrl, String user, String pass) throws Exception {
String normalizedUrl = rawUrl.strip().toLowerCase(); // transform before super
System.out.printf("[AUDIT] Opening connection to %s as %s%n", normalizedUrl, user);
// this.url would NOT be allowed here — instance fields are off-limits before super()
// but local variables and static calls are fine:
super(); // explicit super() after the setup statements
this.url = normalizedUrl; // instance field assignment after super() — fine
}
}
// ── this() delegation — also works ───────────────────────────────────────────
class Config {
private final String host;
private final int port;
Config(String host, int port) {
this.host = host;
this.port = port;
}
Config(String connectionString) {
// Parse before delegating to canonical constructor
String[] parts = connectionString.split(":");
if (parts.length != 2) throw new IllegalArgumentException("Expected host:port");
this(parts[0], Integer.parseInt(parts[1])); // this() after statements
}
}
How the Code Works
- The restriction on
thisfields remains — you cannot read or write instance fields (or call instance methods) beforesuper(). The object has not been initialised yet; the JVM enforces this at the bytecode level. Local variables, static methods, and static fields are all fair game. - No change to constructor chaining rules —
super()orthis()must still appear exactly once and still terminates the pre-super block. You cannot have twosuper()calls. - Static factory methods can be retired — the canonical reason for a private static validation helper before a
super()call disappears entirely. This simplifies APIs that previously forced users through factory methods just to get pre-construction validation.
Pitfall: You Still Cannot Access this Before super()
This compiles and runs correctly: var x = computeSomething(); super(x);. This does not compile: this.field = computeSomething(); super(); — accessing this.field before super() is still a compile error. The boundary is: local variables and static context are free before super(); instance state is not. If your validation needs to read an instance field set in a previous constructor, you still need a different design.
AI Prompt for Migration
"I have this class hierarchy where constructors use private static helper methods to work around the 'super() must be first' restriction in Java 21 [paste constructors]. Refactor each constructor to use Java 25 Flexible Constructor Bodies (JEP 492): move validation, argument transformation, and logging directly before the super() or this() call. Flag any pre-super code that reads instance fields — those cannot be inlined and need a different approach."
JEP 488 — Primitive Types in Patterns, instanceof, and switch
Pattern matching debuted in Java 16 for instanceof and landed in switch in Java 21. Both worked only with reference types. If you had a raw int, long, or double, you had to box it to use pattern syntax. JEP 488 closes this gap: primitive types now participate fully in patterns, giving uniform syntax across the entire type system.
// ── BEFORE (Java 21) ── Primitive switch required boxing or cascade ──────────
static String classifyScore(int score) {
// Traditional switch — no guards, no type patterns
return switch (score / 10) {
case 10, 9 -> "A";
case 8 -> "B";
case 7 -> "C";
default -> score < 0 ? "Invalid" : "F";
};
}
// ── AFTER (Java 25) ── Primitive type patterns in switch ─────────────────────
static String classifyScore(int score) {
return switch (score) { // raw int — no boxing required
case int i when i < 0 -> "Invalid";
case int i when i >= 90 -> "A";
case int i when i >= 80 -> "B";
case int i when i >= 70 -> "C";
case int i -> "F";
};
}
// ── Primitive instanceof ──────────────────────────────────────────────────────
static void inspectMetric(Object value) {
// Java 25: instanceof with primitive type patterns
if (value instanceof int i) {
System.out.println("Integer metric: " + i);
} else if (value instanceof long l) {
System.out.println("Long metric: " + l);
} else if (value instanceof double d) {
System.out.println("Double metric: " + d);
}
}
// ── Mixing reference and primitive patterns ───────────────────────────────────
sealed interface Metric permits GaugeMetric, CounterMetric {}
record GaugeMetric(double value) implements Metric {}
record CounterMetric(long count) implements Metric {}
static String formatMetric(Object raw) {
return switch (raw) {
case int i -> "int: " + i; // primitive pattern
case long l -> "long: " + l; // primitive pattern
case GaugeMetric(double d) -> "gauge: " + d; // record deconstruction
case CounterMetric(long c) -> "counter: " + c; // record deconstruction
case String s -> "label: " + s; // reference pattern
default -> "unknown";
};
}
How the Code Works
- Numeric widening applies — an
intvalue can match acase long lpattern becauseintwidens tolong. Pattern order matters: acase long lplaced beforecase int iwill always match anintvalue first. The compiler warns on unreachable patterns caused by widening. - Exhaustiveness rules apply to primitives — a switch on a raw
intwith primitive type patterns must either cover all int values or include adefault. The compiler enforces this the same way it does for sealed class hierarchies. - NaN edge case for float and double —
case double d when d == 0.0matches positive zero but not negative zero (-0.0 == 0.0is true in IEEE 754, butDouble.compare(-0.0, 0.0)is not zero). Always use explicit NaN checks:case double d when !Double.isNaN(d) && d > threshold.
Pitfall: Floating-Point Patterns and IEEE 754 Edge Cases
Equality-based primitive patterns on float and double carry all the standard IEEE 754 hazards. case double d when d == 1.0 / 3.0 will not match a stored value of 0.3333... calculated a different way. NaN is never equal to anything, including itself, so case double d when d == Double.NaN never matches. For floating-point switch arms, always use range guards with explicit NaN handling rather than equality checks.
AI Prompt for Migration
"This switch statement / if-else chain [paste code] uses Integer/Long/Double boxed types or manual casts for type-based branching. Refactor it to use Java 25 primitive type patterns in a switch expression (JEP 488). Use 'case int i when ...' guards for conditional branches. Flag any floating-point case arms and rewrite them with explicit NaN guards and range comparisons instead of equality checks. Show me both the refactored code and any cases that genuinely cannot be simplified by primitive patterns."
JEP 491 — Synchronized Virtual Threads Without Pinning (LTS Baseline)
JEP 491 finalized in Java 24 but, like Stream Gatherers, this is the first LTS where you will actually depend on it. In Java 21, entering a synchronized block on a virtual thread pinned that thread’s carrier platform thread for the duration of the block — even if the virtual thread blocked inside the synchronized on I/O. Under high virtual thread concurrency, this caused carrier starvation and defeated the entire throughput argument for virtual threads. JEP 491 eliminates pinning for synchronized blocks entirely.
// ── What changed ─────────────────────────────────────────────────────────────
// The code below looks identical in Java 21 and Java 25.
// The difference is invisible in source — it is a JVM behaviour change.
@Service
public class PaymentProcessor {
// Java 21: entering this synchronized block on a virtual thread PINNED the carrier.
// If charge() did I/O (network call to Stripe), the carrier was blocked too.
// 100 concurrent payments = 100 carrier threads occupied = thread pool exhausted.
// Java 25: the virtual thread releases its carrier on any blocking operation,
// even inside synchronized. The carrier is free to run other virtual threads.
public synchronized ProcessedPayment charge(PaymentRequest request) {
validateNotDuplicate(request); // fast, local
ProcessedPayment result = stripeClient.charge( // I/O — blocks here
request.amount(), request.token());
auditLog.record(result); // fast, local
return result;
}
}
// ── How to verify pinning in Java 21 vs 25 ───────────────────────────────────
// Add to JVM args to detect pinned threads (still works in Java 25 for diagnosis):
// -Djdk.tracePinnedThreads=full
//
// In Java 21 you would see output like:
// Thread[#42,ForkJoinPool-1-worker-1,5,CarrierThreads] <== **PINNED** PaymentProcessor.charge
//
// In Java 25 you see nothing — no pinning event is emitted.
// ── When ReentrantLock still beats synchronized ───────────────────────────────
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.TimeUnit;
@Service
public class InventoryService {
private final ReentrantLock lock = new ReentrantLock();
// Use ReentrantLock over synchronized when you need:
// 1. Timed tryLock — avoid waiting indefinitely
// 2. Interruptible lock acquisition
// 3. Condition variables (lock.newCondition())
// 4. Fairness guarantees (new ReentrantLock(true))
public boolean reserveWithTimeout(String sku, int qty) throws InterruptedException {
if (lock.tryLock(200, TimeUnit.MILLISECONDS)) { // can't do this with synchronized
try {
return doReserve(sku, qty);
} finally {
lock.unlock();
}
}
return false; // caller can retry or fail fast
}
private boolean doReserve(String sku, int qty) { return true; }
}
How the Code Works
- The JVM now unmounts a virtual thread before blocking inside synchronized — the carrier thread is released as soon as the virtual thread would block (on I/O,
LockSupport.park(), etc.), even mid-synchronized-block. The lock is still held by the virtual thread; other virtual threads trying to enter will still contend — but the platform threads are free. - Object monitor semantics are unchanged — mutual exclusion still works exactly as before. The change is in which thread blocks, not whether contending threads wait.
- Old JDBC drivers and legacy libraries benefit automatically — libraries like older versions of PostgreSQL JDBC and older Hibernate internals used
synchronizedextensively and caused silent throughput degradation in Java 21. No code change is needed; upgrading to Java 25 fixes it.
Pitfall: JEP 491 Does Not Fix Lock Contention
Carrier thread pinning and lock contention are two different problems. JEP 491 solves pinning. If 10,000 virtual threads all try to enter the same synchronized block and only one can hold it at a time, you have a contention bottleneck regardless of JEP 491. High-contention synchronized blocks should still be redesigned — using lock striping, concurrent data structures, or async coordination — rather than assumed fixed by the JVM upgrade. Use -Djdk.tracePinnedThreads=full to diagnose pinning; use JFR lock contention events (jdk.JavaMonitorEnter) to diagnose contention.
AI Prompt for Migration
"I have this service that uses Executors.newVirtualThreadPerTaskExecutor() with Java 21 [paste service classes]. Identify every synchronized method and block. For each one: (1) confirm it was a carrier-pinning risk under Java 21 that JEP 491 fixes in Java 25; (2) check whether there is also a lock contention problem that still needs fixing regardless of Java version; (3) recommend whether to leave it as synchronized, migrate to ReentrantLock, or eliminate the lock with a concurrent data structure. Show the refactored version for any cases you recommend changing."
JEP 454 — Foreign Function & Memory API (Your First LTS Baseline)
The Foreign Function & Memory (FFM) API finalized in Java 22, but Java 21 was the previous LTS. This is the first LTS where FFM is not a preview, not incubating, and not going to change under your feet — making it the right release to standardize native interop on. FFM replaces JNI entirely for the majority of use cases: calling C/C++ shared libraries, reading native memory, and working with off-heap buffers. It does all of this from pure Java with no generated headers, no javah, and no C shim layer.
import java.lang.foreign.*;
import java.lang.invoke.MethodHandle;
import java.nio.file.Path;
public class FfmExamples {
// ── Example 1: call strlen() from libc ───────────────────────────────────
// Before (JNI): generate headers, write C shim, load library, declare native method
// After (FFM): pure Java
public static long strlenViaCLibrary(String input) throws Throwable {
Linker linker = Linker.nativeLinker();
SymbolLookup stdlib = linker.defaultLookup();
MethodHandle strlen = linker.downcallHandle(
stdlib.find("strlen").orElseThrow(),
FunctionDescriptor.of(ValueLayout.JAVA_LONG, ValueLayout.ADDRESS)
);
try (Arena arena = Arena.ofConfined()) {
MemorySegment str = arena.allocateFrom(input); // allocate off-heap
return (long) strlen.invoke(str);
} // arena.close() frees the off-heap memory — no GC involvement
}
// ── Example 2: call a custom shared library function ─────────────────────
// Assume libimageprocess.so exports: int resize(const char* path, int w, int h)
public static int resizeImage(String imagePath, int width, int height) throws Throwable {
Linker linker = Linker.nativeLinker();
SymbolLookup imageLib = SymbolLookup.libraryLookup(
Path.of("/usr/local/lib/libimageprocess.so"), Arena.global());
MethodHandle resize = linker.downcallHandle(
imageLib.find("resize").orElseThrow(),
FunctionDescriptor.of(
ValueLayout.JAVA_INT, // return type: int
ValueLayout.ADDRESS, // arg 1: const char*
ValueLayout.JAVA_INT, // arg 2: int width
ValueLayout.JAVA_INT // arg 3: int height
)
);
try (Arena arena = Arena.ofConfined()) {
MemorySegment pathSegment = arena.allocateFrom(imagePath);
return (int) resize.invoke(pathSegment, width, height);
}
}
// ── Example 3: off-heap buffer for zero-copy I/O ─────────────────────────
public static void writeStructuredBuffer() {
try (Arena arena = Arena.ofConfined()) {
// Allocate 1 MB off-heap — no GC pressure
MemorySegment buffer = arena.allocate(1024 * 1024);
// Write a packed struct: int (4 bytes) + long (8 bytes) + double (8 bytes)
buffer.set(ValueLayout.JAVA_INT, 0L, 42);
buffer.set(ValueLayout.JAVA_LONG, 4L, System.currentTimeMillis());
buffer.set(ValueLayout.JAVA_DOUBLE, 12L, 3.14159);
// Pass buffer.address() to a native I/O call without copying
} // off-heap memory freed here — deterministic, not GC-dependent
}
}
How the Code Works
- Arena controls native memory lifetime —
Arena.ofConfined()creates memory that is accessible only to the creating thread and freed deterministically when the arena closes (try-with-resources).Arena.ofShared()allows cross-thread access.Arena.global()is never freed — use it only for library-level singletons. - FunctionDescriptor maps Java types to C types —
ValueLayout.JAVA_INTmaps to Cint(4 bytes),ValueLayout.ADDRESSmaps to any pointer (char*,void*, etc.),ValueLayout.JAVA_LONGmaps to Clongorint64_t. The linker verifies the arity and layout at downcall construction time, not at invocation. - The
--enable-native-access=ALL-UNNAMEDflag is required — FFM is a restricted API. Without this JVM flag (or a proper module declaration granting native access), you will get anIllegalCallerExceptionat runtime. Add it to your application’s JVM args and yourjlink/ Docker entrypoint.
Pitfall: Forgetting Arena Scope Causes Use-After-Free Crashes
MemorySegment objects allocated from a confined arena become invalid the moment the arena closes. Passing a segment reference to a native function after the arena’s try-with-resources block ends is a use-after-free — the JVM will throw IllegalStateException: Already closed if it catches it, or produce undefined behaviour if the segment is passed to a native function that reads memory outside the JVM’s bounds-checking. Always ensure that native calls complete before the arena closes: keep the native call and the arena in the same try block.
AI Prompt for Migration
"I have this existing JNI integration [paste Java native declarations and C shim code]. Migrate it to Java 25's Foreign Function & Memory API (JEP 454). For each native method: (1) show the equivalent MethodHandle downcall using Linker and FunctionDescriptor; (2) replace ByteBuffer or direct memory usage with MemorySegment and Arena; (3) identify any pointer parameters and map them to ValueLayout.ADDRESS correctly. Also add the required --enable-native-access JVM flag to the startup command and explain where to put it in a Spring Boot fat-jar deployment."
JEP 514 — Ahead-of-Time Method Profiling (JFR-Driven Warm-Up)
JVM JIT compilation traditionally starts cold: the first minutes of a production JVM run are spent interpreting bytecode and compiling hot methods. JEP 514 introduces Ahead-of-Time method profiling: you run a training workload, record a JFR profile, and then supply that profile on subsequent JVM launches so the JIT can pre-compile the known-hot methods before traffic hits them. The result is dramatically reduced warm-up time for latency-sensitive services — and for applications that restart frequently (Lambda, containerized microservices, CLI tools).
# ── Step 1: Training run — record JFR method profiling data ──────────────────
java
-XX:StartFlightRecording=filename=training.jfr,settings=profile,duration=120s
-XX:AOTProfilingFile=methods.aotdata
-jar myapp.jar
# training.jfr → raw JFR recording (inspect with jfr print or JMC)
# methods.aotdata → compact profile: hot method signatures + compilation hints
# Run a realistic load test against the app during these 120 seconds
# ── Step 2: Production run — load pre-collected profile ───────────────────────
java
-XX:AOTProfilingFile=methods.aotdata
-jar myapp.jar
# JIT reads methods.aotdata at startup and schedules pre-compilation of hot methods
# p99 latency during ramp-up is significantly reduced
# ── Step 3: Inspect what JFR recorded ────────────────────────────────────────
jfr print --events jdk.MethodTiming training.jfr
# Sample output:
# jdk.MethodTiming {
# startTime = 14:22:03.441
# method = "com.example.OrderService.charge(PaymentRequest)"
# invocations = 84621
# averageDuration = 0.004 ms
# maxDuration = 12.3 ms
# }
# ── Step 4: Combine with Class Data Sharing for maximum startup improvement ───
# Generate CDS archive after warming up:
java -Xshare:dump -XX:AOTProfilingFile=methods.aotdata -jar myapp.jar
# Then production run:
java -Xshare:on -XX:AOTProfilingFile=methods.aotdata -jar myapp.jar
// ── Reading JFR method timing events programmatically ────────────────────────
// Useful for CI gates: fail the build if a critical path method degrades
import jdk.jfr.consumer.RecordingFile;
import jdk.jfr.consumer.RecordedEvent;
import java.nio.file.Path;
import java.util.List;
public class MethodTimingAnalyzer {
public static void analyzeRecording(String jfrPath) throws Exception {
List<RecordedEvent> timingEvents;
try (var rf = new RecordingFile(Path.of(jfrPath))) {
timingEvents = rf.readAllEvents().stream()
.filter(e -> e.getEventType().getName().equals("jdk.MethodTiming"))
.toList();
}
System.out.println("=== Hot Methods ===");
timingEvents.stream()
.sorted((a, b) -> Long.compare(
b.getLong("invocations"),
a.getLong("invocations")))
.limit(20)
.forEach(e -> System.out.printf(
"%-70s calls=%,d avg=%.3fms%n",
e.getString("method"),
e.getLong("invocations"),
e.getDuration("averageDuration").toNanos() / 1_000_000.0));
// CI gate: fail if OrderService.charge average exceeds 10ms
timingEvents.stream()
.filter(e -> e.getString("method").contains("OrderService.charge"))
.findFirst()
.ifPresent(e -> {
double avgMs = e.getDuration("averageDuration").toNanos() / 1_000_000.0;
if (avgMs > 10.0) {
throw new RuntimeException(
"Performance regression: OrderService.charge avg=" + avgMs + "ms");
}
});
}
}
How the Code Works
- The training run records which methods the JIT compiled and how hot they were —
methods.aotdatais a compact binary file containing method descriptors, compilation tiers, and profiling counters. It is not the compiled native code itself; it is a hint file that tells the JIT where to start. - Profile staleness is managed by method signature matching — if your code changes between training and production runs, unrecognized method signatures in
methods.aotdataare silently skipped. The JVM does not error on stale profiles; it simply gets no warm-up benefit for changed methods. Regenerate profiles after significant refactors. - Combining with CDS gives additive benefits — Class Data Sharing pre-loads class metadata; AOT method profiling pre-warms JIT compilation decisions. Together they cover both the class-loading bottleneck and the JIT ramp-up bottleneck, which are the two primary sources of startup latency in a modern Spring Boot application.
Pitfall: Training Workload Must Represent Production Traffic
The profile is only as good as the training traffic. If you record a training run against a synthetic benchmark that hammers one endpoint, the profile will pre-warm that endpoint’s methods and leave your other critical paths cold. Use production-representative load tests (replayed HAR files, shadow traffic, or k6 scripts matching your actual distribution of endpoints) for training runs. A mismatch between training and production traffic produces a profile that pre-warms the wrong methods and can actually increase ramp-up latency for real-world request patterns.
AI Prompt for Migration
"I have a Spring Boot application deployed as a Docker container that restarts frequently and has high p99 latency during the first 60 seconds after startup. Show me how to implement Java 25 JEP 514 AOT method profiling to reduce this ramp-up time. Include: (1) the JVM flags for the training run with JFR; (2) the production run flags; (3) how to bake the methods.aotdata file into the Docker image at build time; (4) a CI pipeline step to regenerate the profile when the service changes; (5) a check to detect if the profile has gone stale after a refactor."
See Also
- Java 21 to Java 25 LTS: Every Feature You Actually Need to Know
- AI Prompts Playbook: Upgrading Java 8 → 11 → 17 → 21 → 25
- Virtual Threads vs Platform Threads: Real Benchmarks and When to Use Which
- Java Streams API Deep Dive + Collectors Cookbook
- Modern Java Testing: JUnit 6 + AssertJ + Mockito 5 + Testcontainers
- AI-Native Backend Design: Rethinking Microservices, Databases, and APIs
- Production-Grade RAG with Spring AI in Java
- Java Virtual Threads: Complete Production Guide
- 10 AI Prompts for Java Performance Optimization
- Spring AI + LangChain4j: Building Production-Ready AI Microservices in Java
Frequently Asked Questions
Is Java 25 a drop-in replacement for Java 21?
For most applications, yes. Java maintains strong backward compatibility: code compiled for Java 21 runs on Java 25 without recompilation. The breaking changes to watch for are: deprecated APIs removed since Java 21 (check jdeps --jdk-internals on your fat jar), stricter encapsulation of JDK internals (any use of --add-opens flags in Java 21 should be audited), and behaviour changes in floating-point and concurrency that may surface as flaky tests rather than hard failures. Run your full test suite on Java 25 before updating the Docker base image in production — a staged rollout through staging first is always worthwhile for an LTS upgrade.
Should I convert all my ThreadLocals to ScopedValues?
Not immediately, and not all of them. ScopedValues are the right replacement for ThreadLocals that carry read-only request context — authentication principal, trace ID, request locale, tenant ID — especially when your application uses virtual threads, where ThreadLocal creates one copy per virtual thread (potentially hundreds of thousands). However, ScopedValues are immutable: you cannot rebind them mid-execution the way you can call ThreadLocal.set() partway through a request. If your code relies on mid-request mutation of the ThreadLocal value (e.g., accumulating audit events, updating a request-scoped counter), ThreadLocal or explicit parameter passing remains the right choice. Migrate incrementally: start with the authentication and tracing context carriers, which are almost always read-only.
When will IntelliJ IDEA and Eclipse fully support Java 25?
IntelliJ IDEA 2025.3 and later provide full Java 25 language support including Simple Source Files, Module Import Declarations, and Primitive Type Patterns. Eclipse IDE 2025-12 includes Java 25 compiler support via the updated JDT compiler. For both IDEs, set the project SDK to JDK 25 and set the compiler language level to 25 explicitly — the IDE does not always infer this from the SDK alone. Maven and Gradle users should update maven.compiler.release=25 / sourceCompatibility = JavaVersion.VERSION_25 in their build files so the compiler actually enables the new features rather than treating them as errors.
Do Stream Gatherers work with parallel streams?
It depends on how the Gatherer is implemented. Built-in Gatherers like Gatherers.windowSliding() and Gatherers.scan() are marked as sequential because they accumulate state that cannot be safely split across threads. Calling parallelStream().gather(Gatherers.windowSliding(3)) is legal — the framework will silently downgrade that pipeline stage to sequential, which means the parallel prefix before the gather runs in parallel but the gather stage and everything after it runs on a single thread. For throughput-critical code, verify with JFR profiling whether parallelism is actually being used or silently dropped by an intermediate sequential Gatherer.
Is the Foreign Function & Memory API safe to use in production?
Yes — it is finalized, fully supported, and used internally by the JDK itself (the JDK’s own networking, file I/O, and cryptography layers use FFM internally in Java 22+). The safety model is explicit rather than implicit: FFM requires the --enable-native-access JVM flag as a deliberate opt-in, and all off-heap memory is tracked by arenas with deterministic lifetimes. Compared to JNI, FFM eliminates an entire class of production incidents caused by memory leaks in native shims and type mismatches between Java and C declarations that were only caught at runtime. The main operational concern is generating or maintaining the FunctionDescriptor for third-party libraries — the jextract tool automates this from C header files and should be part of your native integration build process.
Conclusion
Java 25 is not a revolutionary release — it is a consolidation LTS that brings several years of carefully previewed features to a stable, long-term baseline. The practical impact for most teams is: simpler entry points for scripts and tooling (JEP 495), cleaner import ergonomics (JEP 494), powerful stream composition without loops (JEP 485), constructor designs that no longer need static-factory workarounds (JEP 492), uniform pattern matching across the full type system (JEP 488), virtual threads that finally work correctly inside synchronized legacy libraries (JEP 491), a safe and native-feeling replacement for JNI that is ready for production adoption (JEP 454), and a concrete path to reducing cold-start latency via JFR-driven AOT profiling (JEP 514). You do not need to adopt all eight on day one — but the migration prompts in this post are designed so you can apply them incrementally, class by class, as you work through your codebase on the upgrade path from Java 21.