Meet Java 25

JDK 25 reached General Availability on 16 September 2025.

Java 25 continues the trend toward safer concurrency, reduced boilerplate, and better ergonomics—especially for modern, cloud-native and virtual-thread-heavy applications.

Some of the most notable features include:

  • Scoped Values
  • Module Import Declarations
  • Compact Source Files and Instance Main Methods
  • Flexible Constructor Bodies
  • Compact Object Headers

There are many more features and improvements in this release, but in this post, I will highlight just a few, for a comprehensive list, check out the official JDK 25 release notes.

Scoped Values

JEP 506

Scoped Values provide a way to share immutable context data across a well-defined execution scope, without relying on thread-local storage. They are designed for modern Java, especially in the presence of virtual threads and structured concurrency.

Typical use cases include propagating request-scoped data such as user identity, trace IDs, or transaction metadata—without explicitly passing parameters through every method call.

The big picture:

ThreadLocal → thread-bound mutable state (legacy, problematic with virtual threads)

ScopedValue → scope-bound immutable context (modern, safe, structured)

Let’s look at a simple example of how Scoped Values work and contrast them with ThreadLocal. The example below demonstrates how to implement tracing of operations using both approaches.

About tracing:

Tracing is a technique used to monitor and log the execution of operations within an application. It helps in understanding the flow of requests, diagnosing issues, and analyzing performance by recording key events and data points during the execution of code. It relies on passing context information, like trace IDs and/or span IDs, through various layers of the application.

import java.time.Instant;
import java.util.UUID;

public class TracingDemo {

  record TraceContext(String traceId, String spanId) {}

  static final ScopedValue<TraceContext> TRACE = ScopedValue.newInstance();

  public static void main(String[] args) {
    new TracingDemo().handleRequest();
  }

  void handleRequest() {
    TraceContext root = new TraceContext(newTraceId(), "root");

    ScopedValue.where(TRACE, root).run(
            () -> {
              log("Handling request");
              queryDatabase();
              callExternalService();
              log("Request completed");
            }
    );
  }

  void queryDatabase() {
    withSpan(
        "db",
        () -> {
          sleep(300);
          log("Querying database");
        });
  }

  void callExternalService() {
    withSpan(
        "http",
        () -> {
          sleep(500);
          log("Calling external service");
        });
  }

  void withSpan(String spanName, Runnable task) {
    TraceContext parent = TRACE.get();
    TraceContext child = new TraceContext(parent.traceId(), spanName);
    ScopedValue.where(TRACE, child).run(task);
  }

  static void log(String message) {
    TraceContext ctx = TRACE.get();
    System.out.printf(
        "%s [trace=%s span=%s] %s%n", Instant.now(), ctx.traceId(), ctx.spanId(), message);
  }

  static String newTraceId() {
    return "trace-" + UUID.randomUUID();
  }

  static void sleep(long millis) {
    try {
      Thread.sleep(millis);
    } catch (InterruptedException e) {
      throw new RuntimeException(e);
    }
  }
}

Running the code above with java --enable-preview TracingDemo.java produces output similar to the following:

2026-01-02T14:01:18.638861Z [trace=trace-4c3f324f-bc37-4141-a0dc-8062c31423ea span=root] Handling request
2026-01-02T14:01:18.943390Z [trace=trace-4c3f324f-bc37-4141-a0dc-8062c31423ea span=db] Querying database
2026-01-02T14:01:19.452530Z [trace=trace-4c3f324f-bc37-4141-a0dc-8062c31423ea span=http] Calling external service
2026-01-02T14:01:19.453604Z [trace=trace-4c3f324f-bc37-4141-a0dc-8062c31423ea span=root] Request completed

Note that the traceId remains consistent across the different operations, while the spanId changes to reflect the current operation being performed. This demonstrates how Scoped Values can effectively propagate context information through various layers of an application without explicit parameter passing.

Contrasting this with a ThreadLocal approach would involve more boilerplate code. Here’s how the same tracing functionality would look using ThreadLocal:

import java.time.Instant;
import java.util.UUID;

public class TracingDemo {

  record TraceContext(String traceId, String spanId) {}

  static final ThreadLocal<TraceContext> TRACE = new ThreadLocal<>(); // [!code highlight]

  public static void main(String[] args) {
    new TracingDemo().handleRequest();
  }

  void handleRequest() {
    TraceContext root = new TraceContext(newTraceId(), "root");
    TRACE.set(root); // [!code highlight]
    try {
      log("Handling request");
      queryDatabase();
      callExternalService();
      log("Request completed");
    } finally {
      // ⚠️ must remember to clean up, or else trace leaks!
      TRACE.remove(); // [!code highlight]
    }
  }

  void queryDatabase() {
    withSpan(
        "db",
        () -> {
          sleep(300);
          log("Querying database");
        });
  }

  void callExternalService() {
    withSpan(
        "http",
        () -> {
          sleep(500);
          log("Calling external service");
        });
  }

  void withSpan(String spanName, Runnable task) {
    TraceContext parent = TRACE.get();
    TraceContext child = new TraceContext(parent.traceId(), spanName);
    TRACE.set(child); // [!code highlight]
    try {
      task.run();
    } finally {
      // ⚠️ must remember to restore previous context
      TRACE.set(parent); // [!code highlight]
    }
  }

  static void log(String message) {
    TraceContext ctx = TRACE.get();
    System.out.printf(
        "%s [trace=%s span=%s] %s%n", Instant.now(), ctx.traceId(), ctx.spanId(), message);
  }

  static String newTraceId() {
    return "trace-" + UUID.randomUUID();
  }

  static void sleep(long millis) {
    try {
      Thread.sleep(millis);
    } catch (InterruptedException e) {
      throw new RuntimeException(e);
    }
  }
}

This approach produces the same output as the Scoped Values example, but can be more error-prone:

  • forgotten cleanup → trace leaks
  • exceptions break restoration
  • parallel execution loses context
  • virtual threads make it worse
  • nested spans are fragile

Takeaway: Scoped Values make contextual data propagation explicit, safe, and composable—without the lifecycle hazards of ThreadLocal.

Module Import Declarations

JEP 511

This feature introduces a new way to import all of the packages exported by a module using a single import statement. Two immediate benefits of this feature are:

  • avoid the noise of multiple type-import-on-demand declarations (e.g., import com.foo.bar.*)
  • beginner-friendly way to start using core Java APIs and third-party libraries and fundamental Java classes without having to learn where they are located in a package hierarchy.

The syntax for a module import declaration is: import module <module-name>;

Let’s look at an example of how to use module import declarations.

import module java.base; // [!code highlight]

public class ModuleImportDemo {
  public static void main(String[] args) {
    var strings = new String[] {"apple", "berry", "citrus"};
    Function<String, String> firstLetterUppercase = s -> s.toUpperCase().substring(0, 1);
    Map<String, String> map =
        Stream.of(strings).collect(Collectors.toMap(firstLetterUppercase, Function.identity()));
    IO.println(map);
  }
}

Without the module import declaration, you would need to either import each type individually or use type-import-on-demand declarations, i.e.:

import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;

or

import java.util.*;
import java.util.function.*;
import java.util.stream.*;

For compact source files, the java.base module is implicitly imported, which means that the example above could be written like this:

// Compact source file with implicit java.base module import
void main() {
    var strings = new String[] {"apple", "berry", "citrus"};
    Function<String, String> firstLetterUppercase = s -> s.toUpperCase().substring(0, 1);
    Map<String, String> map =
        Stream.of(strings).collect(Collectors.toMap(firstLetterUppercase, Function.identity()));
    IO.println(map);
  }

In case of ambiguous imports, adding a single-type-import declaration resolves the ambiguousity:

import module java.sql;
// import module java.base; -> not needed as it's implicitly imported in compact source files

// without the single-type-import declaration below, Date would be ambiguous
import java.util.Date;

void main() {
    IO.println(new Date());
}

Compact Source Files and Instance Main Methods

JEP 512

The goal of this feature is to make Java more approachable for small programs, scripts, and learning scenarios—without removing the option to write fully structured code.

Do not fret if the terms “compact source files” and “instance main methods” are new to you and maybe somewhat confusing. In essence, this feature introduces a more concise way to write Java programs by allowing you to omit boilerplate code such as class declarations and static main methods.

Basically, we can get from this:

// in a file named HelloWorld.java
public class HelloWorld {
    public static void main(String[] args) {
        System.out.println("Hello, World!");
    }
}

to this:

// in a file with any name, as long as the name is a valid Java class name
// this is a ⚠️ compact source file

void main() { // <-- this is the ⚠️ instance main method
    System.out.println("Hello, World!");
}

Both versions can be run by either compiling them with javac and then running with java, or by running them directly with the source-code launcher.

A “half-way” version is to write the code like this:

class Jep512 {
  void main() { // <-- this is the ⚠️ instance main method
    System.out.println("Hello, World!");
  }
}

This can be run the same as before.

Last two versions can be slightly modified to accept command-line arguments, as well. From void main() to void main(String[] args). Furthermore, the println statement can be simplified to just IO.println("Hello, World!");.

Let’s look at another example:

// in a file named args_printer.java
void main(String[] args) {
  IO.println(Arrays.toString(args));
}

This just works! But, why? How come String, IO and Arrays are available without any imports? The answer is that compact source files implicitly import the java.base module, which exports the java.lang and java.util packages, wherein those types are located.

We could have written it like this:

void main(java.lang.String[] args) {
  java.lang.IO.println(java.util.Arrays.toString(args));
}

To check which packages are exported by the java.base module, you can run the following command:

java --describe-module java.base | grep exports | grep -E "java\.(lang|util)"

which produces the following output:

exports java.lang
# ... other exported packages ...
exports java.util
# ... other exported packages ...

Flexible Constructor Bodies

JEP 513

This feature allows statements to appear before an explicit constructor invocation, i.e., super(...) or this(...).

import module java.base;

public class JEP513 {
  void main() {
    var emp1 = new Employee("John", "Doe", 42, "Engineering");
    IO.println(emp1);
  }
}

class Person {
  final String firstName;
  final String lastName;
  final int age;

  Person(String firstName, String lastName, int age) {
    this.firstName = firstName;
    this.lastName = lastName;
    this.age = age;
  }
}

class Employee extends Person {
  final String department;

  Employee(String firstName, String lastName, int age, String department) {
    if (!List.of("HR", "Sales", "Engineering").contains(department)) {
      throw new IllegalArgumentException("'%s' department not valid.".formatted(department));
    }
    this.department = department;
    super(firstName, lastName, age); // [!code highlight]
  }

  @Override
  public String toString() {
    return "Employee(firstName=%s, lastName=%s, age=%d, department=%s)"
        .formatted(this.firstName, this.lastName, this.age, this.department);
  }
}

Compact Object Headers

JEP 519

The main idea here is to reduce the memory footprint of Java objects by optimizing the layout of object headers in memory. Various experiments demonstrate that enabling compact object headers improves performance:

  • 22% less heap space
  • 8% less CPU time
  • 15% less garbage collections with G1 and Parallel collectors

The feature can be enabled with the JVM option -XX:+CompactObjectHeaders.

$ java -XX:+UseCompactObjectHeaders MyProgram.java

Conclusion

Java 25 continues the shift toward safer concurrency, lower ceremony, and better performance—without abandoning Java’s core strengths. Features like Scoped Values and compact source files show a clear focus on modern workloads and developer ergonomics.