Contents

A platform thread is a thin wrapper around an OS thread. Creating tens of thousands of them is expensive — each consumes ~1 MB of stack by default. A virtual thread is managed by the JVM and mounted onto a carrier (platform) thread only while it is actually executing CPU work. When it blocks on I/O, it unmounts, freeing the carrier thread for other tasks.

// Platform thread — limited, expensive Thread platform = new Thread(() -> System.out.println("Platform thread")); platform.start(); // Virtual thread — lightweight, JVM-managed Thread vt = Thread.ofVirtual().start(() -> System.out.println("Virtual thread")); vt.join(); System.out.println(vt.isVirtual()); // true
// 1. Thread.ofVirtual().start(Runnable) — fire and forget Thread t1 = Thread.ofVirtual().start(() -> { System.out.println("Running in: " + Thread.currentThread()); }); t1.join(); // 2. Thread.ofVirtual().name("my-thread").start(Runnable) — named Thread t2 = Thread.ofVirtual() .name("worker-", 0) // creates worker-0, worker-1, etc. .start(() -> doWork()); // 3. Thread.startVirtualThread(Runnable) — shortcut Thread t3 = Thread.startVirtualThread(() -> processRequest()); // 4. Thread.ofVirtual().unstarted(Runnable) — create without starting Thread t4 = Thread.ofVirtual().unstarted(() -> compute()); t4.start(); t4.join(); // Virtual threads are always daemon threads — they don't prevent JVM exit System.out.println(t1.isDaemon()); // true

The most common way to use virtual threads is through Executors.newVirtualThreadPerTaskExecutor() — it creates a new virtual thread for every submitted task, exactly as if you called Thread.startVirtualThread() for each.

// Simulate 10,000 concurrent "HTTP requests" try (ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor()) { List<Future<String>> futures = new ArrayList<>(); for (int i = 0; i < 10_000; i++) { final int id = i; futures.add(exec.submit(() -> fetchUser(id))); } for (Future<String> f : futures) { System.out.println(f.get()); } } // The executor is AutoCloseable — awaits all tasks on close() static String fetchUser(int id) throws InterruptedException { // Simulates a blocking I/O call (e.g., HTTP or JDBC) Thread.sleep(100); return "User-" + id; } // With platform threads + fixed pool, 10,000 tasks would queue. // With virtual threads, all 10,000 run "concurrently" in ~100ms total. Drop-in replacement for Spring Boot: set spring.threads.virtual.enabled=true in application.properties to enable virtual threads for all request handling in Spring Boot 3.2+.

A virtual thread is pinned to its carrier thread when it holds a monitor lock (synchronized block/method) while blocking on I/O. During pinning, the carrier thread is blocked too — reducing throughput.

// PROBLEM — synchronized holds the carrier thread while sleeping/blocking class PinnedExample { synchronized void blocksCarrier() throws InterruptedException { Thread.sleep(1000); // virtual thread is PINNED — carrier can't be reused } } // SOLUTION — replace synchronized with ReentrantLock, which is virtual-thread-aware class BetterExample { private final ReentrantLock lock = new ReentrantLock(); void doesNotPin() throws InterruptedException { lock.lock(); try { Thread.sleep(1000); // virtual thread unmounts while sleeping — carrier is free } finally { lock.unlock(); } } } // Detect pinning with JVM flags: // -Djdk.tracePinnedThreads=full // This prints a stack trace whenever a virtual thread is pinned // ThreadLocal is fine with virtual threads — but be careful: // A virtual-thread-per-task executor creates a new thread per task, // so ThreadLocal values don't persist across tasks (unlike thread pools). Avoid synchronized in code that will run on virtual threads when the synchronized block contains I/O or Thread.sleep(). Use ReentrantLock instead. Many popular libraries (JDBC drivers, etc.) are being updated to address this.
// Semaphore for back-pressure — limit DB concurrency to 20 Semaphore dbSemaphore = new Semaphore(20); try (ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor()) { for (String userId : userIds) { exec.submit(() -> { dbSemaphore.acquire(); try { return db.findUser(userId); // only 20 at a time } finally { dbSemaphore.release(); } }); } }