Virtual threads (since Java 21, JEP 444) are lightweight threads managed by the JVM rather than the OS. They enable high-throughput concurrent applications — especially I/O-bound workloads — without the overhead of platform threads.

Platform Threads vs Virtual Threads

Feature Platform Thread Virtual Thread
Managed by OS JVM
Memory overhead ~1 MB stack ~few KB
Max practical count Thousands Millions
Best for CPU-bound I/O-bound
Creation cost Expensive Cheap

Creating Virtual Threads

  // Java 21+
Thread vThread = Thread.startVirtualThread(() -> {
    System.out.println("Running in virtual thread: " + Thread.currentThread());
});

vThread.join();
  

Using Executors

  try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    IntStream.range(0, 10_000).forEach(i -> {
        executor.submit(() -> {
            Thread.sleep(Duration.ofSeconds(1));
            return i;
        });
    });
} // executor closes automatically
  

Each task gets its own virtual thread — no pooling needed.

Thread.ofVirtual()

  ThreadFactory factory = Thread.ofVirtual().name("worker-", 0).factory();
Thread t = factory.newThread(() -> System.out.println("Hello"));
t.start();
  

Why Virtual Threads Matter

Traditional thread-per-request models hit limits with platform threads:

  // ❌ 10,000 platform threads — likely OutOfMemoryError or severe slowdown
try (var executor = Executors.newFixedThreadPool(10_000)) { ... }

// ✅ 10,000 virtual threads — feasible
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { ... }
  

Virtual threads shine when tasks block on I/O (HTTP calls, database queries, file reads). The JVM parks virtual threads during blocking, freeing the underlying carrier thread.

Pinning — Important Caveat

A virtual thread is pinned to its carrier platform thread when:

  • Running code inside a synchronized block/method
  • Running native code or foreign function calls

Pinned virtual threads cannot be unmounted during blocking, reducing scalability:

  // ❌ May pin virtual thread
synchronized (lock) {
    socket.read(); // blocking I/O while pinned
}

// ✅ Prefer ReentrantLock
lock.lock();
try {
    socket.read();
} finally {
    lock.unlock();
}
  

Structured Concurrency (Preview)

Groups related tasks with a clear lifetime:

  try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Subtask<String> user = scope.fork(() -> fetchUser());
    Subtask<Integer> order = scope.fork(() -> fetchOrderCount());

    scope.join();
    scope.throwIfFailed();

    return new Dashboard(user.get(), order.get());
}
  

Migration Tips

Before After
Fixed thread pool for I/O newVirtualThreadPerTaskExecutor()
@Async with thread pool Virtual thread executor
Reactive streams for concurrency Simple blocking code on virtual threads

Do not pool virtual threads — create one per task.

Best Practices

  • Use virtual threads for I/O-bound, thread-per-task workloads
  • Avoid pooling virtual threads
  • Replace synchronized with ReentrantLock in hot paths to prevent pinning
  • Do not use virtual threads for CPU-intensive tasks — platform threads are better
  • Requires Java 21+ (production-ready since Java 21)