Java 25 is the latest LTS release at the time of writing, following JDK 21 as the previous LTS. For teams upgrading from 21, the jump is smaller than the one from 17→21 was — but there are several features that change how backend code is written. This article focuses on what actually matters in production.

Why upgrade from 21 to 25

If you’re on 21 and it’s working, you’re not in a hurry. But 25 is an LTS, which means:

  • Five to eight years of vendor support depending on distribution
  • Performance improvements compound with each JDK release (10-15% throughput on typical workloads over 21)
  • Some long-preview features finally go final

Upgrade when convenient, not because of panic. Boring Java is good Java.

Final features worth adopting

Compact Source Files and Instance Main Methods (final)

You can now write a Java program without declaring a class:

void main() {
    System.out.println("Hello");
}

Mainly useful for scripts, teaching, and small utilities. Doesn’t change how production services are structured.

Module Import Declarations (final)

import module java.base; imports everything exported by a module. Reduces import spaghetti in small files; rarely used in large services where explicit imports are preferred for clarity.

Stream Gatherers (final)

Custom intermediate stream operations. Useful for windowing, sliding operations, and stateful transformations that previously required breaking out of stream pipelines:

List<List<Integer>> windows = Stream.of(1,2,3,4,5,6,7)
    .gather(Gatherers.windowSliding(3))
    .toList();
// [[1,2,3], [2,3,4], [3,4,5], [4,5,6], [5,6,7]]

Production uses: time-series windowing, batch chunking, group-changing iteration.

Scoped Values (final)

A safer alternative to ThreadLocal, designed for virtual threads:

final static ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();

ScopedValue.where(REQUEST_ID, "req-123").run(() -> {
    // REQUEST_ID.get() returns "req-123" in this scope and children
    handleRequest();
});

Key advantages over ThreadLocal:

  • Immutable within a scope — no accidental mutations
  • Cheap with virtual threads — no per-thread overhead
  • Structured lifetime — values disappear automatically when the scope ends

Worth adopting now for new code that used ThreadLocal for request context propagation.

Structured Concurrency (final, was long preview)

Bundling related concurrent tasks so they succeed or fail together:

try (var scope = StructuredTaskScope.open()) {
    Subtask<User> user = scope.fork(() -> userService.find(userId));
    Subtask<List<Order>> orders = scope.fork(() -> orderService.recent(userId));
    Subtask<Loyalty> loyalty = scope.fork(() -> loyaltyService.status(userId));

    scope.join();

    return new ProfilePage(user.get(), orders.get(), loyalty.get());
}

If one fork fails, others are cancelled. No more manual Future/CompletableFuture juggling for fan-out.

Combined with virtual threads, this is the cleanest concurrency model Java has ever had.

Preview / incubating features to watch

Primitive Types in Patterns (preview)

Extending pattern matching to primitive conversions and type tests:

Object value = 42L;
String desc = switch (value) {
    case int i -> "small int: " + i;
    case long l -> "big int: " + l;
    case Double d -> "decimal: " + d;
    default -> "other";
};

Not final yet. Usable in preview for experiments.

Stable Values (preview)

A new kind of field that’s guaranteed-immutable after first initialization, enabling JIT optimizations:

private static final StableValue<ExpensiveConfig> CONFIG = StableValue.of();

public Config config() {
    return CONFIG.orElseSet(() -> loadConfig());
}

Like Lazy but with stronger JVM knowledge, potentially faster access than volatile/synchronized patterns.

Vector API (eighth incubator)

SIMD operations. Mostly relevant for numeric workloads (ML, encoding, compression). Typical backend code doesn’t need it.

Performance improvements

Quiet but meaningful:

  • Compact Object Headers (JEP, experimental in 25, likely on-by-default in a future release) — saves ~4-8 bytes per object. On apps with tens of millions of objects, 5-15% heap savings.
  • Ahead-of-Time Class Loading & Linking (AOT) — faster startup for large apps; particularly good for Spring Boot services with heavy context loading.
  • G1 improvements — better latency in highly concurrent workloads.
  • Continued ZGC refinement — sub-millisecond pauses more reliable in multi-GB heaps.

Upgrading a well-tuned 21 service to 25 typically gives 5-15% throughput and 10-30% faster startup with zero code changes. Worth the migration for that alone.

Should you enable previews?

In production code: no. Preview features can change semantics between releases. Regressing because you relied on a signature that shifted is an avoidable pain.

In prototype / POC: yes. The compiler gates them with --enable-preview. Safe playground.

Migration from 21 to 25

Usually uneventful:

  1. Bump JDK in base image
  2. Bump build tool jdkVersion / sourceCompatibility
  3. Rebuild — most issues surface here
  4. Run full test suite
  5. Performance-test in staging — ideally 1 week of shadow-mode traffic

The typical issues I’ve seen: libraries doing internal reflection tricks that depend on specific JDK internals (Lombok, Mockito). Update them alongside the JDK; most vendors support the new LTS quickly.

Closing thought

Java 25 continues the “small releases, big compounding effect” pattern that started with the six-month cadence. Individual features look incremental; five years of accumulated features add up to a much more modern language. Adopt the ones that fit your code — structured concurrency, scoped values, stream gatherers, the record-sealed-pattern trio — and let the rest wait until you hit their specific problem.