Sitelet https://howtodoinjava.com/java/multi-threading/virtual-threads/

Java Virtual Threads Guide with Examples (Java 25)

Java virtual threads explained for Java 25 with examples, carrier threads, the Java 24 pinning fix, when not to use them, pooling and ThreadLocal pitfalls.

Virtual threads mounted on and unmounted from a few carrier threads while they wait for I/O

Java virtual threads are lightweight threads that the JVM schedules on a small number of platform threads, so a virtual thread that waits for I/O does not hold an operating system thread. They became a final feature in Java 21 with JEP 444, and they are ordinary java.lang.Thread objects.

We use virtual threads in servers and clients that handle many tasks at the same time, where each task mostly waits for a database, a REST call or a file. With them, the simple thread-per-request style scales to thousands of concurrent requests without reactive code.

The following example starts a virtual thread with the Thread.ofVirtual() builder and waits for it.

Thread vt = Thread.ofVirtual().name("booking-1").start(() -> System.out.println("checking rooms"));
vt.join();
boolean virtual = vt.isVirtual();          // true
boolean daemon = vt.isDaemon();            // true
String name = vt.getName();                // "booking-1"

Notice that the virtual thread is a daemon thread, so the JVM does not wait for it on exit unless we call join(). We look at how virtual threads run, the ways to create them, the Java 24 change to pinning, and the cases where they are the wrong tool, including pooling and heavy ThreadLocal use.

1. Virtual Threads vs Platform Threads

A platform thread is a thin wrapper around an operating system thread. The OS reserves a large stack for it, often 1 MB of address space, and switching between OS threads is relatively slow. That is why apps limit platform threads with a thread pool, and why the pool size limits how many requests a server handles at once.

A virtual thread is created and scheduled by the JVM. Its stack lives on the heap as a small object that grows and shrinks as needed. To run, the JVM mounts the virtual thread on a carrier thread, which is a platform thread from an internal ForkJoinPool. When the virtual thread blocks on a socket, a lock or a queue, the JVM unmounts it and the carrier picks up another virtual thread.

Virtual threads mounted on and unmounted from a few carrier threads while they wait for I/O
A blocked virtual thread leaves its carrier, so a few carrier threads can serve thousands of waiting tasks

Virtual threads give more throughput, not lower latency. Each request takes as long as before, but the server can work on many more requests at the same time, because waiting no longer costs an OS thread.

AspectPlatform threadVirtual thread
Backed byOne OS thread for its whole lifeA carrier thread, only while running
StackFixed size reserved by the OSSmall, on the heap, grows as needed
Creation costHigh, so we pool themLow, so we create one per task
Practical countThousandsMillions
DaemonConfigurableAlways daemon
PriorityConfigurable hintAlways NORM_PRIORITY
Good forCPU-bound work, long-lived background jobsBlocking I/O tasks

Virtual threads went through two previews, in Java 19 (JEP 425) and Java 20 (JEP 436), became final in Java 21 (JEP 444), and stopped pinning on synchronized in Java 24 (JEP 491). No flags are needed on Java 21 or later.

2. Creating Virtual Threads

The Thread class offers the same builder API for both kinds of threads, Thread.ofVirtual() and Thread.ofPlatform(). We have four ways to create a virtual thread, ordered from the shortest to the most flexible.

  • Thread.startVirtualThread(task) creates and starts an unnamed virtual thread.
  • Thread.ofVirtual().start(task) and unstarted(task) create a started or an unstarted virtual thread, with options such as a name.
  • Thread.ofVirtual().factory() returns a ThreadFactory for APIs that take one.
  • Executors.newVirtualThreadPerTaskExecutor() returns an ExecutorService that runs each task in a new virtual thread.
Thread first = Thread.startVirtualThread(() -> {});
Thread second = Thread.ofVirtual().name("worker-", 0).unstarted(() -> {});
second.start();
first.join();
second.join();
String secondName = second.getName();                       // "worker-0"
ThreadFactory factory = Thread.ofVirtual().name("job-", 1).factory();
Thread fromFactory = factory.newThread(() -> {});
String factoryName = fromFactory.getName();                 // "job-1"
boolean platformByDefault = new Thread(() -> {}).isVirtual(); // false

In app code, we rarely create threads by hand. The executor is the usual entry point, and the newVirtualThreadPerTaskExecutor() examples cover closing it, collecting results, naming threads and reading task failures. The general thread creation options are in creating and starting threads.

3. Running 10,000 Blocking Tasks

Say a hotel booking site checks room availability with a partner API on every search, and each call takes about 100 ms. With a pool of 100 platform threads, 10,000 searches arriving together need 100 rounds of 100 ms, so the last search waits about 10 seconds. With virtual threads, each search gets its own thread, and all of them wait at the same time.

The following example runs 10,000 tasks that each wait 100 ms, using a pause() helper that stands in for the partner call. Each task counts itself when it finishes.

static void pause(long ms) {
    LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(ms));
}
AtomicInteger finished = new AtomicInteger();
Set<String> carriers = ConcurrentHashMap.newKeySet();
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 10_000; i++) {
        executor.submit(() -> {
            String self = Thread.currentThread().toString();
            carriers.add(self.substring(self.indexOf('@') + 1));
            pause(100);                       // partner API call
            finished.incrementAndGet();
        });
    }
}
int completed = finished.get();                                      // 10000
boolean fewCarriers = carriers.size() <= Runtime.getRuntime().availableProcessors(); // true

We can see that 10,000 virtual threads ran on no more carrier threads than the machine has cores. The toString() of a mounted virtual thread shows its carrier, such as VirtualThread[#27]/runnable@ForkJoinPool-1-worker-2. Replacing the virtual thread executor with Executors.newFixedThreadPool(100) gives the same result with 100 tasks in progress at a time, so the job takes roughly 100 times longer.

4. How Virtual Threads Behave Differently

Most of the Thread API works the same for virtual threads, but a few methods behave differently. These differences matter when we move existing code to virtual threads.

Thread worker = Thread.ofVirtual().unstarted(() -> {});
worker.setPriority(Thread.MAX_PRIORITY);
int priority = worker.getPriority();          // 5, the call is ignored
boolean isDaemon = worker.isDaemon();         // true
worker.setDaemon(false);                      // IllegalArgumentException: 'false' not legal for virtual threads
  • Virtual threads are always daemon threads, so setDaemon(false) throws IllegalArgumentException, and the JVM exits without waiting for them.
  • The priority is always Thread.NORM_PRIORITY, and setPriority() is ignored.
  • Virtual threads have no name unless we set one, so getName() returns an empty string.
  • A running virtual thread belongs to a placeholder thread group named VirtualThreads, and thread groups have no other effect on it.
  • The method stop() throws UnsupportedOperationException for every thread on Java 25, and suspend() and resume() were removed in Java 23, so the two kinds of threads no longer differ there.

5. Pinning and the Java 24 Fix

A virtual thread is pinned when the JVM cannot unmount it while it blocks, so it keeps its carrier busy. In Java 21 to 23, blocking inside a synchronized block or method, or in Object.wait(), pinned the virtual thread. A few pinned threads were harmless, but code that blocked inside synchronized on every request could take all carriers and stall the app.

Since Java 24, JEP 491 lets virtual threads acquire, hold and release monitors without their carrier. On Java 25, blocking inside synchronized unmounts the virtual thread like any other blocking call, so the old advice to replace every synchronized with ReentrantLock no longer applies.

class RoomCounter {
    private int booked;

    synchronized void book() throws InterruptedException {
        booked++;
        wait(1);                              // unmounts on Java 24+, pinned on Java 21
    }

    synchronized int booked() {
        return booked;
    }
}
RoomCounter counter = new RoomCounter();
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 1_000; i++) {
        executor.submit(() -> { counter.book(); return null; });
    }
}
int booked = counter.booked();                // 1000

A virtual thread is still pinned while it runs native code, either a native method or a foreign function called through the FFM API. To find pinning in production, we record with JDK Flight Recorder and look for the jdk.VirtualThreadPinned event, which is on by default and reports blocking over 20 ms while pinned. The old jdk.tracePinnedThreads system property was removed in Java 24.

6. When Not to Use Virtual Threads

Virtual threads help only where many tasks wait at the same time. As a rule of thumb, an app that never has 10,000 or more virtual threads at once is unlikely to benefit from them.

SituationWhy virtual threads do not helpUse instead
CPU-bound work such as image resizing or hashingThe task never waits, so it holds its carrier the whole timeA fixed pool sized to the cores, or a parallel stream
A few long-running background jobsNo scalability problem to solvePlatform threads
Code that already uses async or reactive APIsThe code does not block a thread per taskKeep the async style, or move to blocking code completely
Lower latency for one requestA virtual thread runs code no fasterProfiling and optimizing the request itself
Heavy native calls through JNI or FFMNative frames pin the carrierPlatform threads for that part

7. Do Not Pool Virtual Threads

Thread pools exist because platform threads are expensive, and they also limit how many tasks run at the same time. Virtual threads are cheap, so a pool of them keeps the queue and the limit but gives none of the scalability. Each virtual thread represents one task and is discarded when the task ends.

// anti-pattern: a fixed pool of 50 virtual threads used as a concurrency limit
ExecutorService pooled = Executors.newFixedThreadPool(50, Thread.ofVirtual().factory());
pooled.close();

When the real goal is to protect a downstream system, such as a payment API that accepts 10 calls at a time, we create a virtual thread per task and limit the calls with a Semaphore. Tasks that wait for a permit block cheaply, and the limit applies only to the code that needs it.

class PaymentGateway {
    private final Semaphore limit = new Semaphore(10);

    String charge(int bookingId) throws InterruptedException {
        limit.acquire();
        try {
            pause(10);                        // payment API call
            return "charged-" + bookingId;
        } finally {
            limit.release();
        }
    }
}
PaymentGateway payments = new PaymentGateway();
List<Future<String>> charges = new ArrayList<>();
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int id = 1; id <= 100; id++) {
        int bookingId = id;
        charges.add(executor.submit(() -> payments.charge(bookingId)));
    }
}
String lastCharge = charges.get(99).resultNow();     // "charged-100"

8. ThreadLocal with Virtual Threads

Virtual threads support ThreadLocal variables like platform threads do. The problem is a common pattern from pooled code, where a ThreadLocal caches an expensive object such as a SimpleDateFormat so each pool thread creates it once. A virtual thread runs a single task, so every task creates its own copy, and a million tasks create a million copies.

For caching, we use an object that is safe to share instead. For example, DateTimeFormatter is immutable and thread-safe, so one instance serves every virtual thread.

DateTimeFormatter checkInFormat = DateTimeFormatter.ofPattern("dd MMM yyyy", Locale.ENGLISH);
String formatted;
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    formatted = executor.submit(() -> checkInFormat.format(LocalDate.of(2026, 10, 12))).get();
}
String checkIn = formatted;                   // "12 Oct 2026"

For per-request context, such as the user or a trace ID, ThreadLocal still works, but scoped values are the better fit. They became final in Java 25 with JEP 506, they are immutable, and they end with the scope that binds them, so nothing has to be removed by hand.

9. Java Virtual Threads FAQs

The same handful of questions comes up whenever a team plans to turn virtual threads on in production.

9.1. Are virtual threads faster than platform threads?

No. A virtual thread runs Java code at the same speed as a platform thread. The gain is throughput, because many more tasks can wait at the same time, so a server that is limited by its thread pool handles more requests per second.

9.2. How many carrier threads does the JVM use?

By default, as many as there are available processors. The system property jdk.virtualThreadScheduler.parallelism changes the number, but the default fits most apps.

9.3. Do virtual threads still pin on synchronized?

Not since Java 24. With JEP 491, a virtual thread that blocks inside synchronized or in Object.wait() unmounts from its carrier. On Java 21 to 23, it stays pinned, so on those versions we replace synchronized with ReentrantLock in code that blocks often.

9.4. Should we use virtual threads instead of reactive programming?

For new blocking code that waits on I/O, virtual threads give similar scalability with simpler, sequential code that is easier to debug. Reactive libraries still help with streaming, backpressure and complex event pipelines, so existing reactive code does not need a rewrite.

9.5. How can code check whether it runs on a virtual thread?

We call Thread.currentThread().isVirtual(), which returns true inside a virtual thread. Logging it once at startup of a request handler is a quick way to confirm that a framework setting, such as spring.threads.virtual.enabled in Spring Boot, took effect.

9.6. Can virtual threads run structured concurrency code?

Yes. Structured concurrency forks each subtask in a new virtual thread by default. It is a preview API in Java 25, so it needs the –enable-preview flag.

10. Conclusion

Java virtual threads are Thread objects that the JVM runs on a few carrier threads and unmounts while they wait. That makes one thread per task affordable again, and since Java 24 even synchronized code blocks without pinning.

Virtual threads suit servers and clients with many blocking I/O tasks. We create one per task instead of pooling them, limit access to scarce resources with a Semaphore, avoid caching objects in ThreadLocal, and keep CPU-bound work on pools sized to the cores. The rest of the concurrency topics are collected in the Java concurrency guide.

11. References

Happy Learning !!

Source Code on Github

Leave a Comment

  1. Dear Lokesh
    Please explain more about following sentence, what the meaning of unbounded
    “The number of threads created by the Executor is unbounded”

    Regrads
    Kaveh

    • Unbounded simply means here that there is no limitation or upper bound on the number of virtual threads created by this method. You keep adding tasks, and it will create a new thread for each task infinitely.

Comments are closed.

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.