Saturday, October 4, 2025

Java 21 Virtual Threads: Revolutionizing Concurrency and Migrating from Traditional Threads

Java 21 marks a pivotal moment in JVM history — the arrival of Virtual Threads, a game-changing feature from Project Loom.

For decades, Java’s concurrency model has relied on OS-level threads, each carrying significant memory and context-switch overhead. That model struggled under modern workloads — think thousands of concurrent web requests, reactive microservices, and async I/O.

With Virtual Threads, Java finally delivers structured concurrency and massive scalability without abandoning its core threading APIs.

In this post, you’ll learn:

  • How Virtual Threads work internally

  • How to migrate from traditional threads

  • Real-world code comparisons and performance benchmarks

  • Best practices for production-ready adoption

Background: Why Java’s Thread Model Needed

a Revolution

The Problem with Traditional Threads

A traditional Java thread is one-to-one mapped to an OS thread.
That means:

  • Each thread consumes ~1 MB stack memory

  • Switching between threads involves kernel-level context switching

  • You can’t easily scale beyond a few thousand threads before hitting resource limits

This model was fine for the 2000s, but not for microservice-era concurrency, where a single app might handle tens of thousands of connections.

Enter Project Loom

  • Project Loom’s mission was simple but radical:
    • “Make concurrency simple, efficient, and scalable by reimagining the Java thread itself.”
  • Virtual Threads achieve this by decoupling Java threads from OS threads, allowing millions of concurrent tasks with minimal overhead.

Key Concepts & Foundations

What Are Virtual Threads?

  • Virtual Threads are lightweight, user-mode threads managed by the JVM, not the OS.
  • They use the same java.lang.Thread API — meaning no new learning curve — but they’re scheduled by the JVM’s virtual thread scheduler, which multiplexes many virtual threads over a smaller pool of carrier (platform) threads.

Core Principles

  • One API, two execution models: same Thread API for virtual and platform threads

  • Continuation-based scheduling: the JVM parks and resumes virtual threads efficiently

  • Massive scalability: millions of concurrent virtual threads possible

  • Non-blocking by design: integrates beautifully with I/O

Deep Dive: How Virtual Threads Work Under the Hood

Architecture Diagram: Virtual Threads in Action

Diagram:

  • Virtual Threads (user-level)
  • JVM Scheduler
  • Carrier Threads (OS-level)
  • Blocking I/O points causing “pinning”

Simplified Flow

  1. Virtual Thread starts on a carrier thread.
  2. When it hits a blocking I/O call (e.g., socket read), the JVM parks it and frees the carrier thread.
  3. Once the I/O completes, the virtual thread is resumed — without ever involving the OS.

Code Example: Traditional vs Virtual Threads:

Traditional Thread Example

ExecutorService executor = Executors.newFixedThreadPool(100); for (int i = 0; i < 1000; i++) { executor.submit(() -> { Thread.sleep(1000); System.out.println(Thread.currentThread().getName()); }); } executor.shutdown();

  • Creates 100 fixed OS threads.
  • Blocks threads during sleep.
  • Scales poorly with large task counts.

Virtual Thread Example (Java 21)

ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); for (int i = 0; i < 1000; i++) { executor.submit(() -> { Thread.sleep(1000); System.out.println(Thread.currentThread().getName()); }); } executor.shutdown();

  • Creates a new virtual thread per task.
  • JVM handles scheduling & parking.
  • Scales to millions of concurrent tasks.

Performance Benchmark: Real-World Proof


Example Benchmark Code

public class VirtualThreadBenchmark { public static void main(String[] args) throws Exception { long start = System.currentTimeMillis(); try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { IntStream.range(0, 1_000_000).forEach(i -> executor.submit(() -> Thread.sleep(100)) ); } long end = System.currentTimeMillis(); System.out.println("Total time: " + (end - start) + " ms"); } }
✅ Result: Handles 1 million sleeping tasks effortlessly — something impossible with platform threads.

Throughput Comparison:

  • Figure 1: Throughput (tasks/sec)

Analysis:

  • Traditional threads degrade sharply beyond 10K concurrent tasks due to OS scheduling limits.
  • Virtual threads sustain near-constant throughput even at 1M tasks, thanks to continuation-based scheduling inside the JVM.
  • The JVM dynamically parks and resumes virtual threads without invoking costly kernel-level context switches.

Memory Usage Comparison
          • Figure 2: Memory Usage (MB)

Analysis:

  • Traditional threads consume ~1 MB per thread, hitting multi-gigabyte memory usage by 100K threads.

  • Virtual threads remain lightweight — around 2 KB stack footprint each — with JVM-managed stack segmentation.

  • At 1M concurrent tasks, virtual threads use over 95% less memory, demonstrating true scalability for microservices and concurrent backends.

Conclusion of Benchmarks

  • These results validate the performance claims:
    • 10–15× concurrency increase
    • Up to 95% lower memory footprint
    • Order-of-magnitude faster thread creation and scheduling

  • This makes virtual threads ideal for server applications, RPC frameworks, and reactive replacements without adopting new paradigms.

Migrating from Traditional to Virtual Threads

Step 1: Identify Blocking Code

Focus on:

  • I/O-bound operations (network, DB, file I/O)
  • Thread pools using Executors.newFixedThreadPool

Step 2: Replace Executor

// Before ExecutorService executor = Executors.newFixedThreadPool(200); // After ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

Step 3: Verify Non-Pinned Blocking Calls

Avoid “pinning” (when a virtual thread cannot unmount from its carrier).
Pinning occurs if:

  • You block inside a synchronized method/block

  • You use native code that doesn’t yield

  • ✅ Use structured concurrency APIs (StructuredTaskScope) to manage lifetime safely.

Best Practices & Tips

  • Use virtual threads for I/O-bound workloads
  • Avoid long CPU-bound tasks — use traditional threads for those.
  • Avoid synchronization primitives (synchronized, wait, notify)
  • Use modern concurrency tools like CompletableFuture or structured tasks.
  • Monitor pinning using jdk.tracePinnedThreads=true JVM flag

Comparisons & Alternatives

Performance & Scaling Insights

  • Virtual Threads reduce context switching cost by 90–95%.
  • Memory footprint per thread drops from MBs to KBs.
  • Scheduler scales efficiently on modern multicore CPUs.
  • Perfect for REST APIs, database access layers, message-driven systems

Conclusion

  • Virtual Threads are the most significant concurrency innovation in Java since 1995.
  • They democratize scalability — letting any developer write high-throughput, simple, and maintainable concurrent code.

Key Takeaways

  • Use Executors.newVirtualThreadPerTaskExecutor() for easy adoption.

  • Ideal for blocking I/O tasks (network, DB).

  • Avoid synchronization that pins carrier threads.

  • Expect 10x–100x concurrency improvements with minimal code changes.

You may also like

Kubernetes Microservices
Python AI/ML
Spring Framework Spring Boot
Core Java Java Coding Question
Maven AWS