25:00
Focus
Lesson 4
~12 min100 XP

Concurrency Models and Virtual Threads in Java

Introduction

Concurrency has always been one of Java's greatest strengths — and one of its most treacherous pitfalls. For decades, developers have wrestled with platform threads, thread pools, and the infamous complexity of writing correct concurrent code. With Project Loom and the introduction of virtual threads in Java 21, the landscape has fundamentally shifted. In this lesson, you'll master the evolution of Java's concurrency models, understand why virtual threads are revolutionary, and learn how to write highly scalable concurrent applications without the traditional headaches.

The Traditional Threading Model: Platform Threads

Every Java application starts with at least one thread — the main thread. Historically, when you created a thread using new Thread() or submitted a task to an ExecutorService, Java mapped each thread directly to an operating system (OS) thread. These are called platform threads (sometimes called "kernel threads" or "heavyweight threads").

Here's the critical thing to understand: OS threads are expensive resources. Each platform thread typically consumes around 1 MB of stack memory, and the OS kernel must schedule, context-switch, and manage each one. On a typical server, you might be able to run a few thousand platform threads before hitting resource limits — memory exhaustion, excessive context-switching overhead, or OS-level thread limits.

This is why the thread pool pattern became essential. Instead of creating a new thread for every task, you'd reuse a fixed number of threads:

ExecutorService executor = Executors.newFixedThreadPool(200);
executor.submit(() -> handleRequest(request));

The problem? If each task blocks — say, waiting for a database query or an HTTP response — that platform thread sits idle, consuming memory and an OS scheduling slot while doing absolutely nothing. This is the scalability wall that every Java server hits. If you have 10,000 concurrent requests each waiting on I/O, you'd need 10,000 threads, which is impractical with platform threads.

Developers worked around this with asynchronous programming — frameworks like CompletableFuture, RxJava, and reactive libraries (Project Reactor). These approaches avoid blocking threads by chaining callbacks, but they fracture your code's readability, destroy stack traces, and make debugging a nightmare. You trade scalability for complexity.

Key insight: The fundamental tension in traditional Java concurrency is between the simplicity of blocking code (one thread per task) and the scalability of non-blocking code (async/reactive). Virtual threads resolve this tension.

Exercise 1Multiple Choice
Why do platform threads create a scalability bottleneck for I/O-heavy applications?

Enter Project Loom: Virtual Threads

Project Loom is the OpenJDK initiative that introduced virtual threads — lightweight threads managed by the JVM rather than the OS. Virtual threads became a preview feature in Java 19 and were finalized in Java 21 (JEP 444).

The architecture is elegant: virtual threads are scheduled onto a small pool of platform threads called carrier threads. When a virtual thread blocks (e.g., on Thread.sleep(), socket I/O, or Lock.lock()), the JVM unmounts it from its carrier thread, freeing that carrier to run another virtual thread. When the blocking operation completes, the virtual thread is mounted back onto an available carrier.

Think of it like an airline overbooking model. An airline (the JVM) has 200 seats (carrier/platform threads) but sells 10,000 tickets (virtual threads) because it knows most passengers (tasks) will be waiting in the lounge (blocked on I/O) at any given time. Only the passengers actually flying need seats.

Creating a virtual thread is remarkably simple:

// Direct creation
Thread vThread = Thread.ofVirtual().start(() -> {
    System.out.println("Running on: " + Thread.currentThread());
});

// Using the factory method
Thread.startVirtualThread(() -> {
    // your task here
});

// Using an ExecutorService (most practical for applications)
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    executor.submit(() -> handleRequest(request));
}

The beauty of virtual threads is that your code looks exactly like traditional blocking code — no callbacks, no reactive chains, no CompletableFuture pipelines. You write straightforward sequential logic, and the JVM handles the scalability underneath.

Exercise 2True or False
Virtual threads are directly managed by the operating system kernel, just like platform threads.

Structured Concurrency: Taming the Chaos

One of the most powerful concepts introduced alongside virtual threads is structured concurrency (JEP 462, preview in Java 21+). Traditional concurrency is unstructured — you fire off threads or submit tasks to executors, and it's your responsibility to track them, handle their failures, and ensure they don't leak.

Structured concurrency enforces a simple rule: a task's lifetime is bounded by the scope that created it. If a parent task spawns child tasks, all children must complete before the parent completes. If one child fails, sibling tasks can be automatically cancelled.

The API centers around StructuredTaskScope:

import java.util.concurrent.StructuredTaskScope;

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    // Fork subtasks — each runs in its own virtual thread
    Subtask<String> user = scope.fork(() -> fetchUser(userId));
    Subtask<List<Order>> orders = scope.fork(() -> fetchOrders(userId));
    Subtask<Integer> loyaltyPoints = scope.fork(() -> fetchLoyaltyPoints(userId));

    // Wait for all subtasks to complete (or one to fail)
    scope.join();

    // Propagate any exceptions
    scope.throwIfFailed();

    // All results are now available
    return new UserProfile(user.get(), orders.get(), loyaltyPoints.get());
}

There are two built-in policies:

  • ShutdownOnFailure — if any subtask fails, cancel the remaining subtasks and propagate the error. This is the fan-out, all-must-succeed pattern.
  • ShutdownOnSuccess — as soon as one subtask succeeds, cancel the rest. This is the race/first-wins pattern.

Structured concurrency gives you three guarantees that unstructured concurrency never had:

  1. No leaked threads — all forked tasks complete before the scope closes.
  2. Error propagation — child failures are reliably surfaced to the parent.
  3. Cancellation propagation — cancelling the parent cancels all children.

Important: Structured concurrency is still a preview feature as of Java 23. You'll need --enable-preview to use it. However, virtual threads themselves are stable and production-ready since Java 21.

Exercise 3Multiple Choice
What happens when using StructuredTaskScope.ShutdownOnFailure if one of three forked subtasks throws an exception?

Pitfalls, Anti-Patterns, and What to Watch For

Virtual threads are not a magic bullet. There are specific scenarios where they don't help — or can even hurt performance — and understanding these is what separates competent developers from experts.

Pitfall 1: CPU-Bound Work

Virtual threads shine for I/O-bound tasks — database calls, HTTP requests, file operations, anything that involves waiting. For CPU-bound tasks (heavy computation, data processing, number crunching), virtual threads provide zero benefit. The bottleneck is CPU cores, not thread count. For CPU-bound parallelism, continue using ForkJoinPool or parallel streams.

Pitfall 2: Pinning

Pinning occurs when a virtual thread cannot be unmounted from its carrier thread. This happens in two situations:

  1. Inside a synchronized block or method — the JVM cannot unmount a virtual thread that holds a monitor lock.
  2. Inside a native method (JNI call) — native code isn't aware of virtual threads.

When pinning occurs, the carrier thread is blocked just like a platform thread, defeating the purpose of virtual threads.

// BAD — causes pinning
synchronized (lock) {
    // If this blocks (e.g., I/O), the carrier thread is pinned
    resultSet = statement.executeQuery(sql);
}

// GOOD — use ReentrantLock instead
private final ReentrantLock lock = new ReentrantLock();

lock.lock();
try {
    resultSet = statement.executeQuery(sql);
} finally {
    lock.unlock();
}

You can detect pinning at runtime with the JVM flag -Djdk.tracePinnedThreads=full.

Pitfall 3: Thread-Local Abuse

Thread-local variables (ThreadLocal) work with virtual threads, but they become a memory concern. With platform threads, you might have 200 threads each with a cached database connection in a ThreadLocal — that's 200 connections. With virtual threads, you might have 100,000 threads, creating 100,000 connections. This quickly exhausts connection pools and memory.

The solution is scoped values (ScopedValue, preview in Java 21+), which are designed for virtual threads:

private static final ScopedValue<UserContext> CONTEXT = ScopedValue.newInstance();

ScopedValue.runWhere(CONTEXT, new UserContext(userId), () -> {
    // All code in this scope (including virtual threads) can read CONTEXT
    handleRequest();
});

Pitfall 4: Pooling Virtual Threads

Never pool virtual threads. The entire point is that they're cheap to create and destroy. Using Executors.newFixedThreadPool(100) with virtual threads contradicts their design. Always use Executors.newVirtualThreadPerTaskExecutor().

Exercise 4Fill in the Blank
When a virtual thread executes code inside a ___ block and performs a blocking operation, it cannot be unmounted from its carrier thread, a problem known as pinning.

Migrating Existing Applications to Virtual Threads

One of Project Loom's most impressive design decisions is backward compatibility. In many cases, migrating an existing application to virtual threads is surprisingly straightforward — but there are important considerations.

Step 1: Identify Your Thread Pools

Search your codebase for ExecutorService creation points. The simplest migration is replacing fixed or cached thread pools used for I/O-bound tasks:

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

// After
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

For web frameworks, many already support virtual threads natively:

  • Spring Boot 3.2+: Set spring.threads.virtual.enabled=true in application.properties.
  • Quarkus: Configure quarkus.virtual-threads.enabled=true.
  • Helidon 4: Uses virtual threads by default.

Step 2: Audit for Pinning

Run your application with -Djdk.tracePinnedThreads=short and exercise your critical code paths. Look for pinning warnings in the output. Replace synchronized with ReentrantLock where pinning occurs on blocking operations.

Step 3: Audit Thread-Locals and Connection Pools

If your application uses ThreadLocal for caching objects (database connections, formatters, buffers), verify that these resources are properly bounded. With virtual threads, you could have orders of magnitude more threads than before. Ensure your connection pools have sensible maximum sizes — they become your actual concurrency limiter (which is correct).

Step 4: Benchmark and Validate

Virtual threads improve throughput (requests handled concurrently) but not latency (time for a single request). Measure your application under realistic concurrent load using tools like JMH, Gatling, or wrk. You should see dramatic improvements in maximum concurrent connections before degradation.

A word of caution: Don't migrate CPU-intensive thread pools to virtual threads. Keep ForkJoinPool or fixed-size pools for computation. The ideal architecture uses virtual threads for I/O coordination and platform thread pools for CPU-parallel computation.

The Bigger Picture: Concurrency Model Comparison

Understanding where virtual threads fit requires seeing the full landscape of Java concurrency models:

| Model | Scalability | Code Simplicity | Debugging | Best For | |---|---|---|---|---| | Platform Threads | Low (~thousands) | High (blocking) | Excellent | CPU-bound work | | Thread Pools | Medium | High (blocking) | Good | General purpose | | CompletableFuture | High | Medium (chaining) | Poor | Async orchestration | | Reactive (Reactor/RxJava) | Very High | Low (complex) | Very Poor | Streaming/backpressure | | Virtual Threads | Very High | High (blocking) | Excellent | I/O-bound work |

Virtual threads represent a paradigm shift in Java's approach to concurrency. Instead of forcing developers to choose between simplicity and scalability, they deliver both. You write blocking code — the simplest, most natural style — and the JVM transparently provides the scalability you'd previously need reactive frameworks to achieve.

However, reactive programming isn't dead. It still has advantages for streaming workloads with backpressure (processing data streams where the producer is faster than the consumer). Virtual threads solve the concurrency problem; reactive streams solve the data flow problem. They're complementary tools.

The trajectory is clear: Java's future favors virtual threads for general-purpose concurrent applications. Libraries and frameworks are rapidly adding support. JDBC drivers, HTTP clients, and messaging libraries are being updated to avoid pinning. The ecosystem is converging on this model.

Key Takeaways

  • Virtual threads are lightweight, JVM-managed threads that decouple application concurrency from OS thread limits, enabling millions of concurrent tasks with minimal memory overhead.
  • Virtual threads excel at I/O-bound workloads — replace fixed thread pools with Executors.newVirtualThreadPerTaskExecutor() for dramatic scalability improvements while keeping simple blocking code.
  • Avoid pinning by replacing synchronized blocks with ReentrantLock when the critical section contains blocking operations, and use -Djdk.tracePinnedThreads to detect issues.
  • Structured concurrency (StructuredTaskScope) brings discipline to concurrent task management by scoping task lifetimes, ensuring no leaked threads, automatic cancellation propagation, and reliable error handling.
  • Never pool virtual threads, and be cautious with ThreadLocal — use scoped values for thread-contextual data in virtual-thread-heavy applications.
  • Virtual threads complement, rather than replace, other concurrency tools — use ForkJoinPool for CPU-bound parallelism and reactive streams for backpressure scenarios.
Check Your Understanding

Java's concurrency model has evolved dramatically, from heavyweight platform threads mapped directly to OS threads, through thread pool patterns, to the revolutionary virtual threads introduced in Java 21 via Project Loom. **Exercise:** A colleague is designing a web service that needs to handle 50,000 simultaneous client connections, where each connection spends most of its time waiting on database queries and external API calls. They propose solving this by simply increasing the platform thread pool size to 50,000 threads. Explain why this approach is problematic, describe how virtual threads offer a fundamentally different solution to this scalability challenge, and walk through the key architectural difference between how platform threads and virtual threads relate to the underlying operating system. In your explanation, address what happens to a virtual thread when it encounters a blocking operation versus what happens to a platform thread in the same situation.

🔒Upgrade to submit written responses and get AI feedback
Go deeper
  • How do virtual threads handle blocking I/O differently?🔒
  • What's the performance cost of context-switching platform threads?🔒
  • Can virtual threads replace reactive programming like Project Reactor?🔒
  • How does the JVM schedule virtual threads internally?🔒
  • What happens when virtual threads use synchronized blocks?🔒