JDK 26 Features: Hardening Project Loom, Virtual Thread Scheduler Improvements, and JVM Tuning

JDK26 Enhancements | Code2Java

JDK 26 is a non-LTS release, and it is easy to read non-LTS releases as incremental noise. That would be the wrong read of JDK 26. This release is the first version after JDK 25 LTS, which means it is where the features that arrived stable in JDK 25 — Structured Concurrency, Scoped Values, virtual thread scheduling, the Class-File API — get their first significant round of real-world feedback incorporated. The pattern for non-LTS releases in the Loom and Amber era is: introduce in preview, refine based on production usage, finalize in LTS, then harden in the release immediately after. JDK 26 is the hardening release.

Understanding what changed in JDK 26 requires understanding why features that were already finalized still benefit from continued improvement. APIs can be finalized without being fully optimized. The JVM scheduler can be correct without being optimal under every workload. The Class-File API can be stable without having the cleanest possible surface area. Non-LTS releases after an LTS are where this work happens without breaking the stability guarantees that LTS users depend on.

Structured Concurrency: Cancellation Semantics and Failure Propagation

Structured Concurrency was finalized in JDK 25. In JDK 26, the refinements are behavioral: more predictable cancellation semantics and cleaner failure propagation across task boundaries. The core change is in how ShutdownOnFailure and ShutdownOnSuccess handle the window between the first failure and the cancellation signal reaching remaining forks. In JDK 25, the timing of when pending forks saw interruption could vary under load. JDK 26 tightens this, making cancellation propagation more deterministic.

For production systems, this matters when you are using Structured Concurrency for fan-out patterns — sending the same query to multiple replicas and taking the first result, or fetching from N services and failing fast on the first error. The improved cancellation semantics reduce the window where a cancelled fork can still consume resources (a database connection, a file handle) before it detects the interrupt.

// Java 26+ — Structured Concurrency with improved cancellation semantics
// ShutdownOnSuccess cancels remaining forks as soon as any fork returns a result
import jdk.incubator.concurrent.StructuredTaskScope;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;

public class ReplicaReadService {
    private static final Logger log = LoggerFactory.getLogger(ReplicaReadService.class);

    // Query two read replicas — use whichever responds first, cancel the other
    public String readFromFastestReplica(String query, javax.sql.DataSource primary,
                                          javax.sql.DataSource replica) throws Exception {
        try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {

            // Both forks start immediately on virtual threads
            scope.fork(() -> executeQuery(query, primary, "primary"));
            scope.fork(() -> executeQuery(query, replica, "replica"));

            // join() returns as soon as ShutdownOnSuccess has a result
            // The other fork receives an interrupt signal — JDK 26 makes this deterministic
            scope.join();

            // result() returns the first successful result
            return scope.result();
        }
    }

    private String executeQuery(String query, javax.sql.DataSource ds, String source) throws Exception {
        try (Connection conn = ds.getConnection();
             PreparedStatement ps = conn.prepareStatement(query)) {
            ResultSet rs = ps.executeQuery(); // blocks; virtual thread unmounts during wait
            String result = rs.next() ? rs.getString(1) : null;
            log.debug("Query returned from {} replica", source);
            return result;
        }
        // Connection closed here — interrupt from scope reaches this code promptly in JDK 26
    }
}Code language: Java (java)

Scoped Values: Deeper Loom Integration

Scoped Values (JEP 506) were finalized in JDK 25. In JDK 26, the improvement is in how Scoped Values integrate with the broader Loom APIs. Specifically, the propagation of Scoped Values into forked virtual threads becomes cleaner at the JVM level — the JVM’s handling of the binding inheritance mechanism is refined to reduce overhead in scenarios with deeply nested scopes or high fork rates.

The visible effect in application code is that reading a ScopedValue inside a fork that is itself inside a structured scope — a pattern that appears constantly in request-handling pipelines — no longer has the small overhead that came from the multi-level lookup in JDK 25. For services handling tens of thousands of concurrent virtual threads, the aggregate effect is measurable, though the code does not need to change.

// Java 26+ — Scoped Values propagating cleanly into nested structured scopes
// Code is unchanged from JDK 25; the improvement is in JVM-level propagation efficiency
import jdk.incubator.concurrent.ScopedValue;
import jdk.incubator.concurrent.StructuredTaskScope;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.sql.Connection;
import java.sql.PreparedStatement;

public class AuditedOrderPipeline {
    private static final Logger log = LoggerFactory.getLogger(AuditedOrderPipeline.class);

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

    public void processOrder(String reqId, String userId, javax.sql.DataSource ds) throws Exception {
        ScopedValue.where(REQUEST_ID, reqId)
                   .where(AUDIT_USER, userId)
                   .run(() -> runOrderPipeline(ds));
    }

    private void runOrderPipeline(javax.sql.DataSource ds) throws Exception {
        try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
            // Both forks inherit REQUEST_ID and AUDIT_USER from the enclosing bound scope
            // JDK 26: lookup is cheaper in nested scopes than in JDK 25
            scope.fork(() -> validateAndPersist(ds));
            scope.fork(() -> writeAuditLog(ds));
            scope.join();
            scope.throwIfFailed();
        }
    }

    private String validateAndPersist(javax.sql.DataSource ds) throws Exception {
        // ScopedValue.get() is safe inside forks — binding propagates from parent scope
        log.info("Persisting order for req={}", REQUEST_ID.get());
        try (Connection conn = ds.getConnection();
             PreparedStatement ps = conn.prepareStatement(
                 "INSERT INTO orders (request_id, created_by) VALUES (?, ?)")) {
            ps.setString(1, REQUEST_ID.get());
            ps.setString(2, AUDIT_USER.get());
            ps.executeUpdate();
            return "persisted";
        }
    }

    private String writeAuditLog(javax.sql.DataSource ds) throws Exception {
        log.debug("Writing audit record for req={} by={}", REQUEST_ID.get(), AUDIT_USER.get());
        try (Connection conn = ds.getConnection();
             PreparedStatement ps = conn.prepareStatement(
                 "INSERT INTO audit_log (request_id, user_id, action) VALUES (?, ?, 'ORDER_CREATE')")) {
            ps.setString(1, REQUEST_ID.get());
            ps.setString(2, AUDIT_USER.get());
            ps.executeUpdate();
            return "audit written";
        }
    }
}Code language: Java (java)

Virtual Thread Scheduler Improvements

Virtual threads have been stable since JDK 21, but the scheduler that manages them — which determines how virtual threads are mapped to carrier OS threads — has continued to receive tuning across every release since. In JDK 26, the changes target two areas: scheduling fairness under high load and memory efficiency for long-lived virtual threads.

Scheduling fairness addresses a specific pathology: under very high concurrency, some virtual threads could starve while others were continuously rescheduled. This happened because the scheduler used a work-stealing pool (ForkJoinPool) as its carrier thread manager, and work-stealing implementations can exhibit non-uniform scheduling under asymmetric workloads. JDK 26 improves the heuristics that govern when the scheduler intervenes to rebalance.

Memory efficiency improvements target virtual threads that block for extended periods on I/O — long-polling HTTP connections, slow consumers waiting on message queues. A virtual thread that is parked for minutes holds its continuation on the heap. JDK 26 reduces the baseline memory footprint of a parked virtual thread’s continuation, which matters for services maintaining thousands of persistent connections simultaneously. No code changes are required to benefit; the improvement is in the JVM’s heap allocation for continuation objects.

JVM Internals: GC, Startup, and JIT

ZGC and Shenandoah continue to receive tuning in JDK 26. The focus in this release is on region handling under allocation pressure — specifically, the behaviour when allocation rate temporarily exceeds the GC’s clearing rate. Earlier JVM versions would increase pause times or trigger stop-the-world fallbacks in this scenario. JDK 26 improves the adaptive mechanisms that anticipate allocation spikes and adjust the GC cycle frequency before a crisis develops. For latency-sensitive services, this reduces the tail latency spikes that previously appeared under bursty load patterns.

Class Data Sharing (CDS) improvements in JDK 26 reduce startup time for applications that use the default CDS archive. The CDS archive mechanism shares read-only JVM metadata across processes, reducing the work done at startup. JDK 26 expands the classes that can participate in CDS, including some classes from the JDK module system that were previously excluded. For microservices where startup time directly affects scaling latency, this improvement is observable without configuration changes — though applications that already use application-level CDS archives with -XX:ArchiveClassesAtExit will see smaller relative gains since they already benefit from the broader mechanism.

The JIT compiler in JDK 26 improves loop optimization for patterns that appear frequently in Stream pipeline execution, particularly when gatherers are involved. Stream operations with gatherers produce more complex loop structures than standard intermediate operations, and the JIT’s loop unrolling and vectorization passes in JDK 25 were not yet fully tuned for these patterns. JDK 26 closes a portion of that gap. The improvement is most visible in throughput-sensitive data processing pipelines that use gather() with windowFixed() or custom gatherers over large streams.

Class-File API Maturity

The Class-File API, which provides a standard JDK-supported way to read, write, and transform Java class files without third-party bytecode libraries like ASM or Javassist, stabilized in JDK 24. In JDK 26, the refinements are in API surface cleanliness and consistency. Certain builder patterns that were inconsistent in naming across JDK 24 and 25 are aligned. Several APIs that were technically finalized but had confusing semantics when used for bytecode transformation received clarifying documentation and minor behavioral corrections.

For most application developers, the Class-File API is relevant indirectly — it is what frameworks, build tools, and instrumentation agents use to manipulate bytecode. As frameworks migrate from ASM to the Class-File API, the improvements in JDK 26 make that migration less risky, because the API they target is more stable and consistent than the initial JDK 24 version. If you maintain a framework that does bytecode manipulation, JDK 26 is the version where adopting the Class-File API over ASM begins to make sense in earnest.

Interview Questions

What happens when a virtual thread is blocked on a long-polling HTTP connection and the carrier thread pool is fully occupied?

The virtual thread is parked — its continuation is stored on the heap — and the carrier thread is released to handle other runnable virtual threads. The virtual thread does not consume a carrier thread while blocked. The carrier thread pool size (controlled by jdk.virtualThreadScheduler.parallelism, defaulting to available processors) only limits the number of virtual threads that can be actively executing, not the number that can be parked waiting for I/O. A service can maintain hundreds of thousands of long-polling virtual threads simultaneously with only a small carrier thread pool, provided the carrier threads themselves are not pinned by synchronized blocks containing blocking calls.

How does the system behave when a ScopedValue is accessed from a thread that was not created inside a ScopedValue.where().run() binding?

ScopedValue.get() throws NoSuchElementException if called from a thread where the ScopedValue has no binding. Unlike ThreadLocal, which returns null or the initialValue when not set, ScopedValue makes the absence of a binding explicit. This is deliberate — unset ThreadLocals were a source of subtle bugs where code assumed a context was present but it was not, leading to null pointer exceptions or incorrect behaviour deep in the call stack. With ScopedValue, the absence is immediate and clear at the access site. The defensive pattern is to check scopedValue.isBound() before calling get(), or to use scopedValue.orElse(defaultValue).

What issues arise when a framework uses ASM for bytecode manipulation while running on JDK 26, and how does the Class-File API affect this?

ASM depends on knowing the class file format version to parse class files correctly. Each new Java release can introduce new class file attributes, and ASM releases must track the JDK to support the latest class file version. If an application runs on JDK 26 with an ASM version that does not support the JDK 26 class file format, ASM will reject the class files when it attempts to instrument them, throwing IllegalArgumentException: Unsupported class file major version N. This forces framework and library authors to ship ASM updates with each major JDK. The Class-File API, being part of the JDK, always supports the current class file version by definition, eliminating this maintenance coupling.

Summary

JDK 26 is a hardening release. The APIs it refines — Structured Concurrency, Scoped Values, the Class-File API — were finalized in JDK 25 LTS. JDK 26 makes them perform better under real-world load, behave more predictably at edge cases, and integrate more cleanly with each other. The virtual thread scheduler improvements reduce starvation risk under asymmetric load. The GC tuning reduces latency spikes under allocation pressure. If you are running on JDK 25 in production, upgrading to JDK 26 requires no code changes and delivers measurable improvements in tail latency, memory efficiency, and startup time for most I/O-bound Java services.

Leave a Comment

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.

Scroll to Top