The 16th of September 2025, Java 25 was released. Time to take a closer look at the changes since the last LTS release, which is Java 21. In this blog, some of the changes between Java 21 and Java 25 are highlighted, mainly by means of examples. Enjoy!
1. Introduction
What has changed between Java 21 and Java 25? A complete list of the JEPs (Java Enhancement Proposals) can be found at the OpenJDK website. Here you can read the nitty gritty details of each JEP. For a complete list of what has changed per release since Java 21, the Oracle release notes give a good overview.
In the next sections, some of the changes are explained by example, but it is mainly up to you to experiment with these new features in order to get acquainted with them. Do note that no preview or incubator JEPs are considered here. The sources used in this post are available at GitHub.
Check out an earlier blog if you want to know what has changed between Java 17 and Java 21.
Check out an earlier blog if you want to know what has changed between Java 11 and Java 17.
2. Prerequisites
Prerequisites for reading this blog are:
- You must have a JDK25 installed. I advise to use SDKMAN for this purpose, in order that you can switch easily between JDK’s.
- You need some basic Java knowledge.
3. JEP512: Compact Source Files and Instance Main Methods
When you are new to Java, you are confronted with quite some concepts before you can even get started. Take a look at the classic HelloWorld.java.
public class ClassicHelloWorld {
public static void main(String[] args) {
System.out.println("Hello World!");
}
}
You are confronted with the following concepts:
- you must know the concept of a
class; - you must know the concept of access modifiers (
publicin this case); - you must know what a static modifier is and the difference between a static and an instance;
- you must know what a
voidreturn type is; - you must know what a
String Arrayis; - you must know about the strange thing
System.out.
That is quite a lot! And the only thing you have achieved is a simple text output to the console.
The purpose of JEP512 is to remove all of the boiler plate code in order to make the onboarding to Java much more accessible. This means in practice:
- The static method is removed and replaced with simply
void main; - No need to create a
class; - A new
IOclass within thejava.langpackage is introduced which contains basic IO oriented methods.
The new HelloWorld in Java 25 looks as follows:
void main() {
IO.println("Hello Java 25 World!");
}
Much more simple, right? Do note that even package statements are not allowed here. The purpose is just to provide an as easy as possible starting point in order to use Java.
4. JEP513: Flexible Constructor Bodies
In a constructor, it is not possible to add statements before invoking this() (invoking another constructor within the same class) or before invoking super() (invoking a parent constructor). This causes some limitations when you want to validate input parameters, for example.
Assume the following Vehicle class.
public class Vehicle {
int numberOfWheels;
Vehicle(int numberOfWheels) {
if (numberOfWheels < 1) {
throw new IllegalArgumentException("a vehicle must have at least one wheel");
}
this.numberOfWheels = numberOfWheels;
print();
}
void print() {
System.out.println("Number of wheels: " + numberOfWheels);
}
}
Class Java21Car extends the parent Vehicle class.
public class Java21Car extends Vehicle {
Color color;
Java21Car(int numberOfWheels, Color color) {
super(numberOfWheels);
if (numberOfWheels < 4 || numberOfWheels > 6) {
throw new IllegalArgumentException("A car must have 4, 5 or 6 wheels.");
}
this.color = color;
}
@Override
void print() {
System.out.println("Number of wheels: " + numberOfWheels);
System.out.println("Color: " + color);
}
}
Two issues exist:
- If the condition in the subclass at line 5 results to
true, the constructor of the parentVehicleclass is invoked unnecessarily. - If you instantiate a
Java21CarwithnumberOfWheelsequal to 1, anIllegalArgumentExceptionis thrown, but the output of the overriddenprintmethod, will printnullfor thecolorbecause the value has not been assigned yet.
Run the FlexibleConstructor class in order to see this result.
public class FlexibleConstructor {
static void main() {
Java21Car java21Car = new Java21Car(1, Color.BLACK);
}
}
The output is:
Exception in thread "main" java.lang.IllegalArgumentException: A car must have 4, 5 or 6 wheels.
at com.mydeveloperlanet.myjava25planet.constructor.Java21Car.<init>(Java21Car.java:12)
at com.mydeveloperlanet.myjava25planet.constructor.FlexibleConstructor.main(FlexibleConstructor.java:7)
Number of wheels: 1
Color: null
With the introduction of JEP513, these issues can be solved. Move the validation code in the constructor of the subclass above the invocation of super().
public class Java25Car extends Vehicle {
Color color;
Java25Car(int numberOfWheels, Color color) {
if (numberOfWheels < 4 || numberOfWheels > 6) {
throw new IllegalArgumentException("A car must have 4, 5 or 6 wheels.");
}
this.color = color;
super(numberOfWheels);
}
@Override
void print() {
System.out.println("Number of wheels: " + numberOfWheels);
System.out.println("Color: " + color);
}
}
Create an instance just like you did before.
Java25Car java25Car = new Java25Car(1, Color.BLACK);
Running this code results in the IllegalArgumentException, but this time the super()is not invoked and as a consequence, the print method is not invoked.
Exception in thread "main" java.lang.IllegalArgumentException: A car must have 4, 5 or 6 wheels.
at com.mydeveloperlanet.myjava25planet.constructor.Java25Car.<init>(Java25Car.java:11)
at com.mydeveloperlanet.myjava25planet.constructor.FlexibleConstructor.main(FlexibleConstructor.java:8)
Create an instance with valid input arguments.
Java25Car java25Car = new Java25Car(4, Color.BLACK);
The super() is invoked and the print method outputs the data as expected.
Number of wheels: 4
Color: java.awt.Color[r=0,g=0,b=0]
5. JEP456: Unnamed Variables & Patterns
Sometimes it occurs that you do not use a variable. For example in a catch block, you do not want to do anything with the Exception being thrown. However, before Java 25, it was still mandatory to give the variable a name.
Assume the following example, where the NumberFormatException must be given a name, ex in this case.
String s = "data";
try {
Integer.parseInt(s);
} catch (NumberFormatException ex) {
System.out.println("Bad integer: " + s);
}
With the introduction of JEP456, you can be more explicit about this by making this variable an unnamed variable. You do so by using an underscore.
String s = "data";
try {
Integer.parseInt(s);
} catch (NumberFormatException _) {
System.out.println("Bad integer: " + s);
}
The same applies to patterns. Assume the following classes.
abstract class AbstractFruit {}
public class Apple extends AbstractFruit {}
public class Pear extends AbstractFruit {}
public class Orange extends AbstractFruit {}
You create a switch where you test which kind of Fruit the instance equals to. You had to give the case elements a name, even if you did not use them.
AbstractFruit fruit = new Apple();
switch (fruit) {
case Apple apple -> System.out.println("This is an apple");
case Pear pear -> System.out.println("This is a pear");
case Orange orange -> System.out.println("This is an orange");
default -> throw new IllegalStateException("Unexpected value: " + fruit);
}
Also in this case, you can be more explicit about it and use the underscore.
AbstractFruit fruit = new Apple();
switch (fruit) {
case Apple _ -> System.out.println("This is an apple");
case Pear _ -> System.out.println("This is a pear");
case Orange _ -> System.out.println("This is an orange");
default -> throw new IllegalStateException("Unexpected value: " + fruit);
}
6. JEP506: Scoped Values
Scoped Values are introduced by JEP506 and will mainly be of use between framework code and application code. A typical example is the processing of http requests where a callback is executed to framework code. The handle method is invoked from within the framework and from the application code a callback is executed in method readUserInfo.
public class Application {
Framework framework = new Framework(this);
//@Override
public void handle(Request request, Response response) {
// user code, called by framework
var userInfo = readUserInfo();
}
private UserInfo readUserInfo() {
// call framework
return (UserInfo) framework.readKey("userInfo");
}
}
In the framework, data is stored in a framework context within a Thread. By means of ThreadLocal, the CONTEXT is created (1). Request specific data is stored in this context (2) before the application code is invoked. When the application executes a callback to the framework, the CONTEXT can be retrieved again (3).
public class Framework {
private final Application application;
public Framework(Application app) { this.application = app; }
private static final ThreadLocal<FrameworkContext> CONTEXT
= new ThreadLocal<>(); // (1)
void serve(Request request, Response response) {
var context = createContext(request);
CONTEXT.set(context); // (2)
application.handle(request, response);
}
public UserInfo readKey(String key) {
var context = CONTEXT.get(); // (3)
return context.getUserInfo();
}
FrameworkContext createContext(Request request) {
FrameworkContext frameworkContext = new FrameworkContext();
UserInfo userInfo = new UserInfo();
// set data from request
frameworkContext.setUserInfo(userInfo);
return frameworkContext;
}
}
class FrameworkContext {
private UserInfo userInfo;
public UserInfo getUserInfo() {
return userInfo;
}
public void setUserInfo(UserInfo userInfo) {
this.userInfo = userInfo;
}
}
The FrameworkContext object is a hidden method variable. It is present with every method call, but you do not pass it as an argument. Because everything is running within the same thread, readKey has access to the own local copy of the CONTEXT.
There are three problems using ThreadLocal:
- unconstrained mutability: every
ThreadLocalvariable is mutable, when code is able to invoke thegetmethod, it is also able to invoke thesetmethod. - unbounded lifetime: the value of
ThreadLocalexists during the entire lifetime of the thread, or until theremovemethod is called. The latter is often forgotten, whereas per thread, data exists longer than it should. - expensive inheritance: When a child thread is created, the value of the
ThreadLocalvariable is copied to the child thread. The child thread needs to allocate extra storage for this. No shared storage is possible and when using a lot of threads, this can have severe impact.
With the introduction of Virtual Threads, these design flaws have seen increased impact.
The solution is Scoped Values.
A scoped value is a container object that allows a data value to be safely and efficiently shared by a method with its direct and indirect callees within the same thread, and with child threads, without resorting to method parameters. It is a variable of type ScopedValue. It is typically declared as a static final field, and its accessibility is set to private so that it cannot be directly accessed by code in other classes.
The previous framework code can be rewritten as follows. Instead of creating a ThreadLocal variable, a variable of type ScopedValue is created (1). When invoking application code the static method ScopedValue.where is used to assign the value (2). The readKey method remains unchanged (3). The main advantage is that the context value is only to be used during the lifetime of the run method.
public class Framework {
private final Application application;
public Framework(Application app) { this.application = app; }
private static final ScopedValue<FrameworkContext> CONTEXT
= ScopedValue.newInstance(); // (1)
void serve(Request request, Response response) {
var context = createContext(request);
where(CONTEXT, context) // (2)
.run(() -> application.handle(request, response));
}
public UserInfo readKey(String key) {
var context = CONTEXT.get(); // (3)
return context.getUserInfo();
}
FrameworkContext createContext(Request request) {
FrameworkContext frameworkContext = new FrameworkContext();
UserInfo userInfo = new UserInfo();
// set data from request
frameworkContext.setUserInfo(userInfo);
return frameworkContext;
}
}
Copying data to child threads is able by means of Structured Concurrency (using StructuredTaskScope), Scoped Values of the parent are automatically available within child threads. Structured Concurrency is in its 5th preview, so probable will be available soon.
7. JEP485: Stream Gatherers
A stream consists out of three parts:
- Create the stream;
- Intermediate operations;
- A terminal operation.
Terminal operations are extensible, but intermediate operations are not.
JEP485 introduces a new intermediate stream operation Stream:gather(Gatherer) which can process elements by means of a user-defined entity. Creating a gatherer is complex and is out of scope for this blog. But, there are some built-in gatherers which are discussed below.
The stream used is a stream of integers.
List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9);
7.1 Gatherer: fold
Gatherer fold is a many-to-one gatherer which constructs an aggregate incrementally and emits that aggregate when no more input elements exist.
int sum = numbers.stream()
.gather(Gatherers.fold(() -> 0, (acc, x) -> acc + x))
.findFirst() // fold produces a single result
.orElse(0);
System.out.println("fold sum = " + sum);
The output is:
fold sum = 45
7.2 Gatherer: mapConcurrent
Gatherer mapConcurrent is a stateful one-to-one gatherer which invokes a supplied function for each input element concurrently, up to a supplied limit.
List<Integer> squares = numbers.stream()
.gather(Gatherers.mapConcurrent(4, x -> x * x)) // 4 = parallelism hint
.toList();
System.out.println("mapConcurrent squares = " + squares);
The output is:
mapConcurrent squares = [1, 4, 9, 16, 25, 36, 49, 64, 81]
7.3 Gatherer: scan
Gatherer scan is a stateful one-to-one gatherer which applies a supplied function to the current state and the current element to produce the next element, which it passes downstream.
List<Integer> runningSums = numbers.stream()
.gather(Gatherers.scan(() -> 0, (acc, x) -> acc + x))
.toList();
System.out.println("scan running sums = " + runningSums);
The output is:
scan running sums = [1, 3, 6, 10, 15, 21, 28, 36, 45]
7.4 Gatherer: windowFixed
Gatherer windowFixed is a stateful many-to-many gatherer which groups input elements into lists of a supplied size, emitting the windows downstream when they are full.
int size = 2;
List<List<Integer>> windows = numbers.stream()
.gather(Gatherers.windowFixed(size))
.toList();
System.out.println("windowFixed(2) = " + windows);
The output is:
windowFixed(2) = [[1, 2], [3, 4], [5, 6], [7, 8], [9]]
7.5 Gatherer: windowSliding
Gatherer windowSliding is a stateful many-to-many gatherer which groups input elements into lists of a supplied size. After the first window, each subsequent window is created from a copy of its predecessor by dropping the first element and appending the next element from the input stream.
int size = 3;
List<List<Integer>> windows = numbers.stream()
.gather(Gatherers.windowSliding(size))
.toList();
System.out.println("windowSliding(3) = " + windows);
The output is:
windowSliding(3) = [[1, 2, 3], [2, 3, 4], [3, 4, 5], [4, 5, 6], [5, 6, 7], [6, 7, 8], [7, 8, 9]]
7.6 Gatherers Final Words
In order to conclude this paragraph about Gatherers, some final words of advise given by Venkat Subramaniam.
Venkat’s four steps to use gatherers:
- Use familiar functions like
map,filter, etc. - Use a built-in gatherer.
- Call a friend for advise.
- Create a gatherer, but … this is complex and a lot of work.
8. JEP458: Launch Multi-File Source-Code Programs
When writing scripts with Java, you probably want to split the code between different files when the script becomes too long. Also, when writing scripts, you most likely are not using a build tool in order to create a jar-file. JEP458 allows you to resolve other classes needed in your main class without extra effort.
Assume the following class.
public class Application {
public static void main() {
Helper helper = new Helper();
helper.run();
}
}
Run this class using Java 21. When you use SDKMAN, switch to a Java 21 JDK.
sdk use java 21.0.9-tem
Execute the Application.java file.
$ java Application.java
Application.java:6: error: cannot find symbol
Helper helper = new Helper();
^
symbol: class Helper
location: class Application
Application.java:6: error: cannot find symbol
Helper helper = new Helper();
^
symbol: class Helper
location: class Application
2 errors
error: compilation failed
As you can see, the compilation fails because the Helper class, which is located next to the Application class cannot be found.
Switch to a Java 25 JDK.
sdk use java 25.0.1-tem
Execute the Application.java file and now the Helper class can be found and the program executes as expected.
$ java Application.java
Do something
9. JEP467: Markdown Documentation Comments
With Java 21, you can format comments by means of HTML tags as can be seen in this example.
/**
* Returns a hash code value for the object. This method is
* supported for the benefit of hash tables such as those provided by
* {@link java.util.HashMap}.
* <p>
* The general contract of {@code hashCode} is:
* <ul>
* <li>Whenever it is invoked on the same object more than once during
* an execution of a Java application, the {@code hashCode} method
* must consistently return the same integer, provided no information
* used in {@code equals} comparisons on the object is modified.
* This integer need not remain consistent from one execution of an
* application to another execution of the same application.
* <li>If two objects are equal according to the {@link
* #equals(Object) equals} method, then calling the {@code
* hashCode} method on each of the two objects must produce the
* same integer result.
* <li>It is <em>not</em> required that if two objects are unequal
* according to the {@link #equals(Object) equals} method, then
* calling the {@code hashCode} method on each of the two objects
* must produce distinct integer results. However, the programmer
* should be aware that producing distinct integer results for
* unequal objects may improve the performance of hash tables.
* </ul>
*
* @implSpec
* As far as is reasonably practical, the {@code hashCode} method defined
* by class {@code Object} returns distinct integers for distinct objects.
*
* @return a hash code value for this object.
* @see java.lang.Object#equals(java.lang.Object)
* @see java.lang.System#identityHashCode
*/
public int htmlHashCode() {
return 0;
}
However, Markdown is also quite a lot used by developers. With JEP467, you are able to use Markdown for documentation comments. Some notes about it:
- Markdown comments are indicated by means of
///. <p>is not necessary anymore and can be replaced by means of a blank line.- Markdown bullets can be used.
- Font changes use the Markdown syntax, for example an underscore for italic font.
- Backticks can be used for the code font.
- Markdown links are also supported.
- The Markdown syntax to be used is the CommonMark syntax.
The previous example rewritten with Markdown.
/// Returns a hash code value for the object. This method is
/// supported for the benefit of hash tables such as those provided by
/// [java.util.HashMap].
///
/// The general contract of `hashCode` is:
///
/// - Whenever it is invoked on the same object more than once during
/// an execution of a Java application, the `hashCode` method
/// must consistently return the same integer, provided no information
/// used in `equals` comparisons on the object is modified.
/// This integer need not remain consistent from one execution of an
/// application to another execution of the same application.
/// - If two objects are equal according to the
/// [equals][#equals(Object)] method, then calling the
/// `hashCode` method on each of the two objects must produce the
/// same integer result.
/// - It is _not_ required that if two objects are unequal
/// according to the [equals][#equals(Object)] method, then
/// calling the `hashCode` method on each of the two objects
/// must produce distinct integer results. However, the programmer
/// should be aware that producing distinct integer results for
/// unequal objects may improve the performance of hash tables.
///
/// @implSpec
/// As far as is reasonably practical, the `hashCode` method defined
/// by class `Object` returns distinct integers for distinct objects.
///
/// @return a hash code value for this object.
/// @see java.lang.Object#equals(java.lang.Object)
/// @see java.lang.System#identityHashCode
public int markdownHashCode() {
return 0;
}
10. Conclusion
In this blog, you took a quick look at some features added since the last LTS release Java 21. It is now up to you to start thinking about your migration plan to Java 25 and a way to learn more about these new features and how you can apply them into your daily coding habits. Tip: IntelliJ will help you with that!
Discover more from My Developer Planet
Subscribe to get the latest posts sent to your email.

Leave a Reply