What’s New in Java 25: Key Changes from Java 21

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 (public in this case);
  • you must know what a static modifier is and the difference between a static and an instance;
  • you must know what a void return type is;
  • you must know what a String Array is;
  • 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 IO class within the java.lang package 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:

  1. If the condition in the subclass at line 5 results to true, the constructor of the parent Vehicle class is invoked unnecessarily.
  2. If you instantiate a Java21Car with numberOfWheels equal to 1, an IllegalArgumentException is thrown, but the output of the overridden print method, will print null for the color because 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:

  1. unconstrained mutability: every ThreadLocal variable is mutable, when code is able to invoke the get method, it is also able to invoke the set method.
  2. unbounded lifetime: the value of ThreadLocal exists during the entire lifetime of the thread, or until the remove method is called. The latter is often forgotten, whereas per thread, data exists longer than it should.
  3. expensive inheritance: When a child thread is created, the value of the ThreadLocal variable 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:

  1. Create the stream;
  2. Intermediate operations;
  3. 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:

  1. Use familiar functions like map, filter, etc.
  2. Use a built-in gatherer.
  3. Call a friend for advise.
  4. 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

Powered by WordPress.com.

Up ↑

Discover more from My Developer Planet

Subscribe now to keep reading and get access to the full archive.

Continue reading