---
title: 'Virtual Threads'
source: 'https://academia.sh/en/courses/jvm-and-concurrency/virtual-threads'
course: 'JVM, Concurrency and Performance'
language: en
updated: '2026-09-13T05:09:02+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Measures how distinct thread identity count changes by running the same task set on a fixed pool and on an executor giving one virtual thread per task'
    - 'Shows, by running it, that a virtual thread is a real Thread distinguished with isVirtual(), not a platform thread'
    - 'Measures that ThreadLocal keeps a separate value per virtual thread, and that this is a cost'
---

# Virtual Threads

For the same task set, distinct identity stays bounded at four in a fixed pool but grows with the task count in an executor that gives one virtual thread per task; a virtual thread is a real Thread distinguished with isVirtual(), not a platform thread; the carrier cannot be observed; and ThreadLocal keeping a separate value per virtual thread is a cost.

The previous lesson treated the pool's threads as a bounded resource: a fixed number existed,
they filled up, they were reused in turn, and fullness put the caller to work. This bound came
from the platform thread itself being expensive — each one carries an allocation from the
operating system and a scheduling record. This lesson asks the same question under an entirely
different premise: what if the thread itself is cheap? A virtual thread is a far cheaper kind of
thread that comes from the virtual machine, not the operating system. The same task set is run
on two separate executors and the same question is asked again — which thread runs this step —
but this time **how many distinct** threads it can produce is also measured.

In the previous lesson, the executor interface stayed fixed and only the number given to the
constructor changed. In this lesson the interface is the same again — `ExecutorService`,
`submit`, `invokeAll` — but the executor itself produces threads from an entirely different
source. That two different production mechanisms can stand behind the same interface is the
first thing this lesson measures.

## The Same Task Set on Two Executors: Distinct Identity Count

The previous lesson's distinct identity counting technique carries over unchanged: each task
adds the thread it ran on to a set using only its identity (`==` equivalence), no name or number
is printed.

**CC22.** Distinct identity counting also applies to virtual threads; as a subtype of the
`Thread` class, a virtual thread also carries an object identity and can be counted with the
same set.
**CC23.** An executor giving one virtual thread per task (`newVirtualThreadPerTaskExecutor`)
creates a **new** virtual thread for every task given to it; this is a structural rule of the
executor and does not depend on scheduling.

```java
// Identities.java — a set that counts distinct thread identity using == equivalence (helper source, no main)
class Identities {
    static java.util.Set<Thread> newSet() {
        return java.util.Collections.synchronizedSet(
                java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>()));
    }
}
```

```java
// TwoExecutors.java — the same task set produces separate distinct identity counts on a fixed pool and a virtual executor
import java.util.*;
import java.util.concurrent.*;

public class TwoExecutors {
    static int distinctIdentities(ExecutorService executor, int taskCount) throws Exception {
        Set<Thread> seen = Identities.newSet();
        List<Callable<Void>> tasks = new ArrayList<>();
        for (int i = 0; i < taskCount; i++) {
            tasks.add(() -> { seen.add(Thread.currentThread()); return null; });
        }
        executor.invokeAll(tasks);
        return seen.size();
    }

    public static void main(String[] args) throws Exception {
        try (ExecutorService fixed4 = Executors.newFixedThreadPool(4);
             ExecutorService virtual = Executors.newVirtualThreadPerTaskExecutor()) {
            System.out.println("4 tasks,  fixed pool(4)    -> distinct identities: " + distinctIdentities(fixed4, 4));
            System.out.println("4 tasks,  virtual executor -> distinct identities: " + distinctIdentities(virtual, 4));
        }
        try (ExecutorService fixed4b = Executors.newFixedThreadPool(4);
             ExecutorService virtualb = Executors.newVirtualThreadPerTaskExecutor()) {
            System.out.println("20 tasks, fixed pool(4)    -> distinct identities: " + distinctIdentities(fixed4b, 20));
            System.out.println("20 tasks, virtual executor -> distinct identities: " + distinctIdentities(virtualb, 20));
        }
    }
}
```

```
4 tasks,  fixed pool(4)    -> distinct identities: 4
4 tasks,  virtual executor -> distinct identities: 4
20 tasks, fixed pool(4)    -> distinct identities: 4
20 tasks, virtual executor -> distinct identities: 20
```

With four tasks, both executors produce four distinct identities — when the task count equals
the pool size, no difference shows up. With twenty tasks, the difference opens up: the fixed
pool stays at four distinct identities **again**, because the rule from the previous lesson
still holds here — pool size is an upper bound independent of how many tasks arrive. The virtual
executor, on the other hand, produces exactly **twenty** distinct identities — every task finds
its own virtual thread, none of them waits for a previous one and takes its place. The number
grows together with the task count, because a virtual thread executor is not a pool — it is a
mechanism that **spawns** a new thread for every task (CC23).

The number twenty was not chosen at random: four does not show the difference, because it
coincides with the pool size; twenty, being several times the pool size, makes visible the fact
that the fixed pool hits its **ceiling** and stays there. In the previous lesson, this ceiling
was measured through fullness — an `execute` call falling to the caller; here, the same ceiling
is measured as a production limit, through distinct identity count.

## A Virtual Thread Is Still a Thread

Whether the thing driving up the distinct identity count is really a thread can be verified
separately.

**CC24.** Whether a thread is virtual is distinguished **only** by `Thread.isVirtual()` — not by
name, priority, or any other observation.

```java
// VirtualCheck.java — a virtual thread is a real Thread distinguished by Thread.isVirtual()
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.*;

public class VirtualCheck {
    static int virtualCount(ExecutorService executor, int taskCount) throws Exception {
        AtomicInteger virtual = new AtomicInteger(0);
        List<Callable<Void>> tasks = new ArrayList<>();
        for (int i = 0; i < taskCount; i++) {
            tasks.add(() -> {
                if (Thread.currentThread().isVirtual()) virtual.incrementAndGet();
                return null;
            });
        }
        executor.invokeAll(tasks);
        return virtual.get();
    }

    public static void main(String[] args) throws Exception {
        try (ExecutorService fixed4 = Executors.newFixedThreadPool(4)) {
            System.out.println("fixed pool(4), 4 tasks  -> ran on a virtual thread: "
                    + virtualCount(fixed4, 4) + "/4");
        }
        try (ExecutorService virtual = Executors.newVirtualThreadPerTaskExecutor()) {
            System.out.println("virtual executor, 4 tasks -> ran on a virtual thread: "
                    + virtualCount(virtual, 4) + "/4");
        }
        Thread mainThread = Thread.currentThread();
        System.out.println("is the main thread virtual                             : "
                + mainThread.isVirtual());
    }
}
```

```
fixed pool(4), 4 tasks  -> ran on a virtual thread: 0/4
virtual executor, 4 tasks -> ran on a virtual thread: 4/4
is the main thread virtual                             : false
```

None of the fixed pool's four tasks run on a virtual thread, and all four of the virtual
executor's do; the task running the code is the **same** lambda in both cases, only the executor
changes. The thread running the `main` method is not virtual either — the program itself always
starts from a platform thread. A virtual thread exists as a subtype of the `Thread` class: it
shares the same `start`, `join`, `interrupt` calls, the same lifecycle, but it is **not a
platform thread** — that is, it does not correspond one-to-one to a thread allocated by the
operating system. This distinction connects directly to the number from the previous section: the
virtual executor could produce twenty distinct identities because what it produces is not a
resource allocated by the operating system, it is an object the virtual machine manages on its
own. `isVirtual()` is the only way to distinguish the two kinds; since both come from the same
superclass and carry the same methods, no other call gives this distinction.

## A Blocked Virtual Thread Dismounts Its Carrier

If there is no platform thread, how does a virtual thread's code run? A virtual thread mounts
onto a **carrier thread** while it runs — the code actually runs on a platform thread, and the
virtual thread borrows that platform thread. When a virtual thread is **blocked** in a call like
`CountDownLatch.await()`, it dismounts from its carrier: the platform thread underneath it is
freed and can run another virtual thread's code. Once the blocking ends, the virtual thread
mounts a carrier again — not necessarily the same one.

Mounting and dismounting is managed by the mechanism itself in standard-library calls like
`CountDownLatch`, locks, and blocking I/O; the caller does not set this mechanism up, it just
calls `await()` or `get()` as usual. How many times a given virtual thread mounts and dismounts
is also something that cannot be observed — this lesson does not measure that number either, it
only measures how many distinct virtual thread identities can exist **at the same time**.

**The carrier thread cannot be observed** — this lesson neither prints nor counts it; what is
measured is only the distinct count of **virtual** thread identity. **CC25.** Fifty tasks are all
set up to be blocked on a single `CountDownLatch` and released at the same time; the release
call is made right after all the tasks have been submitted, so the delay is not a race —
whenever a waiting task checks, once the `CountDownLatch` reaches zero it sees the same answer.

```java
// BlockedTasks.java — fifty blocking tasks produce four distinct identities on a fixed pool, fifty on a virtual executor
import java.util.*;
import java.util.concurrent.*;

public class BlockedTasks {
    static int blockedDistinctIdentity(ExecutorService executor, int taskCount) throws Exception {
        Set<Thread> seen = Identities.newSet();
        CountDownLatch release = new CountDownLatch(1);
        List<Future<?>> futures = new ArrayList<>();
        for (int i = 0; i < taskCount; i++) {
            futures.add(executor.submit(() -> {
                try {
                    release.await();
                } catch (InterruptedException e) { }
                seen.add(Thread.currentThread());
            }));
        }
        release.countDown();
        for (Future<?> f : futures) f.get();
        return seen.size();
    }

    public static void main(String[] args) throws Exception {
        int taskCount = 50;
        try (ExecutorService fixed4 = Executors.newFixedThreadPool(4)) {
            System.out.println(taskCount + " blocking tasks, fixed pool(4) -> distinct identities: "
                    + blockedDistinctIdentity(fixed4, taskCount));
        }
        try (ExecutorService virtual = Executors.newVirtualThreadPerTaskExecutor()) {
            System.out.println(taskCount + " blocking tasks, virtual executor -> distinct identities: "
                    + blockedDistinctIdentity(virtual, taskCount));
        }
    }
}
```

```
50 blocking tasks, fixed pool(4) -> distinct identities: 4
50 blocking tasks, virtual executor -> distinct identities: 50
```

All fifty tasks block at a single lock point, then are all released together. On the fixed pool,
four platform threads process these fifty tasks in turn, four at a time — distinct identity stays
at four. On the virtual executor, all fifty find their own virtual thread and all finish — none
of them waits in a queue for lack of a thread. This shows that a carrier pool built with four
platform threads was able to accommodate fifty virtual threads — because a blocked virtual thread
releases its carrier. **Limiting measurement:** the growth of distinct identity count is not a
speed claim, it is a fact. No duration is measured in this lesson; the only thing measured is how
many distinct virtual thread identities could exist. Whether this turns into a gain is only
meaningful through the question of how many threads the blocking code would otherwise occupy, and
that question is **modeled** here, not run: all fifty tasks were able to reach an "at-the-same-
time blocked task" count exceeding the platform thread count, while on the fixed pool that
number could never be larger than four.

This modeling deliberately draws a limit: making the number fifty into a hundred or a thousand
does not change the result, it only repeats the same fact with a bigger number. The lesson stops
here rather than scaling it up, because the claim is not in the number itself, it is in the fact
that a ceiling the fixed pool can **never** cross does not exist at all on the virtual
executor. When a platform thread is blocked, it keeps holding its own stack and scheduling
record; when a virtual thread is blocked, it releases the platform thread underneath it, and
what remains behind is only a small amount of state kept by the virtual machine — how many times
the carrier changed hands is not counted in this lesson, but it is what stands behind the "fifty
finished" result above.

## ThreadLocal Keeps a Separate Value per Task: A Cost

This abundance of virtual threads has a cost. `ThreadLocal` is a structure that gives each
thread its own value; the value is produced with `initialValue` on the first `get()` call and
stays tied to that thread.

```java
// ThreadLocalCost.java — ThreadLocal produces a separate value per virtual thread, shared in a fixed pool
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.*;

public class ThreadLocalCost {
    static int producedValueCount(ExecutorService executor, int taskCount) throws Exception {
        AtomicInteger counter = new AtomicInteger(0);
        ThreadLocal<Integer> own = ThreadLocal.withInitial(counter::incrementAndGet);
        List<Callable<Void>> tasks = new ArrayList<>();
        for (int i = 0; i < taskCount; i++) {
            tasks.add(() -> { own.get(); return null; });
        }
        executor.invokeAll(tasks);
        return counter.get();
    }

    public static void main(String[] args) throws Exception {
        int taskCount = 10;
        try (ExecutorService fixed4 = Executors.newFixedThreadPool(4)) {
            System.out.println(taskCount + " tasks, fixed pool(4) -> produced values: "
                    + producedValueCount(fixed4, taskCount));
        }
        try (ExecutorService virtual = Executors.newVirtualThreadPerTaskExecutor()) {
            System.out.println(taskCount + " tasks, virtual executor -> produced values: "
                    + producedValueCount(virtual, taskCount));
        }
    }
}
```

```
10 tasks, fixed pool(4) -> produced values: 4
10 tasks, virtual executor -> produced values: 10
```

The `own` variable is a single `ThreadLocal` object, read from the same code line in both cases.
On the fixed pool, ten tasks share four platform threads; each platform thread produces its own
value once and **reuses** it on later tasks — four values are produced in total. On the virtual
executor, since each of the ten tasks runs on its own virtual thread, `initialValue` is called
ten times, and no value is shared at all. This is a result that points the same way as the
previous section's distinct identity count, but needs to be read in reverse: the abundance of
virtual threads makes code using `ThreadLocal` produce that same abundance of output. Running one
virtual thread per task can mean setting up one `ThreadLocal` value per task — this is a cost,
and it can go unnoticed with large task counts.

Read together, the four measurements bring the course to a claim: a virtual thread makes the
thread itself cheaper, but it does not make everything that **comes with** a thread cheaper.
`ThreadLocal` is the one measured example of this; any other structure that grows with the
thread count can carry the same cost. As distinct identity count grows, this cost grows with it —
like the gain itself, the cost too is a fact, and needs to be measured separately.

## Summary

- The same task set produces a distinct identity count bounded by pool size on a fixed pool,
  but one that grows with the task count on an executor giving one virtual thread per task.
- A virtual thread is a real `Thread` distinguished with `Thread.isVirtual()`; it shares the
  same lifecycle API but does not correspond one-to-one to a platform thread.
- A blocked virtual thread dismounts its carrier and frees the platform thread; the carrier
  cannot be observed, and the only thing measured is distinct virtual identity count.
- The growth of distinct identity count is not a speed claim, it is a fact; the gain is only
  meaningful through the question of how many threads the blocking code would otherwise occupy,
  and that question is modeled, not run.
- `ThreadLocal` shares its value across threads in a fixed pool, but produces a separate value
  per task on a virtual executor; this is a cost carried by the abundance of virtual threads.

## Next Step

This lesson made the thread itself cheaper and showed that spawning one per task is possible.
But every task so far has been independent — one did not start until another finished. The next
lesson strings independent tasks into a **chain**: one step's result becomes the next step's
input. The question of who runs these chained stages is now asked not for a single task, but for
a sequential series.
