iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🌊

Platform Threads vs. Virtual Threads in the JDK: Understanding the Differences

に公開

Introduction

Until now, the following classes have been used for Java concurrency:
java.lang.Thread
Concurrency APIs in java.util.concurrent, including java.util.concurrent.ExecutorService
These are known as Platform Threads.

However, as time has passed and the processing scale required by products has increased, the following problems with Platform Threads have become more apparent.

"Not scaling" refers to the following aspects:

  • Scaling in number (cannot increase the "number of processes held simultaneously")
    • Mapping 1:1 to the OS = limit assumed to be around several thousand to 10,000
    • Consumes large amounts of memory
  • Resource efficiency ("CPU is idle, but cannot process")
    • Cannot scale even when the thread pool reaches its limit, leading to pool exhaustion
    • Increased context switching (the CPU switching between running threads)
    • Deterioration of latency
  • Wait time (threads are occupied even when not using the CPU)
    • Threads are occupied during wait times. Processing stops.

This is not to say that Platform Threads are bad, but rather that because they are a "model designed on the premise of OS threads," they are simply unsuitable for workloads that assume high concurrency.
The strengths of Platform Threads are:

  • CPU-bound processing (numerical calculations like encryption/decryption, data parsing)
  • Small numbers of long-running threads
  • Processes with minimal I/O wait that continuously consume CPU resources

What are Virtual Threads?

Virtual Threads were officially released in Java 21 and are instances of java.lang.Thread.
According to the official documentation, Virtual Threads have the following characteristics:

  • Managed by the JVM (Java Virtual Machine)
  • Not mapped 1:1 to OS threads (N virtual threads are mapped to a single OS thread)
  • When waiting for I/O, they can release the linked OS thread and allocate it to other virtual thread tasks

Differences between Platform Threads and Virtual Threads from the perspective of the JDK

Virtual Threads

From here, let's look at the differences between the two types of threads from the JDK's internal source code.
Creating and starting a Virtual Thread is executed as follows:

java.lang.Thread.java
Thread thread = Thread.ofVirtual().start(() -> System.out.println("Hello"));

What is Thread.ofVirtual doing?

java.lang.Thread.java
public static Builder.OfVirtual ofVirtual() {
    return new ThreadBuilders.VirtualThreadBuilder();
}

ThreadBuilders.VirtualThreadBuilder() returns an instance of VirtualThreadBuilder, which is an inner class of ThreadBuilders.java.

java.lang.ThreadBuilders.java
static final class VirtualThreadBuilder
        extends BaseThreadBuilder implements OfVirtual {
    private Executor scheduler;
        VirtualThreadBuilder() {
    }
// ...omitted below...

What happens when you call start() on a VirtualThreadBuilder instance?

java.lang.ThreadBuilders.java
        @Override
        public Thread unstarted(Runnable task) {
            Objects.requireNonNull(task);
            var thread = newVirtualThread(scheduler, nextThreadName(), characteristics(), task);
            UncaughtExceptionHandler uhe = uncaughtExceptionHandler();
            if (uhe != null)
                thread.uncaughtExceptionHandler(uhe);
            return thread;
        }

        @Override
        public Thread start(Runnable task) {
            Thread thread = unstarted(task);
            thread.start();
            return thread;
        }
java.lang.ThreadBuilders.java
The point of interest is the `var thread = newVirtualThread(scheduler, nextThreadName(), characteristics(), task);` part, where the virtual thread is created by the following process:
static Thread newVirtualThread(Executor scheduler,
                               String name,
                               int characteristics,
                               Runnable task) {
    if (ContinuationSupport.isSupported()) {
        return new VirtualThread(scheduler, name, characteristics, task);
    } else {
        if (scheduler != null)
            throw new UnsupportedOperationException();
        return new BoundVirtualThread(name, characteristics, task);
    }
}

```java:java.lang.VirtualThread.java
    VirtualThread(Executor scheduler, String name, int characteristics, Runnable task) {
        super(name, characteristics, /*bound*/ false);
        Objects.requireNonNull(task);

        // choose scheduler if not specified
        if (scheduler == null) {
            Thread parent = Thread.currentThread();
            if (parent instanceof VirtualThread vparent) {
                scheduler = vparent.scheduler;
            } else {
                scheduler = DEFAULT_SCHEDULER;
            }
        }

        this.scheduler = scheduler;
        this.cont = new VThreadContinuation(this, task);
        this.runContinuation = this::runContinuation;
    }

The scheduler in VirtualThread that appears here is called a ForkJoinPool, which serves as the thread pool where the virtual threads operate.
This is the key point where virtual threads are linked N:1 with OS threads.
The Continuation shown here forms the foundation.

private static class VThreadContinuation extends Continuation {
        VThreadContinuation(VirtualThread vthread, Runnable task) {
            super(VTHREAD_SCOPE, wrap(vthread, task));
        }
        @Override
        protected void onPinned(Continuation.Pinned reason) {
        }
        private static Runnable wrap(VirtualThread vthread, Runnable task) {
            return new Runnable() {
                @Hidden
                @JvmtiHideEvents
                public void run() {
                    vthread.endFirstTransition();
                    try {
                        vthread.run(task);
                    } finally {
                        vthread.startFinalTransition();
                    }
                }
            };
        }
    }

The run() method of this VThreadContinuation class executes the actual task.
Another essential process is this.runContinuation = this::runContinuation;.

private void runContinuation() {
 ~~Omitted~~
        mount();
        try {
            cont.run();
        } finally {
            unmount();
            if (cont.isDone()) {
                afterDone();
            } else {
                afterYield();
            }
        }
    }

The mount/unmount called here are the core of virtual threads.

private void mount() {
        startTransition(/*is_mount*/true);
        // We assume following volatile accesses provide equivalent
        // of acquire ordering, otherwise we need U.loadFence() here.

        // sets the carrier thread
        Thread carrier = Thread.currentCarrierThread();
        setCarrierThread(carrier);

        // sync up carrier thread interrupted status if needed
        if (interrupted) {
            carrier.setInterrupt();
        } else if (carrier.isInterrupted()) {
            synchronized (interruptLock) {
                // need to recheck interrupted status
                if (!interrupted) {
                    carrier.clearInterrupt();
                }
            }
        }

        // set Thread.currentThread() to return this virtual thread
        carrier.setCurrentThread(this);
    }

In this mount() method, the virtual thread is being mounted onto an OS thread.

    private void unmount() {
        assert !Thread.holdsLock(interruptLock);

        // set Thread.currentThread() to return the platform thread
        Thread carrier = this.carrierThread;
        carrier.setCurrentThread(carrier);

        // break connection to carrier thread, synchronized with interrupt
        synchronized (interruptLock) {
            setCarrierThread(null);
        }
        carrier.clearInterrupt();

        // We assume previous volatile accesses provide equivalent
        // of release ordering, otherwise we need U.storeFence() here.
        endTransition(/*is_mount*/false);
    }

In this unmount() method, the virtual thread mounted on the OS thread is being unmounted (i.e., detached).
Also,
Through this mount-unmount process, threads that are blocked by I/O are released and resumed.
From the above, it can be seen that Virtual Threads lead to the following characteristics:

  • Not fixed 1:1 with OS threads → A small number of OS threads are shared by a large number of Virtual Threads
  • Resources are released when I/O blocking occurs
  • Tends to be disadvantageous for CPU-bound processing (because they are managed on the JVM)

We can see how this leads to these characteristics.

Platform Threads

For Platform Threads as well, the process from instance creation to task execution proceeds in a similar way, but the start method is distinctly different.

java.lang.Thread.java
    public void start() {
        synchronized (this) {
            // zero status corresponds to state "NEW".
            if (holder.threadStatus != 0)
                throw new IllegalThreadStateException();
            start0();
        }
    }

This start0() is a method that calls a C++ native method named private native void start0(). Since I am not well-versed in C++, this will be a broad explanation, but it links the JVM thread to an OS thread. Because a link to an OS thread is established every time Thread.start() is called, it means that Platform Threads map threads to OS threads 1:1.

Because threads are mapped directly to OS threads, we can understand how this leads to characteristics such as:

  • High-speed processing is possible
  • Scaling is not possible

Summary

Although it was a brief overview, I have summarized the differences between Platform Threads and Virtual Threads from the perspective of the JDK internals. I hope to keep in mind how to use each of them correctly by taking advantage of their respective characteristics.

Discussion