New features from Java 22 to 25
A quick tour of new, production-ready features when LTS-upgrading from Java 21 to Java 25.

Note that this article highlights only those features that will be relevant for most developers - many other improvements have also been made.
All articles in this series:
- “New features from Java 12 to 17”
- “New features from Java 18 to 21”
- “New features from Java 22 to 25”
Foreign Function & Memory API
With the classes and interfaces in the java.lang.foreign package, it is possible to
- invoke functions
- access data
that are located outside the Java runtime (i.e., in languages such as C). Compared to the Java Native Interface (JNI), which has been around since Java 1.1, the FFM API is easier to use, safer, and more efficient.
You can find more details on this rather involved topic in the Java Developer Guide.
Unnamed Variables & Patterns
When a variable is not required after its initialization, it can be declared with an underscore “_” instead of a regular name in certain locations:
- local variable
- enhanced “for” loop
- resource specification of a try-with-resources
- exception parameter in a catch clause
- lambda parameter
- type pattern (variable)
Unnamed variables cannot be accessed (written or read) after their initialization. Multiple unnamed variables inside the same block are allowed.
Examples:
// local variables (useless in most cases)
var _ = "Hello";
var _ = "World";
// enhanced "for" loop
for (var _ : items) {
IO.println("Item!");
}
// try-with-resources, catch
try (var _ = new FooAutoCloseable()) {
IO.println("Processing...");
} catch (Exception _) {
IO.println("Exception!");
}
// lambda
items.stream()
.peek(_ -> IO.println("Item!"))
.toList();
// unnamed pattern variable
switch (obj) {
case Integer _, Double _ -> IO.println("Number!");
default -> IO.println("Something else!");
}
// unnamed pattern (for records)
if (obj instanceof ColoredPoint(_, var color)) {
IO.println("Color: " + color);
}
Launch Multi-File Source-Code Programs
When launching a program directly from its .java source file via java (i.e., without explicitly compiling it first),
this source file can reference additional classes from other source files:
// in file "Main.java":
class Main {
void main() {
new Other();
}
}
// in file "Other.java" (right next to "Main.java"):
class Other {
Other() {
IO.println("Other!");
}
}
The above program can be executed via java Main.java (or java path/to/Main.java from another directory).
Named packages are supported as well, as long as they are reflected in the directory structure (as should usually be the case):
// in file "at/loopyx/Main.java":
package at.loopyx;
import at.loopyx.other.Other;
class Main {
void main() {
new Other();
}
}
// in file "at/loopyx/other/Other.java"
package at.loopyx.other;
public class Other {
public Other() {
IO.println("Other!");
}
}
Libraries can be used by including their jar files in the class path:
// directory structure:
- Main.java
- lib/
- lib1.jar
- lib2.jar
// command line:
java -cp 'lib/*' Main.java
Multiple source files are not supported when launching the program via the shebang mechanism.
Locale-Dependent List Patterns
ListFormat can be used to turn a list of strings into a single, human-readable string for the current locale:
var list = List.of("A", "B", "C");
var listFormat = ListFormat.getInstance();
String formatted = listFormat.format(list);
IO.println(formatted);
// "A, B, and C"
The locale and a few details can be configured with an overloaded ListFormat.getInstance(Locale, Type, Style) method:
var listFormat = ListFormat.getInstance(
// German instead of the default locale:
Locale.GERMAN,
// "or" instead of "and"
ListFormat.Type.OR,
ListFormat.Style.FULL);
...
// "A, B oder C"
The other overload ListFormat.getInstance(String[]) allows for even more customization (although locale-independent):
var list = List.of("A", "B", "C", "D", "E");
var listFormat = ListFormat.getInstance(new String[] {
// pattern for the first two elements:
"-> {0},{1}",
// pattern for the middle elements:
"{0};{1}",
// pattern for the last two elements:
"{0} and {1}",
// pattern for a two-element list
// (optional, can be "")
"only two: {0} / {1}",
// pattern for a two-element list
// (optional, can be "")
"only three: {0} - {1} - {2}"});
...
// "-> A,B;C;D and E"
Markdown Documentation Comments
JavaDoc comments can also be written in CommonMark. Here are a few examples for the most commonly used style elements:
/// Some description here.
/// An empty comment line means a paragraph break.
///
/// Package link: [java.util]
/// Class link: [java.util.List]
/// Class link if import is available: [List]
/// Field link: [Integer#MAX_VALUE]
/// Method link: [List#size()]
/// Link with some [alternative text][List]
///
/// Monospace `highlight`
/// Emphasis _highlight_
///
/// Bullet points:
/// - one
/// - two
/// - three
///
/// Literal, uninterpreted text has to be either
///
/// indented like this
///
/// or
///
/// ```
/// "fenced" like this
/// ```
///
/// @return JavaDoc tags are supported
String foo() { ... }
Stream Gatherers
The stream gatherers API
- allows you to build your own intermediate operations for stream processing, and
- provides a few ready-to-use gatherers
Gatherers are applied on a stream via the gather method, which returns the stream transformed by the gatherer:
var inputStream = Stream.of(1, 2, 3);
var transformedStream = stream.gather(someGatherer)
The built-in Gatherers are:
fold: Transforms the input stream into a single-element stream, comparable to the terminal operation Stream.reduce.
var inputStream = Stream.of(1, 2, 3);
var outputList = inputStream
.gather(
Gatherers.fold(
// initial value supplier
() -> 0,
// "folder" function
(sumSoFar, next) -> sumSoFar + next))
.toList();
IO.println(outputList); // [6]
scan:
Similar to the previous fold gatherer, but emits a value every time the provided function is executed
(while fold only emits the last value).
var inputStream = Stream.of(1, 2, 3);
var outputList = inputStream
.gather(
Gatherers.scan(
// initial value supplier
() -> 0,
// "scanner" function
(sumSoFar, next) -> sumSoFar + next))
.toList();
IO.println(outputList); // [1, 3, 6]
windowFixed: Transforms the input stream into a sequence of non-overlapping “windows”, where each window is a list with at most the given size.
var inputStream = Stream.of(1, 2, 3, 4, 5);
var outputList = inputStream
.gather(
Gatherers.windowFixed(2))
.toList();
IO.println(outputList);
// [[1, 2], [3, 4], [5]]
windowSliding: Transforms the input stream into a sequence of overlapping “windows”, where each window is a list with at most the given size.
var inputStream = Stream.of(1, 2, 3, 4, 5);
var outputList = inputStream
.gather(
Gatherers.windowSliding(2))
.toList();
IO.println(outputList);
// [[1, 2], [2, 3], [3, 4], [4, 5]]
Like Stream.map, transforms every element of the input stream with the given mapping function, but does so in parallel using up to the given number of virtual threads.
var inputStream = Stream.of(1, 2, 3, 4, 5);
var outputList = inputStream
.gather(Gatherers.mapConcurrent(3, el -> el * 2))
.toList();
IO.println(outputList);
// [2, 4, 6, 8, 10]
Multiple gatherers can be invoked with subsequent calls to gather:
// the two gatherers from the above examples
var scanner = Gatherers.<Integer,Integer>scan(
() -> 0, (sum, next) -> sum + next);
var folder = Gatherers.<Integer,Integer>fold(
() -> 0, (sum, next) -> sum + next);
var inputStream = Stream.of(1, 2, 3);
var outputList = inputStream
.gather(scanner)
.gather(folder)
.toList();
IO.println(outputList); // [10]
Alternatively, a reusable gatherer can be composed with Gatherer.andThen:
// the two gatherers from the above examples
var scanner = Gatherers.<Integer,Integer>scan(
() -> 0, (sum, next) -> sum + next);
var folder = Gatherers.<Integer,Integer>fold(
() -> 0, (sum, next) -> sum + next);
var combined = scanner.andThen(folder);
var inputStream = Stream.of(1, 2, 3);
var outputList = inputStream
.gather(combined)
.toList();
IO.println(outputList); // [10]
A simple, ad-hoc gatherer can be written using Gatherer.of:
var inputStream = Stream.of(1, 2, 3);
var outputList = inputStream
.gather(Gatherer.of(
(state, element, downstream) ->
downstream.push(element * 2)
))
.toList();
IO.println(outputList); // [2, 4, 6]
Alternatively, you can implement the Gatherer interface instead:
class CustomGatherer implements Gatherer<Integer, Object, Integer> {
public Integrator<Object, Integer, Integer> integrator() {
return Gatherer.Integrator.of(
(state, element, downstream) ->
downstream.push(element * 2)
);
}
}
// use analogous to ad-hoc gatherer:
// inputStream.gather(new CustomGatherer())
Apart from the mandatory integrator method above, the interface contains the following optional methods that you might have to implement, depending on what the gatherer is supposed to do:
- initializer: Initializes a state object, which will be passed to the integrator (and thus, serves as memory across separate invocations of the integrator).
- finisher: Invoked once at the end of the stream, and receives the final state object.
- combiner: Combines two state objects that have resulted from parallel invocations of the integrator (in a parallel stream).
Synchronize Virtual Threads without Pinning
Virtual threads (which were introduced with Java 21) are no longer pinned to their platform thread while inside a synchronized
piece of code. This means that when a virtual thread blocks inside such a piece of code (e.g., because it
is waiting for a backend’s response via the network), it can now release its underlying platform thread,
making it available for other virtual threads to do their work.
Scoped Values
Like thread-local variables, scoped values hold data that is specific to the current thread. But in comparison, scoped values are
- immutable
- limited to a certain scope
- efficiently inherited to child threads (without additional memory allocation)
and hence, very well suited for virtual threads, which are meant to be plentiful.
In scenarios where a one-way transmission of unchanging data is required, it makes sense to use scoped values instead of thread-local variables.
However, beware that child threads inherit scoped values only if they are created using the Structured Concurrency API, which is still in preview as of Java 25 (JEP 505).
Simple usage example:
// defining the container (still without value)
static final ScopedValue<String> GREETING = ScopedValue.newInstance();
void main() {
ScopedValue
// binding a value to the container
.where(GREETING, "Hello!")
.run(() -> {
// code that runs within this lambda
// has access to the value
IO.println(GREETING.get()); // -> "Hello!"
});
// code that runs outside the above lambda
// does NOT have access to the value
IO.println(GREETING.get());
// -> NoSuchElementException: ScopedValue not bound
}
Scopes can be nested, where a new value can be bound:
ScopedValue.where(GREETING, "Hello")
.run(() -> {
IO.println(GREETING.get()); // -> "Hello"
// creating a nested scope with a different value
ScopedValue.where(GREETING, "Bonjour")
.run(() -> {
IO.println(GREETING.get()); // -> "Bonjour"
});
// after the nested scope: previous value again
IO.println(GREETING.get()); // -> "Hello"
});
An alternative to the above run is call, which returns a value, and may throw a Throwable (i.e., including checked exceptions):
var answer = ScopedValue.where(GREETING, "Hello")
.call(() -> {
if ("Bye".equals(GREETING.get())) {
throw new java.io.IOException("not a greeting");
}
return 42;
});
Multiple scoped values can be bound by multiple invocations of where:
ScopedValue
.where(GREETING, "Hello")
.where(PARTING, "Bye")
.run(() -> {
IO.println(GREETING.get()); // -> "Hello"
IO.println(PARTING.get()); // -> "Bye"
});
Module Import Declarations
An import declaration such as
import module java.base;
imports all public classes and interfaces from the packages that are exported by the given module. In the previous example, this would be all exports of the java.base module (such as java.util.List, java.util.stream.Stream, and java.nio.file.Path).
If the referenced module has indirect exports resulting from transitive dependencies (like java.sql), these modules are imported as well.
Ambiguity can be resolved by explicitly importing the desired class
import module java.base; // contains java.util.Date
import module java.sql; // contains java.sql.Date
import java.util.Date; // resolves the ambiguity
or using an “on-demand” import of the desired package:
import module java.base; // contains java.util.Date
import module java.sql; // contains java.sql.Date
import java.util.*; // resolves the ambiguity
Compact Source Files and Instance Main Methods
A simple Java program can be written as a compact source file like this:
void main() {
// code here
}
The class java.lang.IO contains five static methods for interacting with the console:
- void print(Object obj)
- void println(Object obj)
- void println()
- String readln(String prompt)
- String readln()
void main() {
var name = IO.readln("Your name? ");
IO.println("Hello, " + name);
}
Additional methods (and fields) can be defined and accessed from within the main method:
String greeting = "Hello!";
void main() {
greet();
}
void greet() {
IO.println(greeting);
}
Commonly used packages (such as java.util and java.io) are imported automatically by a compact source file via an implicit
import module java.base;
Flexible Constructor Bodies
Constructors now allow code before an explicit call to super(..) or this(..). This can be used for validating or computing arguments before passing them on to other constructors.
Code that comes before an explicit invocation of super(..) or this(..) must not use fields or methods of the current instance. Only simple assignments to uninitialized fields are permitted:
class Demo {
String greeting = "Hello ";
String addressee;
Demo() {
IO.println("code before super()");
addressee = "World";
// not allowed here (already initialized):
// greeting = "Bye ";
// not allowed here (access to instance fields or methods)
// IO.println(greeting);
// IO.println(addressee);
// greet();
super();
greet();
greeting = "Bye ";
IO.println(greeting + addressee);
}
void greet() {
IO.println(greeting + addressee);
}
}
Enji's Blog