Back to Java 21+ Features
Q1Question 1 of 10

What are virtual threads?

Key Concepts
  • Virtual threads are lightweight, JVM-managed threads that cost roughly 1 KB each instead of the 1 MB a platform thread requires
  • When a virtual thread blocks on I/O the JVM unmounts it from its carrier and mounts another, so the carrier is never idle
  • They solve the C10K problem by allowing millions of concurrent threads for I/O-bound workloads
  • They do NOT speed up CPU-bound work because the carrier stays occupied during computation
  • Thread pinning occurs inside synchronized blocks or JNI calls where the virtual thread cannot unmount
  • Use Executors.newVirtualThreadPerTaskExecutor() for the standard one-thread-per-task model
  • In Spring Boot set spring.threads.virtual.enabled=true to make all request-handling threads virtual

Definition

A virtual thread is a lightweight, JVM-managed thread that unmounts from its carrier OS thread when it blocks on I/O, enabling millions of concurrent threads at roughly 1 KB each.
  • Virtual thread – a thread managed entirely by the JVM, not by the operating system, introduced as final in Java 21 via JEP 444.
  • Carrier thread – the platform (OS) thread on which a virtual thread is mounted for execution; the JVM maintains a small pool of carriers.
  • Unmounting – when a virtual thread blocks on I/O (database call, HTTP request, file read), the JVM detaches it from its carrier so the carrier can run another virtual thread.
  • Cost – each virtual thread uses roughly 1 KB of stack space versus roughly 1 MB for a platform thread, so millions can coexist.
  • Limitation – virtual threads do not help CPU-bound tasks because the carrier stays occupied during computation, and thread pinning inside synchronized blocks prevents unmounting.

Think of a bank with 20 teller counters (carrier threads) and an unlimited supply of numbered tokens (virtual threads). Each customer takes a token and waits. When a teller finishes one customer and that customer leaves to get a document (I/O block), the teller immediately calls the next token instead of sitting idle. One teller can serve hundreds of customers in a day because most of the time customers are away fetching documents. But if a customer needs the teller to perform a long manual calculation (CPU-bound), the teller is fully occupied and cannot switch.

Understanding the Concepts

What is a virtual thread?

A virtual thread is a lightweight thread whose lifecycle is managed by the JVM rather than the operating system. Traditional platform threads map one-to-one to OS threads and cost roughly 1 MB of stack memory each. Virtual threads cost roughly 1 KB and share a small pool of carrier platform threads. When a virtual thread blocks on I/O, the JVM unmounts it from its carrier and mounts another virtual thread, so the carrier thread is never idle.

Virtual threads were introduced as a preview in Java 19 (Project Loom) and became final in Java 21 (JEP 444). The programming model remains simple and blocking. You write ordinary synchronous code and the runtime handles the scheduling.

JAVA
package com.bank.service;

import java.util.concurrent.Executors;
import java.math.BigDecimal;

public class TransferService {

    public void processTransfers() {
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 50_000; i++) {
                final String accountId = "SAV" + (2001 + i);
                executor.submit(() -> processOneTransfer(accountId));
            }
        } // executor.close() waits for all tasks
    }

    private void processOneTransfer(String accountId) {
        // Each virtual thread blocks on DB and HTTP calls.
        // The JVM unmounts it from the carrier during these waits.
        BigDecimal balance = fetchBalance(accountId);       // blocks on DB
        boolean cleared = checkFraud(accountId, balance);   // blocks on HTTP
        if (cleared) {
            executeTransfer(accountId, balance);
        }
    }
}
TEXT
Output: 50,000 transfers processed concurrently on ~8 carrier threads.

Why do virtual threads exist?

Before virtual threads, Java servers used the thread-per-request model. Each incoming HTTP request got its own platform thread. Because platform threads are expensive (roughly 1 MB each, OS-scheduled), servers were limited to a few thousand concurrent connections. This is the C10K problem: handling 10,000 or more simultaneous connections required either thread pooling with queuing or reactive programming (callbacks, CompletableFuture chains, Project Reactor).

Virtual threads solve this by making blocking cheap. You write simple, synchronous code and the JVM schedules millions of virtual threads on a handful of carriers. No reactive callbacks, no future chains, no non-blocking I/O frameworks.

  • Before virtual threads, a server with 200 platform threads could handle at most 200 concurrent requests.
  • With virtual threads, the same server can handle 50,000 concurrent requests because each blocked virtual thread costs almost nothing.

How do virtual threads work internally?

The JVM maintains a ForkJoinPool of carrier platform threads (typically one per CPU core). When you start a virtual thread, the scheduler mounts it on an available carrier. The virtual thread runs until it encounters a blocking operation (socket read, file I/O, Thread.sleep, Lock.lock). At that point the JVM unmounts the virtual thread, saves its continuation (stack frames), and mounts the next runnable virtual thread on the same carrier.

When the I/O completes, the virtual thread is re-scheduled onto any available carrier. The carrier pool is never idle as long as there are runnable virtual threads.

DIAGRAM
Carrier Pool (8 platform threads):

  Carrier-1:  VT-1 runs → VT-1 blocks on DB → mount VT-5 → VT-5 runs
  Carrier-2:  VT-2 runs → VT-2 blocks on HTTP → mount VT-6 → VT-6 runs
  Carrier-3:  VT-3 runs → VT-3 finishes → mount VT-7 → VT-7 runs
  ...
  (50,000 virtual threads, 8 carriers)

How do you create virtual threads?

There are three main ways to create and run virtual threads.

JAVA
package com.bank.core;

import java.util.concurrent.Executors;
import java.util.concurrent.ExecutorService;

public class VirtualThreadCreation {

    public static void main(String[] args) throws Exception {

        // Way 1: Thread.ofVirtual().start()
        Thread vt1 = Thread.ofVirtual()
            .name("balance-checker")
            .start(() -> System.out.println("Checking balance for SAV2001"));
        vt1.join();

        // Way 2: Thread.startVirtualThread() shortcut
        Thread vt2 = Thread.startVirtualThread(
            () -> System.out.println("Processing transfer for Rajesh"));
        vt2.join();

        // Way 3: Executor (recommended for production)
        try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
            executor.submit(() -> System.out.println("Fraud check for CUR3001"));
            executor.submit(() -> System.out.println("Compliance check for CUR3001"));
        }
    }
}
TEXT
Checking balance for SAV2001
Processing transfer for Rajesh
Fraud check for CUR3001
Compliance check for CUR3001

When do virtual threads NOT help?

Virtual threads do not speed up CPU-bound work. If a virtual thread is performing a long calculation (risk computation, encryption, data transformation), it keeps its carrier busy for the entire duration. No unmounting happens because there is no blocking I/O.

  • I/O-bound: virtual threads shine. Each thread spends most of its time waiting for responses. Carriers are freed during the wait.
  • CPU-bound: virtual threads add overhead without benefit. Use a fixed-size platform thread pool sized to the number of CPU cores.
JAVA
// WRONG: virtual threads for CPU-bound work
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 1000; i++) {
        executor.submit(() -> {
            // Heavy CPU computation: carrier stays occupied
            BigDecimal risk = computePortfolioRisk(); // no I/O, no unmounting
        });
    }
}

// CORRECT: platform thread pool for CPU-bound work
ExecutorService cpuPool = Executors.newFixedThreadPool(
    Runtime.getRuntime().availableProcessors());
for (int i = 0; i < 1000; i++) {
    cpuPool.submit(() -> computePortfolioRisk());
}

What is thread pinning?

Pinning occurs when a virtual thread cannot unmount from its carrier during a blocking operation. In Java 21, two situations cause pinning.

First, blocking inside a synchronized block or method pins the virtual thread because the JVM monitor implementation requires the thread to stay mounted. Second, JNI native calls pin the thread because native code cannot be suspended.

When pinned, the carrier thread is blocked, which defeats the purpose of virtual threads.

JAVA
// WRONG: synchronized pins the virtual thread
public class AccountRepository {
    public synchronized BigDecimal getBalance(String accountId) {
        return jdbcTemplate.queryForObject(           // blocking I/O inside synchronized
            "SELECT balance FROM accounts WHERE id = ?",
            BigDecimal.class, accountId);
    }
    // Virtual thread is PINNED for the entire DB call
}

// CORRECT: ReentrantLock allows unmounting
import java.util.concurrent.locks.ReentrantLock;

public class AccountRepository {
    private final ReentrantLock lock = new ReentrantLock();

    public BigDecimal getBalance(String accountId) {
        lock.lock();
        try {
            return jdbcTemplate.queryForObject(
                "SELECT balance FROM accounts WHERE id = ?",
                BigDecimal.class, accountId);
        } finally {
            lock.unlock();
        }
    }
    // Virtual thread unmounts during DB call, carrier is free
}

How is this different from reactive programming?

| Aspect | Virtual Threads | Reactive (Reactor/RxJava) |
|---|---|---|
| Programming model | Synchronous, blocking | Asynchronous, callback chains |
| Code readability | Familiar imperative style | Steep learning curve, operators |
| Debugging | Normal stack traces | Complex operator chains |
| Backpressure | Manual (Semaphore) | Built-in (Flux backpressure) |
| Ecosystem | Works with existing JDBC, HttpClient | Requires reactive drivers (R2DBC) |
| Performance | Excellent for I/O-bound | Excellent for I/O-bound |

Virtual threads let you get the scalability of reactive programming with the simplicity of blocking code. You do not need to rewrite your JDBC calls to R2DBC or your HttpClient calls to WebClient.

What are common mistakes with virtual threads?

JAVA
// WRONG: Pooling virtual threads defeats their purpose
ExecutorService pool = Executors.newFixedThreadPool(100,
    Thread.ofVirtual().factory());  // BAD: limits to 100 VTs

// CORRECT: One virtual thread per task, no pooling
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

// WRONG: Using ThreadLocal for per-request data in VTs (memory leak risk)
ThreadLocal<String> currentUser = new ThreadLocal<>();
// Millions of VTs = millions of ThreadLocal copies

// CORRECT: Use ScopedValue (Java 21+ preview)
ScopedValue<String> CURRENT_USER = ScopedValue.newInstance();
ScopedValue.where(CURRENT_USER, "Rajesh").run(() -> processRequest());

How do you use virtual threads in Spring Boot?

Spring Boot 3.2 and later supports virtual threads with a single property.

PROPERTIES
# application.properties
spring.threads.virtual.enabled=true

This makes all request-handling threads virtual. Every incoming HTTP request runs on its own virtual thread. Blocking JDBC calls, RestTemplate calls, and other I/O operations automatically unmount the virtual thread from its carrier.

What should a senior engineer know?

  • Virtual threads use a ForkJoinPool as the carrier pool. The default parallelism is the number of available processors. Override with -Djdk.virtualThreadScheduler.parallelism=N.
  • The -Djdk.tracePinnedThreads=short flag prints a stack trace whenever a virtual thread is pinned, helping you find synchronized blocks on I/O paths.
  • Java 24 (JEP 491) removes the synchronized pinning limitation by reimplementing monitors to support virtual thread unmounting.
  • Virtual threads are daemon threads by default. They do not prevent JVM shutdown.
  • Thread.sleep on a virtual thread unmounts it from the carrier, unlike on a platform thread where it wastes the OS thread.

What are common interview follow-up questions?

  • What is the difference between a virtual thread and a platform thread? (Scheduling: JVM-managed vs OS-managed. Cost: 1 KB vs 1 MB. Count: millions vs thousands.)
  • Can you pool virtual threads? (No, pooling is an anti-pattern. Use Semaphore to limit access to finite resources.)
  • What replaces ThreadLocal for virtual threads? (ScopedValue, finalized in JDK 25.)
  • How do you detect thread pinning? (JFR event jdk.VirtualThreadPinned or -Djdk.tracePinnedThreads=short.)

Visual Overview

DIAGRAM
  ┌─────────────────────────────────────────────────────┐
  │              Virtual Thread Scheduling                │
  │                                                       │
  │   Virtual Threads (millions)                          │
  │   ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐        │
  │   │VT-1│ │VT-2│ │VT-3│ │VT-4│ │VT-5│ │... │        │
  │   └──┬─┘ └──┬─┘ └──┬─┘ └──┬─┘ └──┬─┘ └──┬─┘        │
  │      │      │      │      │      │      │            │
  │      ▼      ▼      ▼      ▼      ▼      ▼            │
  │   ┌──────────────────────────────────────┐            │
  │   │       JVM Scheduler (ForkJoinPool)    │            │
  │   │   mount / unmount on I/O block        │            │
  │   └──────────────┬───────────────────────┘            │
  │                  │                                     │
  │      ┌───────────┼───────────┐                        │
  │      ▼           ▼           ▼                        │
  │   ┌──────┐   ┌──────┐   ┌──────┐                     │
  │   │ CT-1 │   │ CT-2 │   │ CT-3 │  Carrier Threads    │
  │   │(OS)  │   │(OS)  │   │(OS)  │  (one per CPU core) │
  │   └──────┘   └──────┘   └──────┘                     │
  └─────────────────────────────────────────────────────┘

  VT blocks on I/O:
  ┌──────┐          ┌──────┐
  │ VT-1 │──runs──▶│ CT-1 │
  │      │  I/O    │      │
  │      │  block  │      │  VT-1 unmounted, parked
  │      │◀────────│      │
  │ VT-5 │──mount─▶│ CT-1 │  VT-5 runs on same carrier
  └──────┘          └──────┘

Key Takeaway

Virtual threads are the most significant concurrency feature Java has added since the original threading model. They are lightweight, JVM-managed threads that cost roughly 1 KB each instead of the 1 MB that a platform thread requires. When a virtual thread blocks on I/O, the JVM unmounts it from its carrier OS thread and mounts another virtual thread, so the carrier is never idle. This means you can have millions of virtual threads running concurrently on a handful of carrier threads.

The key insight is that virtual threads make blocking cheap. Before Java 21, writing scalable I/O-bound servers meant either limiting concurrency with thread pools (and queuing excess requests) or adopting reactive programming with its complex callback chains. Virtual threads let you write simple, synchronous, blocking code that scales to hundreds of thousands of concurrent connections. The programming model stays familiar: you call jdbcTemplate.query(...) or httpClient.send(...) and the runtime handles the efficiency.

Virtual threads do not help CPU-bound workloads. If a thread is computing a risk model or encrypting data, there is no I/O block for the JVM to exploit. The carrier stays occupied. For CPU-bound tasks, use a fixed-size platform thread pool sized to the number of CPU cores. Thread pinning is the other limitation: blocking inside a synchronized block or a JNI call prevents the virtual thread from unmounting. Replace synchronized with ReentrantLock on I/O paths.

In a banking system like National Bank of India, a transfer service handling 50,000 concurrent requests gives each request its own virtual thread. Each thread blocks on a database call and a fraud-check HTTP call. The eight carrier threads stay busy because whenever one virtual thread blocks, another is mounted immediately. The result is the throughput of a reactive system with the readability of imperative code.

Remember one sentence: virtual threads make blocking I/O cheap by unmounting from the carrier on every block, enabling millions of concurrent threads at 1 KB each.

---

Share
Use ← → arrow keys to navigate