Java 25 — What's New in the Next LTS
A practical overview of Java 25 LTS — the features that matter, what's finalized from preview, and what you need to know before upgrading from Java 21.
Java 25 was released on September 16, 2025 — the next Long-Term Support release after Java 21. If you skipped Java 22, 23, and 24, this is the version to upgrade to.
This post covers the features that matter for everyday development — not every JEP, just the ones you’ll actually use.
Why Java 25?
Java LTS releases get years of support. Java 21 (September 2023) is the current standard. Java 25 is the next one. If your team follows the “upgrade on LTS” strategy, Java 25 is your target.
Key themes:
- Features that were preview in 21–24 are now finalized
- Virtual threads improvements
- Pattern matching enhancements
- Project Panama (Foreign Function & Memory API) finalized
- Performance improvements across the board
Finalized features (were preview in 21–24)
Structured Concurrency
First previewed in Java 21. Now stable in 25.
import java.util.concurrent.StructuredTaskScope;
record UserProfile(User user, List<Order> orders) {}
UserProfile fetchProfile(String userId) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var userTask = scope.fork(() -> userService.getUser(userId));
var ordersTask = scope.fork(() -> orderService.getOrders(userId));
scope.join();
scope.throwIfFailed();
return new UserProfile(userTask.get(), ordersTask.get());
}
}
Both tasks run concurrently. If either fails, the other is cancelled. If the parent thread is interrupted, both tasks are cancelled. No thread leaks, no forgotten futures.
This is the Java equivalent of Kotlin’s coroutineScope with structured concurrency.
Scoped Values
Replacement for ThreadLocal that works correctly with virtual threads:
import java.lang.ScopedValue;
static final ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();
void handleRequest(User user) {
ScopedValue.runWhere(CURRENT_USER, user, () -> {
processOrder(); // can access CURRENT_USER.get()
});
}
void processOrder() {
User user = CURRENT_USER.get(); // available without passing as parameter
// ...
}
Unlike ThreadLocal, scoped values:
- Are immutable within a scope
- Are automatically cleaned up when the scope exits
- Work correctly with virtual threads (no thread-pool reuse issues)
String Templates
Write strings with embedded expressions:
String name = "Alice";
int age = 30;
// String template
String message = STR."Hello, \{name}. You are \{age} years old.";
// "Hello, Alice. You are 30 years old."
// With expressions
String calc = STR."2 + 3 = \{2 + 3}";
// "2 + 3 = 5"
// Multi-line
String json = STR."""
{
"name": "\{name}",
"age": \{age}
}
""";
STR is the standard string template processor. The \{expr} syntax embeds expressions. This is safer than string concatenation because custom processors can handle SQL injection, HTML escaping, etc.
Unnamed Variables and Patterns
Use _ for variables you don’t need:
// Before
try {
// ...
} catch (IOException e) {
// e is unused
log.warn("IO error occurred");
}
// After
try {
// ...
} catch (IOException _) {
log.warn("IO error occurred");
}
// In enhanced for loops
for (var _ : collection) {
count++;
}
// In pattern matching
if (obj instanceof Point(var x, _)) {
// only care about x coordinate
}
Gatherers (Stream API enhancement)
Custom intermediate stream operations:
import java.util.stream.Gatherers;
// Window sliding
List<List<Integer>> windows = List.of(1, 2, 3, 4, 5).stream()
.gather(Gatherers.windowSliding(3))
.toList();
// [[1, 2, 3], [2, 3, 4], [3, 4, 5]]
// Fixed-size groups
List<List<Integer>> groups = List.of(1, 2, 3, 4, 5).stream()
.gather(Gatherers.windowFixed(2))
.toList();
// [[1, 2], [3, 4], [5]]
Gatherers let you write custom stream operations that maintain state — something that was impossible with the existing Stream API without hacks.
Virtual Threads improvements
Virtual threads shipped in Java 21. Java 25 refines them:
- Better integration with synchronized blocks (less pinning)
- Improved debugging and profiling support
- Better stack trace reporting
The core API hasn’t changed — you still use Thread.ofVirtual() or Executors.newVirtualThreadPerTaskExecutor(). But the runtime behavior is more reliable.
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<String>> futures = urls.stream()
.map(url -> executor.submit(() -> fetch(url)))
.toList();
List<String> results = futures.stream()
.map(f -> {
try { return f.get(); }
catch (Exception e) { return "error"; }
})
.toList();
}
One virtual thread per task. Millions of concurrent tasks. No thread pool tuning.
Foreign Function & Memory API (Project Panama)
Finalized. Call native code without JNI:
import java.lang.foreign.Arena;
import java.lang.foreign.FunctionDescriptor;
import java.lang.foreign.Linker;
import java.lang.foreign.MemorySegment;
import java.lang.foreign.SymbolLookup;
import java.lang.foreign.ValueLayout;
// Call strlen from C standard library
Linker linker = Linker.nativeLinker();
SymbolLookup stdlib = linker.defaultLookup();
var strlen = linker.downcallHandle(
stdlib.find("strlen").orElseThrow(),
FunctionDescriptor.of(ValueLayout.JAVA_LONG, ValueLayout.ADDRESS)
);
try (var arena = Arena.ofConfined()) {
MemorySegment str = arena.allocateFrom("Hello, Panama!");
long len = (long) strlen.invoke(str);
System.out.println("Length: " + len); // 14
}
Most developers won’t use this directly, but libraries like database drivers, crypto, and compression will benefit from replacing JNI with a safer API.
Pattern matching enhancements
Building on Java 21’s pattern matching:
Primitive patterns in instanceof
// Java 25 — pattern match with primitives
Object obj = 42;
if (obj instanceof int i) {
System.out.println("Integer value: " + i);
}
Enhanced switch patterns
record Point(int x, int y) {}
String describe(Object obj) {
return switch (obj) {
case Integer i when i > 0 -> "Positive: " + i;
case Integer i -> "Non-positive: " + i;
case String s when s.length() > 10 -> "Long string";
case String s -> "Short string: " + s;
case Point(var x, var y) when x == 0 && y == 0 -> "Origin";
case Point(var x, var y) -> "Point(%d, %d)".formatted(x, y);
case null -> "null";
default -> "Unknown: " + obj;
};
}
Guards (when), record patterns, null handling — all in one switch expression.
Migration from Java 21
What breaks?
Very little. Java has strong backward compatibility. Common issues:
- Deprecated APIs removed — Check for
@Deprecated(forRemoval=true)usage - Security Manager removed — If your code uses SecurityManager, it’s gone
- Internal API access —
--add-opensflags might change
Steps
- Update your build tool’s JDK to 25
- Update
sourceCompatibilityandtargetCompatibilityto 25 - Build and run tests
- Fix deprecation warnings
- Start using new features incrementally
// build.gradle.kts
java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(25))
}
}
Framework compatibility
| Framework | Java 25 Support |
|---|---|
| Spring Boot 4.0+ | Full support |
| Spring Boot 3.x | Works (requires 3.4+) |
| Kotlin 2.1+ | Full support |
| Gradle 9.x | Full support |
| Maven 3.9+ | Full support |
Should you upgrade?
Yes, if:
- You’re on Java 17 or 21 and your framework supports 25
- You want structured concurrency and virtual thread improvements
- You’re planning a Spring Boot 4 migration (it targets Java 25)
Wait, if:
- You’re on Java 11 or earlier (jump to 21 first, then 25)
- Critical dependencies don’t support Java 25 yet
- You’re mid-release and stability matters more
Summary
Java 25 LTS finalizes the most anticipated features from Java 21–24:
- Structured Concurrency — safe concurrent task management
- Scoped Values — better than ThreadLocal for virtual threads
- String Templates — embedded expressions in strings
- Unnamed Variables —
_for unused variables - Gatherers — custom stream operations
- Foreign Function API — native calls without JNI
- Pattern matching — primitives, guards, records in switch
It’s a substantial upgrade from Java 21, and the first LTS to fully embrace the virtual threads ecosystem.