Virtual Threads in Java
Introduction
In modern backend development, handling thousands of concurrent connections efficiently is paramount. Historically, Java's approach to concurrency relied on Platform Threads, which are 1:1 wrappers around underlying OS threads. However, OS threads are expensive to create, consume significant memory, and limit an application's ability to scale.
Enter Virtual Threads, introduced natively in Java 21 (Project Loom). Virtual threads are lightweight threads that drastically reduce the effort of writing, maintaining, and observing high-throughput concurrent applications. They break the 1:1 coupling with OS threads, allowing you to run millions of concurrent tasks on a modest machine.
Core Concept: Deep Dive and Architecture
What are Virtual Threads?
A virtual thread is an instance of java.lang.Thread that is not tied to a particular OS thread. They are cheap, fast to create, and block gracefully without tying up native OS resources.
How It Works Internally
Virtual threads operate using an M:N scheduling model. The JVM manages a large number of virtual threads (M) and multiplexes them over a smaller pool of carrier OS threads (N).
When a virtual thread executes a blocking I/O operation (like fetching data from a database or a network call), the JVM "unmounts" the virtual thread from its carrier thread. The state of the virtual thread is saved onto the heap. The carrier thread is then free to execute another virtual thread. Once the blocking operation completes, the virtual thread is rescheduled and "mounted" back onto an available carrier thread to resume execution.
This means you can write straightforward, synchronous, blocking code, while achieving the scalability of asynchronous, non-blocking frameworks.
Code Examples
1. Beginner Example: The Basics
Creating and starting virtual threads is incredibly simple. You can use the new Thread.ofVirtual() API.
public class VirtualThreadBasics {
public static void main(String[] args) throws InterruptedException {
// Creating and starting a single virtual thread
Thread vThread = Thread.ofVirtual().start(() -> {
System.out.println("Hello from a Virtual Thread! Carrier: " + Thread.currentThread());
});
// Wait for the virtual thread to complete
vThread.join();
}
}
2. Intermediate Example: Massive Concurrency
The real power of virtual threads is scaling. Let's launch 10,000 threads that sleep for 1 second. With Platform threads, this would crash many machines or cause immense latency. With Virtual Threads, it completes almost instantly.
import java.util.concurrent.Executors;
import java.util.stream.IntStream;
import java.time.Duration;
import java.time.Instant;
public class MassiveConcurrency {
public static void main(String[] args) {
Instant start = Instant.now();
// Using the new Executors factory method for Virtual Threads
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 10_000).forEach(i -> {
executor.submit(() -> {
try {
Thread.sleep(Duration.ofSeconds(1));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
});
} // The executor automatically waits for all tasks to finish here
Instant end = Instant.now();
System.out.println("Completed in: " + Duration.between(start, end).toMillis() + " ms");
}
}
3. Advanced Example: Avoiding Thread Pinning
While virtual threads are fantastic, certain operations can "pin" the virtual thread to its carrier thread, preventing the carrier from executing other threads while blocked. This usually happens inside synchronized blocks or native methods.
Instead of synchronized, you should prefer ReentrantLock when dealing with Virtual Threads.
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.Executors;
public class PinningAvoidance {
private final ReentrantLock lock = new ReentrantLock();
private int counter = 0;
public void incrementTask() {
lock.lock();
try {
// Simulated blocking I/O inside critical section
Thread.sleep(10);
counter++;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
lock.unlock();
}
}
public static void main(String[] args) {
var app = new PinningAvoidance();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 1000; i++) {
executor.submit(app::incrementTask);
}
}
System.out.println("Final Counter: " + app.counter);
}
}
Common Mistakes Beginners Make
1. Pooling Virtual Threads
The Mistake: Using a Thread Pool (like FixedThreadPool) to manage virtual threads.
The Fix: Virtual threads are designed to be short-lived and disposable. Never pool them. Always use Executors.newVirtualThreadPerTaskExecutor().
2. Using ThreadLocal Extensively
The Mistake: Storing heavy objects in ThreadLocal variables. Since you can easily create millions of virtual threads, doing this will result in severe memory exhaustion.
The Fix: Minimize ThreadLocal usage. Consider using Java's new Scoped Values API for sharing immutable data across threads.
3. Thread Pinning with synchronized
The Mistake: Wrapping slow blocking I/O operations inside synchronized blocks.
The Fix: Refactor to use java.util.concurrent.locks.ReentrantLock, which is fully compatible with virtual thread unmounting.
Interview Q&A
Q1: How do Virtual Threads differ from Platform Threads? Answer: Platform threads map 1:1 to OS threads, making them heavy and resource-intensive. Virtual threads map M:N to OS threads, allowing the JVM to manage millions of them on the heap with negligible overhead.
Q2: Will Virtual Threads speed up CPU-bound tasks? Answer: No. Virtual threads excel at I/O-bound tasks (network calls, DB queries) because they unmount during blocking operations. For CPU-bound tasks, platform threads or a ForkJoin pool remain the correct choice.
Q3: Does Project Loom remove the need for reactive programming? Answer: Mostly, yes. Reactive frameworks (like Project Reactor or RxJava) solve concurrency via complex, non-blocking asynchronous chains. Virtual threads provide the exact same scalability but allow you to write readable, synchronous, blocking code.
Summary
- Virtual Threads allow Java to achieve massive concurrency without complex asynchronous APIs.
- They are lightweight, managed by the JVM, and multiplexed over OS carrier threads.
- Do not pool them, watch out for memory leaks via
ThreadLocal, and avoid thread pinning insynchronizedblocks around blocking I/O. - They are the definitive future of high-throughput I/O-bound backend services in Java.