Skip to content
academia.sh

Lesson 10 / 11

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.

Published

In this lesson

  • 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
Contents

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.

// 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<>()));
    }
}
// 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.

// 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.

// 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.

// 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.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close