Java Virtual Threads (Project Loom) Cheat Sheet
Creating and managing virtual threads, structured concurrency, and pitfalls like pinning, for writing simple blocking-style concurrent Java code at scale.
Creating Virtual Threads
Virtual threads are cheap, JVM-scheduled threads — millions can run concurrently.
// Direct creationThread vt = Thread.ofVirtual().name("worker-1").start(() -> { System.out.println("running on " + Thread.currentThread());});vt.join();// Factory for use with ExecutorService-style codeThreadFactory factory = Thread.ofVirtual().name("worker-", 0).factory();// Unstarted, then start laterThread t = Thread.ofVirtual().unstarted(() -> doWork());t.start();
Virtual-Thread-Per-Task Executor
The idiomatic way to run many concurrent blocking tasks.
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) { List<Future<String>> futures = new ArrayList<>(); for (int i = 0; i < 10_000; i++) { int id = i; futures.add(executor.submit(() -> fetch("https://example.com/" + id))); } for (var f : futures) { System.out.println(f.get()); }} // executor.close() waits for all tasks, one virtual thread per submitted task
Structured Concurrency
Scopes tie a task's lifetime to its subtasks — errors and cancellation propagate cleanly.
// java.util.concurrent.StructuredTaskScope (preview in recent JDKs)try (var scope = new StructuredTaskScope.ShutdownOnFailure()) { Subtask<String> user = scope.fork(() -> fetchUser(id)); Subtask<String> orders = scope.fork(() -> fetchOrders(id)); scope.join(); // wait for both, or fail fast on first exception scope.throwIfFailed(); // rethrow if any subtask failed return new Response(user.get(), orders.get());}
Pinning: What Blocks the Carrier Thread
Situations where a virtual thread pins its OS carrier thread, hurting scalability.
- synchronized blocks/methods- pins during the block; prefer java.util.concurrent.locks.ReentrantLock
- native calls (JNI)- pin for the duration of the native call
- Object.wait() inside synchronized- classic pinning source, migrate to Condition
- Thread.holdsLock queries- indicate you're in a pinning-prone region
- jdk.tracePinnedThreads- JFR/JDK flag to diagnose pinning at runtime
Scoped Values (ThreadLocal Replacement)
ScopedValue provides immutable, structured per-thread data that is safe and cheap across millions of virtual threads, unlike ThreadLocal.
// java.lang.ScopedValue (finalized JDK 21+/25 depending on release)final static ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();void handleRequest(String id) { ScopedValue.where(REQUEST_ID, id).run(() -> { // REQUEST_ID.get() is visible to this thread and any it forks process(); });}void process() { log.info("handling " + REQUEST_ID.get());}// Unlike ThreadLocal:// - value is bound only for the dynamic scope of run()/call(), then unbound// - no inheritance leaks between pooled/reused threads (moot anyway since// virtual threads aren't pooled), no explicit remove() needed// - immutable within the scope: no accidental mutation from nested code
Tuning the Virtual Thread Scheduler
Virtual threads run on a ForkJoinPool of carrier (platform) threads; its default parallelism can be tuned via system properties.
// Default carrier pool size = Runtime.availableProcessors()// Override at JVM startup:// -Djdk.virtualThreadScheduler.parallelism=16// -Djdk.virtualThreadScheduler.maxPoolSize=256// -Djdk.virtualThreadScheduler.minRunnable=8// Inspect at runtime:// jcmd <pid> Thread.dump_to_file -format=json threads.json// jcmd <pid> VM.info | grep -i virtual// Rule of thumb: parallelism should roughly match available CPU cores for// CPU-bound work interleaved with virtual threads; raising it does NOT help// I/O-bound throughput once carriers are no longer the bottleneck, since a// blocking virtual thread unmounts from its carrier automatically.
Throttling Unbounded Fan-Out
newVirtualThreadPerTaskExecutor imposes no concurrency limit itself — use a Semaphore to cap concurrent downstream calls (e.g. a rate-limited API).
Semaphore permits = new Semaphore(50); // cap concurrent in-flight callstry (var executor = Executors.newVirtualThreadPerTaskExecutor()) { List<Future<String>> futures = new ArrayList<>(); for (String url : urls) { futures.add(executor.submit(() -> { permits.acquire(); try { return httpClient.send(url); } finally { permits.release(); } })); } for (var f : futures) System.out.println(f.get());}// Blocking on permits.acquire() is cheap here: it parks the virtual thread// without pinning or wasting an OS thread, unlike blocking a platform thread.
StructuredTaskScope.Joiner (Custom Policies)
Newer StructuredTaskScope APIs let you supply a Joiner to collect results with custom success/failure/cancellation policies beyond ShutdownOnFailure.
// Collect all successful results, throw if any subtask failedvar joiner = StructuredTaskScope.Joiner.<String>allSuccessfulOrThrow();try (var scope = StructuredTaskScope.open(joiner)) { scope.fork(() -> fetchFromMirrorA()); scope.fork(() -> fetchFromMirrorB()); scope.fork(() -> fetchFromMirrorC()); List<String> results = scope.join();}// Race: return the first successful result, cancel the restvar race = StructuredTaskScope.Joiner.<String>anySuccessfulResultOrThrow();try (var scope = StructuredTaskScope.open(race)) { scope.fork(() -> fetchFromMirrorA()); scope.fork(() -> fetchFromMirrorB()); String first = scope.join();}// Cancellation of the losing subtasks is automatic and propagates interrupts// to any blocking I/O they're doing, which unparks their virtual threads.
Observability & Debugging Tools
How to inspect virtual thread behavior in production without guessing.
- jdk.VirtualThreadPinned (JFR event)- fires when a virtual thread pins its carrier; enable with -XX:StartFlightRecording=settings=profile
- jdk.tracePinnedThreads=full- system property, prints stack traces to stdout whenever pinning occurs
- jcmd <pid> Thread.dump_to_file -format=json out.json- dumps all virtual and platform threads, including unmounted ones
- jstack limitation- classic jstack does not show unmounted virtual threads well; prefer jcmd Thread.dump
- Thread.currentThread().isVirtual()- runtime check, useful in logging filters or assertions
- async-profiler- supports virtual-thread-aware wall-clock profiling to see where time is actually spent
Don't pool virtual threads and don't reuse them like platform threads — create a new one per task (they're designed to be cheap and disposable) and replace ReentrantLock/synchronized hotspots only where profiling shows real pinning, not preemptively.