Java 25 and the New Age of Performance: Virtual Threads and Beyond

Summary

Project Loom continues to reshape how we handle concurrency in the JVM, and Java 25 makes virtual threads more accessible to developers than ever. In this article, we’ll take a closer look at how virtual threads work, how they differ from platform threads, and where they provide the most benefit in real-world applications. We’ll also include performance benchmarks and a step-by-step guide for refactoring blocking I/O services to use virtual threads.

Introduction: The Evolution of Concurrency in Java

Concurrency has long been at the heart of Java. From the very beginning, with java.lang.Thread and the simple Runnable interface, developers could spin up concurrent tasks in just a few lines of code. Early synchronization tools like synchronized and wait/notify offered precise control, but as applications grew, these primitives often became tricky and error-prone to use.

Java 5 marked a major milestone with the introduction of the java.util.concurrent package. Executors, thread pools, and other high-level synchronization tools made it much easier to manage concurrency systematically. Even so, each thread remained an expensive OS resource, consuming roughly a megabyte of stack memory plus additional scheduling overhead. Java 7’s ForkJoinPool improved parallelism for divide-and-conquer algorithms, especially for CPU-bound tasks. Yet the core problem remained: heavyweight OS threads just couldn’t scale to millions of lightweight concurrent tasks.

In response, other languages and frameworks explored new ways to handle concurrency. Go brought goroutines, Kotlin introduced coroutines, and .NET leaned on async/await. Java developers tried reactive frameworks like RxJava, Project Reactor, and Akka to work around blocking I/O limits. Powerful as they were, these approaches often relied on callback-heavy patterns, new operators, and tricky debugging, making day-to-day development more challenging.

The traditional Java approach – one platform thread per task – just couldn’t keep up in a world with 10,000 open WebSocket connections, microservices waiting on remote APIs, or cloud servers handling hundreds of thousands of clients. Context switching between OS threads introduced latency, memory overhead limited throughput, and developers often had to trade simplicity for performance.

Project Loom turned that scenario on its head. With early previews in JDK 19 and 20 (JEP 425 and JEP 436) and full production support in JDK 21 (JEP 444), virtual threads made thread-per-task programming practical again. By JDK 25, Loom-related APIs matured even further: Structured Concurrency reached its fifth preview (JEP 505), Scoped Values were finalized (JEP 506), and diagnostic tools like JFR and jcmd now fully support the new model. This means developers can write simple, blocking code while still handling hundreds of thousands of concurrent operations on modest hardware.

What makes this change so remarkable is that it builds on abstractions developers already know instead of replacing them. A virtual thread is still a java.lang.Thread, so your favorite libraries, profilers, and debuggers continue to work exactly as before. Unlike coroutine libraries that need a separate DSL or runtime, Loom integrates concurrency straight into the JVM, handling scheduling and stack management behind the scenes. Developers get decades of ecosystem stability while now being able to run cloud-scale workloads with code that’s both simple and readable.

With JDK 25, Java closes a long chapter in its concurrency journey. From the early, rough days of Thread and synchronized in 1995, through the more structured APIs of Java 5, and even the reactive experiments of the 2010s, the platform has been searching for a way to perfect balance between simplicity and scalability. Now, virtual threads deliver on that promise, and with Loom becoming mainstream, a new era of Java concurrency has officially begun.

JAVA Virtual Threads Explained: Core Concepts

A virtual thread is a lightweight thread managed by the JVM rather than the OS, first introduced by Project Loom and finalized in JDK 21 (JEP 444). It’s still a java.lang.Thread just like a traditional platform thread, but it’s designed to let you run thousands – even millions – of concurrent tasks without eating up huge amounts of memory. Virtual threads shine in I/O-bound workloads, where blocking operations would normally throttle scalability, letting developers write straightforward, blocking code that scales effortlessly.

Lifecycle of Java Virtual Threads

Creating a virtual thread is simple:

Thread v = Thread.startVirtualThread(() -> {
       System.out.println("Running in virtual thread: " + Thread.currentThread());
});

Each virtual thread runs on what’s called a carrier thread – an OS thread that the JVM uses to execute virtual threads as needed. When a virtual thread hits a blocking operation, like network I/O or Thread.sleep(), the JVM automatically “unmounts” it from the carrier, freeing that OS thread to work on other virtual threads. Once the blocking operation finishes, the virtual thread is “remounted” and picks up right where it left off. This way, a single carrier can keep many virtual threads moving without wasting resources.

Notably, key aspects of a virtual thread’s lifecycle include:

  • Creation: extremely cheap in terms of time and memory.
  • Execution: multiplexed across carrier threads.
  • Blocking: does not block the carrier, allowing other tasks to run.
  • Termination: behaves like a standard thread; resources are reclaimed by the JVM.

Design Goals

As a result, virtual threads are built to tackle some of the biggest pain points with traditional platform threads. In particular, they aim to:

  • Simplify concurrency: let you write blocking code in a natural, sequential style – no need for callbacks, reactive streams, or convoluted async chains.
  • Scale efficiently: handle hundreds of thousands of concurrent tasks without overloading memory or CPU, so even cloud-scale workloads feel manageable.
  • Integrate with existing tools: work seamlessly with your current thread-based APIs, debuggers, and profilers – no learning a new runtime or rewriting libraries.

Observability and Tooling For java virtual threads

Existing Java tools mostly work just as they always have with virtual threads. The JVM now emits new JFR events for virtual thread start and end, as well as blocking and pinning, giving you real-time insight into thread behavior. Thread dumps (jcmd Thread.print) clearly show virtual threads, grouped by their carrier threads, making it much easier to see what’s happening in a high-concurrency application running in production.

Historical Context and Maturity

Virtual threads were previewed in:

  • JDK 19 (JEP 425): First preview, experimental APIs.
  • JDK 20 (JEP 436): Second preview, refinement based on feedback.
  • JDK 21 (JEP 444): Virtual threads finalized; standard APIs fully available. Between JDK 21 and JDK 25, iterative enhancements improved pinning behavior, debugging support, and executor implementations, creating a robust, production-ready feature set.

Examples of java virtual threads Use

For bulk task submission, virtual threads are simple to use:

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
     for (int i = 0; i < 10_000; i++) {
          executor.submit(() -> {
                Thread.sleep(50); // blocking I/O or simulated work
                System.out.println("Task done on " + Thread.currentThread());
          });
     }
}

This executor automatically handles mounting and unmounting of virtual threads, effortlessly scaling to thousands of tasks without the need for complex pooling logic.

By understanding these core concepts, developers can begin designing applications that fully take advantage of virtual threads’ lightweight nature – while still working with the familiar Java threading APIs they already know.

Under the Hood in JDK 25: Scheduling, Pinning, and Tooling

M:N Scheduling and Continuations

For platform threads, the OS scheduler does all the heavy lifting. Virtual threads, on the other hand, rely on the JDK’s scheduler to multiplex many lightweight threads onto a small number of carrier threads. Under the hood, they use continuations with heap-allocated, growable stack segments. When a blocking operation occurs, the continuation parks the virtual thread and unmounts it, freeing the carrier to run other tasks.

Pinning: What Changed Since JDK 21 for java virtual threads

Early in Loom’s lifecycle, certain constructs could pin a virtual thread to its carrier, preventing it from unmounting – most notably synchronized blocks/methods and some native operations. JDK 24 introduced JEP 491 (Synchronize Virtual Threads without Pinning), which removes most monitor-related pinning. In practice, entering a synchronized region no longer holds the carrier hostage if the virtual thread blocks inside it. Native blocking and a few corner cases can still cause pinning, but monitor usage is no longer the common footgun it once was.

Tooling

  • JFR now emits events like VirtualThreadStart/End, VirtualThreadPinned, and VirtualThreadSubmitFailed, providing valuable insight for adoption, monitoring, and troubleshooting.
  • jcmd offers a virtual-thread–aware thread dump (in text or JSON) that groups scopes and threads in a meaningful way – especially useful when working with Structured Concurrency.
  • JDK 21 (LTS) – Virtual Threads were finalized in JEP 444, while JEP 453 introduced Structured Concurrency (still in Preview). Scoped Values continue to evolve through their preview stages, giving developers a taste of the refined concurrency tools coming in future releases.
  • JDK 22–23 – Structured Concurrency and Scoped Values went through iterative previews and refinements. Developers also saw improved diagnostics and early-access updates designed to reduce pinning, making the adoption of Loom features smoother and more practical in real-world applications.
  • JDK 24JEP 491 significantly reduced pinning for synchronized (monitor) usage, easing a major pain point for developers. Structured Concurrency reached its fourth preview, giving a clearer picture of its capabilities. Additionally, improvements to JFR and jcmd brought several quality-of-life enhancements for monitoring and debugging high-concurrency applications.
  • JDK 25JEP 505 introduces Structured Concurrency in its fifth preview, giving developers a more refined view of how to structure concurrent tasks. JEP 506 finalizes Scoped Values, bringing stability to this key feature. On top of that, further updates to JFR and JMC, make monitoring and managing high-concurrency applications even smoother.

java virtual threads Performance in Practice

Virtual threads really shine in I/O-heavy workloads – where most tasks spend time waiting rather than using the CPU. To see this in action, consider a simple benchmark that launches 100,000 sleeping tasks. Each task simply sleeps for 50 milliseconds, simulating an I/O-bound operation.

Benchmark: 100k Sleeping Tasks

try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {
     long t0 = System.nanoTime();
     var futures = IntStream.range(0, 100_000)
                .mapToObj(i -> exec.submit(() -> { Thread.sleep(50); return i; }))
                .toList();
     for (var f : futures) f.get();
     long ms = (System.nanoTime() - t0) / 1_000_000;
     System.out.println("Completed in " + ms + " ms");
}

On a typical developer machine, these 100,000 sleeping tasks finish in just a few seconds with virtual threads. Try the same experiment with platform threads. You’ll either hit memory limits or wait much longer, because each blocked OS thread ties up significant resources.
This simple benchmark shows why virtual threads are a game-changer for I/O-heavy workloads. They let you write straightforward, blocking code that scales easily.

CPU-bound Workloads

If your tasks are CPU-bound rather than waiting on I/O, virtual threads won’t give much benefit. In those cases, it’s better to stick with ForkJoinPool, parallel streams, or other task-specific pools to manage parallelism efficiently. Remember: virtual threads excel at concurrency, not raw parallelism. They let you handle many tasks at once without blocking, but won’t speed up CPU-heavy work.

Migrating a Blocking Service to Virtual Threads

Consider a simple HTTP service that performs a downstream call and a database query per request.

Baseline: Bounded Thread Pool

var executor = java.util.concurrent.Executors.newFixedThreadPool(200);
// Each request uses a worker; under load, the queue grows and latency spikes.

Migration: Virtual Thread per Request

var executor = java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor();
// One lightweight thread per request; blocked calls unmount and free carriers.

Spring Boot sketch (servlet stack)

@Bean
ExecutorService requestExecutor() {
    return Executors.newVirtualThreadPerTaskExecutor();
}

Observations after migration

After migrating to virtual threads, a few key observations become clear. Applications often see dramatic improvements in concurrency, though some caveats remain. Here are the main takeaways:

  • Lower latency under high concurrency: Blocking is cheap, so tail latencies flatten and overall responsiveness improves.
  • Better memory usage: Running 100,000+ tasks no longer consumes massive amounts of memory as it would with platform threads.
  • Synchronized blocks are safer: With JDK 24+, common synchronized usage rarely causes severe pinning – but watch out for long-running native calls.
  • JDBC and external drivers: Virtual threads work well with most JDBC drivers, but drivers that heavily synchronize or invoke native code can still limit scalability. Use JFR VirtualThreadPinned events and jcmd dumps to monitor these situations.

Structured Concurrency & Scoped Values in JDK 25

Structured Concurrency (JEP 505 – Fifth Preview)

Structured Concurrency lets you think of a group of tasks as a single unit. You can start them together, handle failures in a coordinated way, and propagate cancellations smoothly. It works hand-in-hand with virtual threads, making it much easier to write clear, reliable concurrent code.

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    var user  = scope.fork(() -> userClient.fetch(userId));
    var orders = scope.fork(() -> orderClient.fetch(userId));

    scope.join();       // wait for both
    scope.throwIfFailed();

    return new UserView(user.resultNow(), orders.resultNow());
}

Note: In JDK 25 this API remains preview, so package names and minor signatures can differ from earlier previews. Importantly, always compile and run with the appropriate –enable-preview/–release flags for your toolchain when using SC in JDK 25.

Scoped Values (JEP 506 – Final)

ScopedValue<T> provides a safer and faster alternative to ThreadLocal for storing per-task context, such as request IDs or authentication tokens. Scoped values work well with virtual threads and structured scopes. They make it easier to manage context consistently across many concurrent tasks.

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

void handle(Request req) {
    ScopedValue.where(REQ_ID, req.id())
               .run(() -> service(req));
}

void service(Request req) {
    log.info("handling {} in {}", REQ_ID.get(), Thread.currentThread());
}

Best Practices

When adopting virtual threads, a few practical guidelines can help you get the most from them. They also help you avoid common pitfalls and keep your services scalable.

  • Prefer blocking I/O with virtual threads in services that spend most time waiting.
  • Don’t pool virtual threads, just use one per task. Instead of simply reducing thread counts, use rate limiters or semaphores to protect scarce resources.
  • Watch for pinning in native calls and unusual synchronization patterns and rely on JFR VirtualThreadPinned to spot issues.
  • Keep context out of ThreadLocal and favor Scoped Values.
  • Profile with JFR/JMC or modern profilers that understand virtual threads.
  • For CPU-bound code, bound parallelism explicitly because virtual threads won’t speed math.

Conclusion

Virtual threads have transformed the old “thread-per-request” approach from an anti-pattern into a practical default for high-throughput Java services. With JDK 21, they became stable, and by JDK 24, adoption was simpler thanks to the near-elimination of monitor-related pinning. JDK 25 completes the Loom story, bringing finalized Scoped Values and the most refined Structured Concurrency preview yet.

If you run blocking services on large thread pools or complex reactive pipelines, try refactoring them to use virtual threads. Measure the impact in your own environment. Many teams notice clear improvements in concurrency and scalability without changing their core code.


references

This article is part of the magazine issue Java 25 – Part 1.
You can read the complete issue with all contributions here.

Total
0
Shares
Previous Post

Conferences. How and Why

Next Post

Boxlang intellij ide released

Related Posts