Virtual Threads in Javia 21

A bit of history
Java has allowed concurrent programming since version 1.0 through the use of the Thread class.
This capability was introduced in 1996 (I celebrated my 18th birthday that year!), but it quickly became evident that it wasn’t straightforward. Larger and more complex applications required better tools to handle threads.
The standard Java library lacked essential constructs for modeling certain concurrent programming patterns, and the code was often hard to read and prone to errors.
Then, eight years later, in 2004, Java SE 5.0 was released, and it brought new classes in java.util.concurrent (concurrency utilities overview). Although these changes were overshadowed by generics, enums, varargs, and annotations, they significantly improved concurrent programming:
- The Executor framework allowed us to queue tasks (Runnable) in a scheduler, eliminating the need to manually start threads.
- New efficient implementations of Map, List, and Queue interfaces for concurrent programming (interfaces Queue and BlockingQueue were introduced in this version) were introduced
- Thread synchronization tools such as mutexes, semaphores, barriers, and latches were added.
- Lock implementations in java.util.concurrent.lock improved the synchronization using the synchronized {} mechanism, adding features like defining timeouts for lock waiting, multiple conditional variables per lock, and interrupting waiting threads.
Ten years later, in 2014, Java 8 was released.
It introduced lambdas, which somewhat overshadowed the new concurrency management mechanism called fork-join. Fork-join allowed multiple lists of threads waiting for tasks to be executed by an executor, enabling an algorithm called “work-stealing” where threads can “steal” tasks from other lists. Additionally, Java 8 significantly extended the standard library with the Completable Future API, attempting to create an asynchronous programming model in Java. However, it didn’t gain as much popularity among developers compared to the async/await model in many other programming languages.
And now, after another ten years, the Loom project comes on stage, bringing another upgrade: virtual threads, structured concurrency, and scoped values.
Examples and demos of virtual threads (by Hose Paumard) can be found in the demo repository.
What is a thread?
Until now, Java threads were merely thin wrappers around operating system threads (OS threads or kernel threads). OS threads - called pthreads or platform threads - are quite resource-intensive:
- Starting a thread takes about 1 millisecond.
- Each thread requires memory allocation (around 2MB) to hold its stack.
- Switching between threads is a heavyweight operation (context switch) managed by the operating system scheduler (see context switch).
The weakness of the thread-per-request model
Because of these limitations, “commodity hardware” is somewhat restricted when it comes to creating platform threads. On heavily loaded application servers that follow the “one thread per request” model, reaching hundreds of thousands of requests could lead to memory exhaustion, causing the server to crash. On the other hand, using thread pools (limiting the number of threads) might lead to performance issues due to insufficient processing power to handle incoming connections/requests.
The JVM world attempted to address this problem with approaches like actor-based programming (Akka in Scala and Java), Coroutines in Kotlin, Vert.x, and even in plain Java. We had…
Not perfect Completable Future
Asynchronous programming model in Java with CompletableFuture is not perfect (supplyAsync…, andThenApply… andThenApply…):
- it is very hard to read
- it is difficult to draw conclusions about the course of control
- it is not known in which thread Runnables are executed (debugging and logging is very difficult)
- it is difficult to unit test
So, again, after next ten years there are finally virtual threads implementeded in core Java:
Virtual threads and platform threads
Platform threads
- Platform threads map 1-to-1 to kernel threads, which are queued by the operating system scheduler.
- They have a large stack size and require resources managed by the OS.
- While they are suitable for many tasks, they are expensive resources.
- Platform threads have default names and can be daemon or non-daemon threads. The main thread in which main() method runs is the main non-daemon thread. The JVM starts the shutdown sequence once all non-daemon threads have finished.
Virtual threads
- Virtual threads, on the other hand, are user-mode threads managed by the JVM rather than the operating system scheduler.
- They are mapped in a v:p proportion (v > p) to system threads. Multiple virtual threads can map to a single platform thread.
- Virtual threads are lightweight threads managed by a special fork-join pool controlled by the JVM. A single platform thread can execute multiple virtual threads simultaneously.
- When a virtual thread executing on a specific platform thread starts a blocking I/O operation, its “state” (data) may be moved to the heap. Once the kernel completes the I/O operation, the JVM will restore the virtual thread from the heap onto a potentially different platform thread.
- The currentThread() method returns information about the virtual thread, and when we create a virtual thread, we don’t have access to the platform thread information.
- Virtual threads don’t have default names, and if we don’t set one, getName() will return an empty string.
- They have a fixed and unchangeable priority.
- They are daemon threads and don’t block JVM shutdown.
Creating and running threads
To create and run threads, the developer writes code in the traditional way: by creating Runnables and running them using threads to perform long-running operations without blocking the main application thread. The new Java 21 provides two methods for this purpose. The first one creates platform threads directly, and the second one creates virtual threads. The class that actually creates threads is a thread builder
- Thead.ofPlatform() creates a builder for platform threads:
|
|
- Thread.ofVirtual() - creates a builder for virtual threads or a builder for creating vurtual thread factory:
|
|
Simple test program
This example is an extension of: JosePaumard’s Loom demo.
The MaxThreads.java starts a number of virtual (virt
) or platform (plat
) threads; the number is passed as commandline parameter; orders each thread to sleep for two seconds, waits untill the threds complete and prints out the time it took for them to finish:
Here’s how I’d start ten platform threads:
java --source 21 --enable-preview MaxThreads.java plat 10
And here is how I’d start fifteen virtyal thereads:
java --source 21 --enable-preview MaxThreads.java virt 15
The program:
|
|
Platform threads - running the program
I’ll try to run the above program in a loop, each time increasing the number of threads by an order of magnitute (i.e. ten times). I start with platform threads and iterate from ten to a milion of threads:
JVM on my machine didn’t make it to handle ten thousands of platform threads:
|
|
Virtual threads - running the program
How are virtual threads doing? ** The’re doing great!** :)
|
|
Inside
Until now, the only possible state of a thread (except from “running”) was that it could be interrupted (infamous InterruptedException
), could complete normally or could throw an exception. Now something elme may happen: thread can be “parked” - e.g. when theoperating system is waiting for some I/O operation to complete - and after a while it will be “unparked”, that means, restored to a tate where it can continiue with its coputation with the received data. How is this possible?
If you look deep into Thread class implementation, in particular into sleep
method, you’ll see a Continuation
class which is an internal wrapper aroud a platform thread. It has an interesting yieldContinuation
method which is responsible for giving away the control to the platform thread. If we enable access to interanl java module (see openjdk mailing list thread): java --add-opens java.base/jdk.internal.vm=ALL-UNNAMED <your-main-class>
then we can use ContinuationScope
class ourselves in such a way that - while still being inside a “Continuation” - we’ll “park” current task, and the rest of the code won’t be executed. If we start continuation again, it will pick up where it left and continue execution till the end. This is not a public API (i and it is not yet desided if it will ever be), but itis quite interesting to see what’s possible.
This code shows how to steet thread execution using Continuation: G3_Continuation_Yield.
Structured concurency
With virtual threads available, developers can try out structured concurrency, which allows for writing imperative code without using CompletableFuture. [StructuredTaskScope](https://download.java.net/java/early_access/jdk21/docs/api/java.base/java/util/concurrent/StructuredTaskScope.html] enables the following features:
- Creation of multiple virtual threads (
fork()
) - Definition of when the scope should be closed (on success, on failure)
- Clear visibility of dependencies between threads in thread dumps
- The ability to cancel the parent thread, causing all the child threads to be canceled as well
- No need to explicitly declare an ExecutorService, which might cause confusion about when to shut it down
For example:
|
|
Scoped value
The traditional ThreadLocal has been replaced within the StructuredTaskScope by the ScopedValue mechanism.
Like ThreadLocal, ScopedValue allows sharing values across child threads without passing them as parameters. However, a ScopedValue is tied to and visible only within a dynamically scoped region (“scope”), and it’s not accessible outside of it. It can be bound to different values, and child threads will see the new value.
Sample usage
|
|
You can read about ScopedValues in the blog post about ScopedValues and ThreadLocals.
Preparations in the ecosystem
Interestingly, even Spring Boot has prepared itself to run request handling on virtual threads using its internal Tomcat. You can implement and expose as a @Bean a TomcatProtocolHandlerCustomizer class that defines callables for setting the appropriate executor used in synchronous requests (return handler -> handler.setExecutor(Executors.newVirtualThreadPoolPerTaskExecutor()
)). For more details and a demo, you can refer to the excellent presentation by Jose Paumard: “Virtual Threads and Structured Concurrency in Java 21 With Loom,” which inspired and provided the code for this post.
For more details I highly recommend the video with JobePaumard (of JavaCafe) which inspired me to check and play with his demo code: JosePaumard: Virtual Threads and Structured Concurrency in Java 21 With Loom and to write this blogpost.
The evening is comming, so it’s time to take care of the house and kids.
Happy coding!