Java 21 to 25 New Features
After looking into the jump from Java 16 to 21, let’s continue with the next LTS: Java 25. Same idea as the previous post — big features you get when moving from Java 21 to Java 25, with a bit of runtime context too.
JEP 456: Unnamed Variables & Patterns
This is a small change that improves readability a lot. When a variable must be declared but is never used, we can now name it _.
Before, unused variables were noisy:
int count = 0;
for (Order order : orders) {
count++; // order is unused
}
try {
int number = Integer.parseInt(s);
} catch (NumberFormatException ex) {
return -1; // ex is unused
}
if (obj instanceof Point p) {
// only care that it is a Point
}
Now, we can simply write:
int count = 0;
for (Order _ : orders) {
count++;
}
try {
int number = Integer.parseInt(s);
} catch (NumberFormatException _) {
return -1;
}
if (obj instanceof Point(_)) {
// only care that it is a Point
}
It also works nicely with record patterns when you only care about some components:
if (obj instanceof Point(_, int y)) {
System.out.println("y = " + y);
}
JEP 454: Foreign Function & Memory API
The Foreign Function & Memory API is finally out of preview/incubator territory (finalized in Java 22).
Before, interoperating with native code usually meant JNI boilerplate, or unsafe off-heap tricks. Now we can allocate off-heap memory and call native functions with a much safer Java API.
Example allocating and writing off-heap memory:
try (Arena arena = Arena.ofConfined()) {
MemorySegment segment = arena.allocate(100);
segment.set(ValueLayout.JAVA_INT, 0, 42);
int value = segment.get(ValueLayout.JAVA_INT, 0);
System.out.println(value); // 42
}
Calling a native function (simplified):
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 cString = arena.allocateFrom("Hello");
long len = (long) strlen.invokeExact(cString);
System.out.println(len); // 5
}
This is one of those features that frameworks will use heavily, but it is also useful when you need precise native interop without drowning in JNI.
JEP 485: Stream Gatherers
Collectors were always the extensible terminal operation. Gatherers bring the same idea to intermediate operations.
Before, some transformations were awkward with the fixed Stream API. For example, grouping elements into fixed-size windows meant manual loops or custom collectors that felt unnatural as intermediate steps.
Now, we can do simply:
List<List<Integer>> windows =
Stream.of(1, 2, 3, 4, 5, 6, 7, 8, 9)
.gather(Gatherers.windowFixed(3))
.toList();
// [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
Sliding windows:
List<List<Integer>> sliding =
Stream.of(1, 2, 3, 4, 5)
.gather(Gatherers.windowSliding(2))
.toList();
// [[1, 2], [2, 3], [3, 4], [4, 5]]
There are built-in gatherers like fold, scan, windowFixed, and windowSliding, and you can also implement custom ones when needed.
JEP 513: Flexible Constructor Bodies
For years, the first statement in a constructor had to be super(...) or this(...). That often forced awkward validation after expensive superclass construction.
Before:
class Employee extends Person {
String officeID;
Employee(String name, int age, String officeID) {
super(name, age); // may do unnecessary work
if (age < 18 || age > 67) {
throw new IllegalArgumentException("invalid age");
}
this.officeID = officeID;
}
}
Now, we can validate (and even initialize fields) before calling super:
class Employee extends Person {
String officeID;
Employee(String name, int age, String officeID) {
if (age < 18 || age > 67) {
throw new IllegalArgumentException("invalid age");
}
this.officeID = officeID;
super(name, age);
}
}
I really like this one — constructors finally read in the order we usually think about them.
JEP 511: Module Import Declarations
Tired of importing half of java.util and friends at the top of every file?
Before:
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;
Map<String, String> m =
Stream.of("apple", "berry", "citrus")
.collect(Collectors.toMap(
s -> s.substring(0, 1).toUpperCase(),
Function.identity()));
Now, we can import a whole module:
import module java.base;
Map<String, String> m =
Stream.of("apple", "berry", "citrus")
.collect(Collectors.toMap(
s -> s.substring(0, 1).toUpperCase(),
Function.identity()));
import module java.base; pulls in the exported packages from that module on demand. If names collide (Date, List, …), resolve them with a regular single-type import as usual.
JEP 512: Compact Source Files and Instance Main Methods
Java gets a much nicer on-ramp for small programs.
Before, even Hello World needed ceremony:
public class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello, World!");
}
}
Now, we can simply write:
void main() {
IO.println("Hello, World!");
}
Or keep an explicit class with a simpler main:
class HelloWorld {
void main() {
IO.println("Hello, World!");
}
}
Compact source files also auto-import useful APIs from java.base, which makes prototyping feel much lighter.
JEP 506: Scoped Values
Scoped values are the modern alternative to many ThreadLocal use cases, especially with virtual threads.
Before, sharing immutable-ish context per request often looked like:
static final ThreadLocal<String> USER = new ThreadLocal<>();
static void handle(String user) {
USER.set(user);
try {
process();
} finally {
USER.remove(); // easy to forget!
}
}
static void process() {
System.out.println(USER.get());
}
Now, with scoped values:
static final ScopedValue<String> USER = ScopedValue.newInstance();
static void handle(String user) {
ScopedValue.where(USER, user).run(() -> process());
}
static void process() {
System.out.println(USER.get());
}
The binding is immutable for the dynamic scope of the call, cleaned up automatically, and works well together with structured concurrency and virtual threads.
JEP 505: Structured Concurrency (Fifth Preview)
This is on preview, so to use it, you need to supply the --enable-preview when running Java. |
Structured concurrency is still evolving, but the idea is important: treat related concurrent tasks as a single unit of work.
Before, fan-out / fan-in with executors was easy to get wrong around cancellation and error handling:
Future<String> user = executor.submit(() -> findUser());
Future<Integer> order = executor.submit(() -> fetchOrder());
String result = user.get() + ":" + order.get();
With structured concurrency, child tasks live inside a clear scope:
try (var scope = StructuredTaskScope.open()) {
Subtask<String> user = scope.fork(() -> findUser());
Subtask<Integer> order = scope.fork(() -> fetchOrder());
scope.join();
return user.get() + ":" + order.get();
}
API details may still move while it remains in preview, but the programming model is the one to watch next to virtual threads and scoped values.
Runtime and JVM highlights
JEP 491: Synchronize Virtual Threads without Pinning
In Java 24, virtual threads that block inside synchronized no longer pin their carrier platform thread in the common case. That removes one of the biggest practical footguns when adopting virtual threads in existing codebases that still use intrinsic locks.
JEP 519: Compact Object Headers
Object headers get smaller, which can reduce heap usage noticeably (often in the 10–20% range depending on the workload) and ease GC pressure. Enable it when available on your build/platform:
-XX:+UseCompactObjectHeaders
JEP 521: Generational Shenandoah
Shenandoah also gets a generational mode as a final feature in Java 25, improving throughput for many allocation-heavy workloads while keeping short pauses.
-XX:+UseShenandoahGC -XX:ShenandoahGCMode=generational
