Java Virtual Threads: Lightweight Concurrency
Java developers have wrestled with concurrency limitations for decades. The traditional threading model maps each Java thread directly to an operating system thread, and this 1:1 relationship creates...
Key Insights
- Virtual threads are lightweight, JVM-managed threads that eliminate the memory overhead of platform threads, enabling millions of concurrent tasks with minimal resource consumption.
- They excel at I/O-bound workloads where blocking operations automatically unmount from carrier threads, but offer no advantage for CPU-bound computations.
- Thread pinning in synchronized blocks and ThreadLocal memory accumulation are the primary pitfalls to watch for during migration.
The Concurrency Problem
Java developers have wrestled with concurrency limitations for decades. The traditional threading model maps each Java thread directly to an operating system thread, and this 1:1 relationship creates significant constraints. Each platform thread consumes roughly 1MB of stack memory by default, and the OS imposes hard limits on how many threads a process can spawn.
Context switching between thousands of OS threads becomes expensive. The kernel must save and restore register states, flush caches, and manage scheduling overhead. When your web server needs to handle 10,000 concurrent connections, dedicating a thread to each request becomes impractical. This reality pushed the Java ecosystem toward reactive programming—frameworks like Project Reactor and RxJava that use callback-based, non-blocking patterns to multiplex many tasks onto few threads.
Reactive code works, but it fragments your logic across callback chains, complicates debugging, and demands developers learn an entirely different programming paradigm. Java 21 introduced virtual threads as a better solution: keep the simple, synchronous programming model while scaling to millions of concurrent tasks.
Platform Threads vs. Virtual Threads
Platform threads are thin wrappers around OS threads. When you create one, the JVM asks the operating system to allocate a kernel thread, complete with its own stack space and scheduling overhead. The OS manages these threads, decides when they run, and handles preemption.
Virtual threads flip this model. They’re managed entirely by the JVM, scheduled onto a pool of carrier threads (which are platform threads). When a virtual thread blocks—waiting for I/O, sleeping, or acquiring a lock—the JVM unmounts it from its carrier thread. That carrier becomes available to run other virtual threads. When the blocking operation completes, the JVM remounts the virtual thread onto an available carrier and resumes execution.
This multiplexing means you can have millions of virtual threads backed by a handful of carrier threads (typically matching your CPU core count). The memory footprint drops dramatically because virtual thread stacks start small and grow on demand, stored in heap memory rather than requiring contiguous OS-allocated stack space.
public class ThreadComparison {
public static void main(String[] args) throws InterruptedException {
// Platform threads: This will likely fail or crawl
long startPlatform = System.currentTimeMillis();
try {
List<Thread> platformThreads = new ArrayList<>();
for (int i = 0; i < 10_000; i++) {
Thread t = new Thread(() -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
t.start();
platformThreads.add(t);
}
for (Thread t : platformThreads) {
t.join();
}
System.out.println("Platform threads completed in: " +
(System.currentTimeMillis() - startPlatform) + "ms");
} catch (OutOfMemoryError e) {
System.out.println("Platform threads failed: " + e.getMessage());
}
// Virtual threads: Handles this easily
long startVirtual = System.currentTimeMillis();
List<Thread> virtualThreads = new ArrayList<>();
for (int i = 0; i < 10_000; i++) {
Thread t = Thread.ofVirtual().start(() -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
virtualThreads.add(t);
}
for (Thread t : virtualThreads) {
t.join();
}
System.out.println("Virtual threads completed in: " +
(System.currentTimeMillis() - startVirtual) + "ms");
}
}
Run this on a typical machine and the platform thread version will either throw OutOfMemoryError or take significantly longer due to OS scheduling overhead. The virtual thread version completes in roughly one second.
Creating and Using Virtual Threads
Java provides several APIs for creating virtual threads. The most direct approach uses the builder pattern:
// Create and start immediately
Thread vThread = Thread.ofVirtual()
.name("worker-", 0) // Names: worker-0, worker-1, etc.
.start(() -> processTask());
// Create without starting
Thread unstarted = Thread.ofVirtual()
.unstarted(() -> processTask());
unstarted.start();
For most server applications, the executor service pattern integrates better with existing code:
// One virtual thread per submitted task
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<String>> futures = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
final int taskId = i;
futures.add(executor.submit(() -> fetchDataFromApi(taskId)));
}
for (Future<String> future : futures) {
String result = future.get();
processResult(result);
}
} // Executor shuts down here, waits for all tasks
The newVirtualThreadPerTaskExecutor() creates a new virtual thread for each submitted task. Unlike traditional thread pools, there’s no queue—tasks start immediately on their own virtual thread. This executor is also AutoCloseable, shutting down and awaiting termination when the try-with-resources block exits.
When Virtual Threads Shine: I/O-Bound Workloads
Virtual threads transform I/O-bound applications. Consider a service that aggregates data from multiple external APIs:
public class ApiAggregator {
private final HttpClient client = HttpClient.newHttpClient();
// Traditional approach: limited by thread pool size
public List<String> fetchWithThreadPool(List<String> urls)
throws InterruptedException, ExecutionException {
ExecutorService pool = Executors.newFixedThreadPool(100);
try {
List<Future<String>> futures = urls.stream()
.map(url -> pool.submit(() -> fetch(url)))
.toList();
List<String> results = new ArrayList<>();
for (Future<String> f : futures) {
results.add(f.get());
}
return results;
} finally {
pool.shutdown();
}
}
// Virtual threads: scales to thousands of concurrent requests
public List<String> fetchWithVirtualThreads(List<String> urls)
throws InterruptedException, ExecutionException {
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<String>> futures = urls.stream()
.map(url -> executor.submit(() -> fetch(url)))
.toList();
List<String> results = new ArrayList<>();
for (Future<String> f : futures) {
results.add(f.get());
}
return results;
}
}
private String fetch(String url) throws IOException, InterruptedException {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.GET()
.build();
return client.send(request, HttpResponse.BodyHandlers.ofString()).body();
}
}
With a fixed thread pool of 100 threads, fetching 5,000 URLs means only 100 requests execute concurrently. The rest queue up. With virtual threads, all 5,000 requests can proceed simultaneously—each virtual thread blocks on I/O, unmounts from its carrier, and the carrier picks up another virtual thread. When responses arrive, virtual threads remount and continue.
This pattern applies universally to I/O-bound work: database queries, file operations, network calls, message queue interactions. Any blocking operation that previously required careful thread pool tuning now “just works.”
Pitfalls and Limitations
Virtual threads aren’t magic. Several gotchas can undermine their benefits.
Thread Pinning occurs when a virtual thread cannot unmount from its carrier. The two main causes are synchronized blocks and native method calls. When a virtual thread enters a synchronized block, it pins to its carrier for the duration:
public class PinningExample {
private final Object lock = new Object();
private final ReentrantLock reentrantLock = new ReentrantLock();
// BAD: Causes thread pinning
public void synchronizedApproach() {
synchronized (lock) {
performBlockingIO(); // Carrier thread is blocked!
}
}
// GOOD: No pinning with ReentrantLock
public void reentrantLockApproach() {
reentrantLock.lock();
try {
performBlockingIO(); // Virtual thread unmounts properly
} finally {
reentrantLock.unlock();
}
}
private void performBlockingIO() {
try {
Thread.sleep(1000); // Simulates blocking I/O
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
Use -Djdk.tracePinnedThreads=full to detect pinning during development.
CPU-bound work gains nothing from virtual threads. If your tasks compute without blocking, virtual threads add overhead without benefit. Stick with platform thread pools sized to your CPU core count for computational workloads.
ThreadLocal accumulation becomes problematic at scale. A ThreadLocal that stores a few kilobytes per thread is fine with 200 platform threads. With 2 million virtual threads, that’s gigabytes of heap consumption. Audit ThreadLocal usage before migrating.
Migration Strategies
Start by identifying candidates: services with high concurrency requirements and I/O-bound workloads. Web servers handling many concurrent requests are prime targets.
Spring Boot 3.2+ makes adoption trivial:
# application.yml
spring:
threads:
virtual:
enabled: true
This configures Tomcat to use virtual threads for request handling. Each incoming request gets its own virtual thread instead of borrowing from a fixed pool.
Quarkus supports virtual threads via annotations:
@Path("/api")
@RunOnVirtualThread
public class MyResource {
@GET
public String getData() {
return blockingDatabaseCall();
}
}
For custom code, replace Executors.newFixedThreadPool() calls with Executors.newVirtualThreadPerTaskExecutor() and test thoroughly. Monitor for pinning events and ThreadLocal memory growth. Run load tests comparing throughput and latency against your existing implementation.
Conclusion
Virtual threads represent the most significant change to Java concurrency since the introduction of the java.util.concurrent package. They eliminate the thread-per-request scalability bottleneck without forcing developers into reactive programming patterns.
Use virtual threads for I/O-bound workloads where you previously needed careful thread pool tuning or reactive frameworks. Keep platform threads for CPU-bound computation and cases where you need precise control over parallelism. Replace synchronized with ReentrantLock in hot paths, audit your ThreadLocal usage, and enjoy writing straightforward blocking code that scales to millions of concurrent operations.