9.5 Virtual Threads in Java 21 (Carrier Threads, Pinning, and Best Practices)
Key Takeaways
- Virtual threads (JEP 444) are lightweight, JVM-managed user-mode threads scheduled M:N on top of an internal ForkJoinPool carrier thread pool, scaling to millions of concurrent tasks.
- Virtual threads are permanently daemon threads with fixed normal priority (5); invoking setDaemon(false) throws IllegalArgumentException, while setPriority() is silently ignored.
- Thread pinning occurs when a virtual thread blocks inside a synchronized block/method or native call, preventing unmounting from its carrier thread and degrading system throughput.
- Refactoring synchronized blocks in I/O paths to java.util.concurrent.locks.ReentrantLock eliminates thread pinning because ReentrantLock unmounts cleanly via LockSupport.park().
- Virtual threads should never be pooled; applications should create a new virtual thread per task using Thread.ofVirtual() or Executors.newVirtualThreadPerTaskExecutor() while keeping platform thread pools for CPU-bound computations.
Virtual Threads in Java 21 (Carrier Threads, Pinning, and Best Practices)
Finalized as part of Project Loom in Java SE 21 (JEP 444), Virtual Threads represent the most significant architectural evolution of Java concurrency since Java 5. Virtual threads solve the thread scalability bottleneck by decoupling Java's thread abstraction from operating system kernel threads, enabling high-throughput concurrent applications written in the simple, synchronous, thread-per-request style.
1. The Thread Scalability Bottleneck: Platform vs. Virtual Threads
Historically, every java.lang.Thread was a Platform Thread (a thin wrapper around an OS kernel thread). This 1:1 architecture introduces severe limitations:
| Attribute | Platform Thread (Kernel Thread) | Virtual Thread (JEP 444 / Java 21) |
|---|---|---|
| Mapping to OS | 1:1 mapping with an OS kernel thread | M:N multiplexing over carrier platform threads |
| Memory Footprint | Large: $\approx 1,\text{MB}$ reserved call stack memory | Tiny: $\approx 1,\text{KB}$ metadata in Java heap memory |
| Creation Cost | Expensive (requires OS kernel context allocation) | Inexpensive (simple Java object allocation on heap) |
| Max Capacity | Few thousands ($\approx 2,000\text{--}10,000$ per JVM) | Millions of active concurrent threads |
| Context Switch | Heavy (OS kernel mode transition, registers, CPU) | Lightweight (JVM user-mode continuation jump) |
| Blocking Cost | Blocks underlying OS kernel thread | Unmounts from carrier thread; OS thread remains free |
| Ideal Workload | CPU-intensive computational tasks | High-throughput, I/O-bound blocking workloads |
2. Carrier Threads and M:N Scheduling
Virtual threads are executed by mounting them onto underlying platform threads known as Carrier Threads:
- Carrier Pool: The JVM manages carrier threads using an internal FIFO
ForkJoinPool. - Default Parallelism: The number of carrier threads defaults to
Runtime.getRuntime().availableProcessors()(configurable via-Djdk.virtualThreadScheduler.parallelism=N). - Mounting & Continuation Mechanism:
- When a virtual thread executes code, it is mounted onto a carrier platform thread.
- When the virtual thread encounters a blocking operation (e.g.,
SocketChannel.read(),Thread.sleep(),BlockingQueue.take(),LockSupport.park()), the virtual thread yields its continuation. - Its execution stack frames are copied from the carrier stack into the JVM heap, and the virtual thread unmounts.
- The carrier thread is immediately released to mount and execute another runnable virtual thread.
- When the blocking operation completes in the OS kernel, the JVM schedules the virtual thread continuation, mounting it on any available carrier thread (not necessarily the original one).
+-------------------------------------------------------------------------+
| MILLIONS OF VIRTUAL THREADS |
| [VT 1: Active] [VT 2: Blocked on I/O] [VT 3: Active] [VT 4...] |
+-------------------------------------------------------------------------+
| | (Unmounted) |
(Mounted) v (Mounted)
+-------------------+ +-------------------+ +-------------------+
| Carrier Thread 1 | | Carrier Thread 2 | | Carrier Thread 3 | (ForkJoinPool)
| (OS Thread 1) | | (OS Thread 2) | | (OS Thread 3) |
+-------------------+ +-------------------+ +-------------------+
3. Creating and Launching Virtual Threads
Java 21 provides several APIs to instantiate virtual threads:
// Method 1: Instant launch helper
Thread vt1 = Thread.startVirtualThread(() -> {
System.out.println("Running in VT: " + Thread.currentThread());
});
// Method 2: Fluent Builder API (Configured and started)
Thread vt2 = Thread.ofVirtual()
.name("order-worker-", 0) // Names: order-worker-0, order-worker-1...
.start(() -> System.out.println("Named VT running"));
// Method 3: Fluent Builder unstarted
Thread vt3 = Thread.ofVirtual()
.name("batch-vt")
.unstarted(() -> System.out.println("Unstarted VT"));
vt3.start();
// Method 4: ThreadFactory integration
ThreadFactory factory = Thread.ofVirtual().name("http-req-", 1).factory();
Thread vt4 = factory.newThread(() -> System.out.println("Factory created VT"));
vt4.start();
// Method 5: ExecutorService (One virtual thread per submitted task)
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
int taskId = i;
executor.submit(() -> {
Thread.sleep(1000); // Unmounts cleanly; does not block OS thread
return taskId;
});
}
} // Auto-closes: shuts down and awaits all 10,000 tasks
4. Invariants and Architectural Rules for the Exam
Virtual threads possess strict invariants that differ fundamentally from platform threads:
- Permanent Daemon Status:
- Virtual threads are always daemon threads. The JVM will never wait for virtual threads to finish when all non-daemon platform threads have terminated.
- Calling
vt.setDaemon(false)throwsjava.lang.IllegalArgumentException.
- Fixed Normal Priority:
- Virtual threads always have
Thread.NORM_PRIORITY(5). - Calling
vt.setPriority(int priority)is silently ignored; priority remains 5.
- Virtual threads always have
- Fixed, Non-Configurable ThreadGroup:
- Every virtual thread belongs to a single JVM-managed group named
VirtualThreads, and theThread.ofVirtual()builder offers no way to choose a different group.ThreadGroupis effectively inert in Java 21:destroy()is terminally deprecated and simply does nothing, andsetMaxPriority()never changes the priority of virtual threads. Treat thread groups as a legacy diagnostic label, not a control surface.
- Every virtual thread belongs to a single JVM-managed group named
Thread.currentThread().isVirtual():- Returns
trueif invoked on a virtual thread;falseon platform threads.
- Returns
Thread vt = Thread.ofVirtual().unstarted(() -> {});
System.out.println(vt.isDaemon()); // PRINTS: true
System.out.println(vt.isVirtual()); // PRINTS: true
System.out.println(vt.getPriority()); // PRINTS: 5
vt.setPriority(Thread.MAX_PRIORITY); // Silently ignored; priority remains 5
// vt.setDaemon(false); // COMPILE OK, but THROWS IllegalArgumentException at runtime!
5. Thread Pinning: Causes, Diagnostics, and Remediation
What is Thread Pinning?
When a virtual thread blocks while pinned, it cannot unmount from its carrier thread. Consequently, the underlying OS carrier thread remains blocked and unavailable to execute other virtual threads, severely degrading throughput.
The Two Pinning Triggers:
- Executing inside a
synchronizedblock or method: Entering an intrinsic monitor lock binds the virtual thread stack frame to the OS carrier thread's C-runtime stack. - Executing a Native Method or Foreign Function: JNI native calls or Foreign Function & Memory (FFM) API calls.
Remediation: Replace synchronized with ReentrantLock
Unlike intrinsic monitors (synchronized), java.util.concurrent.locks.ReentrantLock uses LockSupport.park() for blocking. LockSupport.park() supports virtual thread continuations, allowing virtual threads to unmount cleanly without pinning!
// PINNING ANTI-PATTERN (Carrier thread is blocked!)
public class PinnedService {
public synchronized String fetchData() { // synchronized causes pinning!
try {
return httpCall(); // Blocking I/O while holding monitor = PINNED!
} catch (Exception e) { return ""; }
}
}
// REFACTORED BEST PRACTICE (Cleanly unmounts carrier thread!)
public class SafeService {
private final ReentrantLock lock = new ReentrantLock();
public String fetchData() {
lock.lock();
try {
return httpCall(); // Virtual thread unmounts cleanly during I/O!
} catch (Exception e) {
return "";
} finally {
lock.unlock();
}
}
}
Diagnosing Pinned Threads via JVM Flag
You can detect pinning at runtime by launching the JVM with:
-Djdk.tracePinnedThreads=full (prints full stack trace) or -Djdk.tracePinnedThreads=short (prints one-line summary).
6. Best Practices and Anti-Patterns
Anti-Pattern 1: Pooling Virtual Threads
- Do NOT Pool Virtual Threads: Never use
Executors.newFixedThreadPool(100)with virtual threads. Pooling virtual threads adds memory overhead and defeats their design. - Best Practice: Create a new virtual thread per task using
Executors.newVirtualThreadPerTaskExecutor()orThread.ofVirtual().start().
Anti-Pattern 2: CPU-Bound Tasks on Virtual Threads
- Virtual threads provide zero throughput benefits for purely CPU-intensive computations (e.g., matrix math, video rendering, cryptographic hashing). Because CPU workloads cannot unmount, they monopolize carrier threads.
- Best Practice: Use platform thread pools or
ForkJoinPool.commonPool()for CPU-bound tasks.
Anti-Pattern 3: Heavy ThreadLocal Caching
- Creating millions of virtual threads that each instantiate large cached objects in
ThreadLocalcan rapidly exhaust heap memory. - Best Practice: Avoid large
ThreadLocalcaches in virtual threads; prefer ephemeral task parameters or modern features like Scoped Values (JEP 446).
7. Java 21 Concurrency Preview Overview
- Structured Concurrency (JEP 453 Preview): Coordinates groups of related subtasks running in concurrent virtual threads as a single transaction using
StructuredTaskScope(ShutdownOnFailurefor fail-fast orShutdownOnSuccessfor first-result racing). - Scoped Values (JEP 446 Preview): Enables safe, immutable, and lightweight data sharing across threads without the memory overhead of
ThreadLocal.
What is the result of attempting to compile and execute the following code snippet in Java SE 21?
Thread vt = Thread.ofVirtual().unstarted(() -> {
System.out.println("Running");
});
vt.setDaemon(false);
vt.start();
A high-traffic web application migrates its service methods to virtual threads. However, server monitoring reveals that carrier threads are constantly blocked and unable to accept new tasks during database calls. What is the most likely cause of this thread pinning, and how should it be remediated?
Which statement correctly describes how Java SE 21 schedules and executes virtual threads?
Which of the following scenarios represents an anti-pattern when designing concurrent systems with virtual threads in Java SE 21?