Project Loom or fibers as it was called in its initial stage, but better and more famously known today as Virtual threads was made LTS in Java 21. Virtual threads are a significant development in the JDK ecosystem in the context of non-blocking IO. Creating virtual threads is straightforward and cheap. Understanding Java Virtual Threads can get nasty, so to keep it simple, we must think of them as tasks or objects and not threads. Since virtual threads are a concept and not a physical advancement in threads, they are backward compatible in terms of hardware. Meaning, virtual threads do not need any specific hardware to run. They can be run on a server/CPU manufactured in the 2000s and on any OS, since the JVM itself is platform independent. If you come from another programming language background, in the polyglot world you may compare Java’s Virtual Threads to Coroutines in Kotlin and GoRoutines in Golang.
Java Project Loom introduces the concept of a Java Virtual Thread, enabling lightweight, JVM-managed concurrency that dramatically improves scalability for I/O-bound applications. If you’re new to the concept, you may find our quick introduction to Java Virtual Threads helpful.
Java Virtual Threads are lightweight subprocesses, which are cheap to create in a Java application. The number of Virtual Threads you can create in a java app can be significantly more than the number of platform or OS native threads you can create. There is always a physical limitation on the number of threads a user can create on a specific machine. In traditional Java, for decades, one Java thread corresponds to one OS thread. To keep the article free from ambiguity, let’s call the traditional OS threads as platform threads to distinguish them from Virtual Threads. The number of platform threads that can be created in an application is finite and dependents on the OS and CPU i.e. hardware specific. A production server with strong and large CPU cores can create and schedule multiple threads compared to an individual developer’s work laptop.
For a deeper dive into why Virtual Threads are considered lightweight and how they differ from traditional threads, see Is Java Virtual Threads Truly Lightweight?
When to use Java Virtual Threads
Virtual Threads embrace the concept of non-blocking IO, a use case for which they are best suited. Using virtual threads at the right place can do wonders, but using them for the wrong use case will leave you disheartened as you would see no significant improvement in terms of scalability and performance in your Java applications. Java Virtual Threads shine in specific scenarios. These scenarios are the ones where your application tasks undergo waiting or more technically, the OS threads enter waiting or blocked state. In traditional microservices architecture, it is pretty common for apps to make REST calls to other services, post messages onto a message broker and insert/fetch data from databases. All these are network calls, and the application threads wait for a few milliseconds or seconds to retrieve the required information over the network to complete their corresponding requests.
We’ve explored the advantages of Virtual Threads in greater detail in our article Advantages of Java Virtual Threads.
Limitations of platform threads
Rather than discussing theory, let’s discuss the limitation of platform threads through a program.
import com.example.utils.CommonUtils;import java.util.concurrent.CountDownLatch;/** * * @author example * @apiNote Demonstrates creation of n no. of native OS threads **/public class App { private static final int MAX_PLATFORM_THREADS = 20_000; private static final int MAX_VIRTUAL_THREADS = 20_000; public static void main(String[] args) throws InterruptedException { System.out.println(Runtime.getRuntime().availableProcessors()); //exploreVirtualThreads(); // line 1 explorePlatformThreads(); // line 2 CommonUtils.logger.accept("Main done."); } private static void explorePlatformThreads() { for (int i = 0; i < MAX_PLATFORM_THREADS; i++) { int j = i; new Thread(() -> Task.ioIntensive(j)).start(); } } /** * blocking and nullifying daemon behaviour using countDown Latch * * @throws InterruptedException */ private static void exploreVirtualThreads() throws InterruptedException { CountDownLatch latch = new CountDownLatch(MAX_VIRTUAL_THREADS); for (int i = 0; i < MAX_VIRTUAL_THREADS; i++) { int j = i; // create a virtual thread Thread.ofVirtual() .name("VThread ", i) .start(() -> { Task.ioIntensive(j); latch.countDown(); }); } latch.await(); }}
It is worth noting that the use of countdown latch is not optional since the Virtual Threads are daemon by default.
public class Task { private static final Logger log = LoggerFactory.getLogger(Task.class); public static void ioIntensive(int i) { try { log.info("io intensive task started: {} , {}", i, Thread.currentThread()); Thread.sleep(1000); log.info("io intensive task ended : {}, {}", i, Thread.currentThread()); } catch (Exception ignored) { } }}
Running the above program for platform threads will yield this error in console:
18:32:06.770 [Thread-2017] INFO com.example.Task -- io intensive task started: 2017 , Thread[#2046,Thread-2017,5,main][0.654s][warning][os,thread] Failed to start thread "Unknown thread" - pthread_create failed (EAGAIN) for attributes: stacksize: 2048k, guardsize: 16k, detached.[0.654s][warning][os,thread] Failed to start the native thread for java.lang.Thread "Thread-2018"Exception in thread "main" java.lang.OutOfMemoryError: unable to create native thread: possibly out of memory or process/resource limits reached at java.base/java.lang.Thread.start0(Native Method) at java.base/java.lang.Thread.start(Thread.java:1526) at com.example.App.explorePlatformThreads(App.java:28) at com.example.App.main(App.java:21)[1.041s][warning][os,thread] Failed to start thread "Unknown thread" - pthread_create failed (EAGAIN) for attributes: stacksize: 2048k, guardsize: 16k, detached.[1.041s][warning][os,thread] Failed to start the native thread for java.lang.Thread "RMI TCP Accept-0"
Note :
The number of threads in your case could be more or less depending on how powerful your machine is. This is the output running the code on an Apple Silicon M3 chip with 8 GB RAM and 8 cores.
Comment out the line 2 and run after uncommenting the line 1. You will see that the program does not crash like it did previously and exists normally:
18:46:24.134 [VThread 19966] INFO com.example.Task -- io intensive task ended : 19966, VirtualThread[#19995,VThread 19966]/runnable@ForkJoinPool-1-worker-618:46:24.134 [VThread 19983] INFO com.example.Task -- io intensive task ended : 19983, VirtualThread[#20012,VThread 19983]/runnable@ForkJoinPool-1-worker-618:46:24.134 [VThread 19985] INFO com.example.Task -- io intensive task ended : 19985, VirtualThread[#20014,VThread 19985]/runnable@ForkJoinPool-1-worker-618:46:24.134 [VThread 19987] INFO com.example.Task -- io intensive task ended : 19987, VirtualThread[#20016,VThread 19987]/runnable@ForkJoinPool-1-worker-618:46:24.134 [VThread 19995] INFO com.example.Task -- io intensive task ended : 19995, VirtualThread[#20024,VThread 19995]/runnable@ForkJoinPool-1-worker-818:46:24.134 [VThread 19990] INFO com.example.Task -- io intensive task ended : 19990, VirtualThread[#20019,VThread 19990]/runnable@ForkJoinPool-1-worker-818:46:24.136 [main] INFO com.example.utils.CommonUtils -- Main done.Process finished with exit code 0
How Java Virtual Threads work under the hood
Virtual Threads are scheduled by the JVM and not the OS scheduler. This distinguishes them entirely. When a developer creates a virtual thread for executing a particular task, this task is sent into a queue. JVM picks tasks from this queue and mounts it to a carrier thread. When the carrier thread starts executing this task and enters a waiting state, the task is unmounted from the carrier thread and its content is stored on the heap. This process is called parking. When the waiting operation like DB call, network call is over, the virtual thread is unparked and mounted back on the carrier thread and processing resume. The data of the virtual thread is stored in its virtual stack. This virtual stack is outbound unlike the traditional platform thread’s stack which is final and configured at runtime of application and cannot be changed throughout application runtime. Having understood this, you must have been quick to figure out that Virtual Threads are merely an optical illusion. There is obviously nothing like running millions of threads in a single program – that’s impossible! This is where Oracle Java maintainers have done the magic for us.
Some important factors about Virtual Threads
Daemon behavior
Virtual threads are daemon by default.
Pooling of Virtual Threads
Java Virtual Threads should not be pooled like the traditional pooling for the platform threads. Virtual threads use ForkJoinPool under the hood. For the thread per request model, there exists a factory pool under Executors.
CPU intensive tasks
If you choose to use Virtual Threads for implementing CPU intensive tasks like heavy numeric computing or serialisation you will find that the Java Virtual Threads do not shine here since they are not best fitted for CPU intensive tasks.
Pinning
One of the known issues in Virtual Threads post its release in Java 21 was pinning. The issue is somewhat complex and even harder to debug, but in a simple to understand way : pinning is a situation in which a virtual thread enters a synchronized block or method. When a virtual thread enters a synchronized block or method, it cannot be unmounted or unparked. This may show up as undesirable negative performance in high load applications. So we must be careful to use Virtual Threads if our application code has a lot of manual synchronization. Oracle fixed the thread pinning issue in Java 24 and Java 25 happens to be the first LTS version released since the fix. So if you are using Java 21 in production you must keep this thing in mind.
Java Project Loom case study: Creating 2 million Virtual Threads
This experiment highlights Java Project Loom in action, demonstrating how a Java Virtual Thread scales far beyond the limits of platform threads. As an exercise, if you increase the MAX_VIRTUAL_THREADS to 2 million, the program would still run. That’s the power of Virtual Threads! Here are few screenshots taken after changing the MAX_VIRTUAL_THREADS to 2 million and then taking a thread dump using fastThread

Fig: yCrashThread dump report showing daemon behavior

Fig: Thread dump report showing virtual & platform threads

Fig: Thread dump report showing usage of ForkJoinPool

Fig: Thread dump report showing underlying thread type
What these screenshots conclude
From the first screenshot, we infer that at a certain point in time 48 threads were active concurrently and that 25 of them were daemon – but we did not explicitly create any daemon threads! At the same time, the second one tells us that the exact same number of Virtual Threads existed in the program. Thus, this indirectly hints & in fact proves that Virtual Threads are daemon by default.
From the third screenshot, we can see that the Virtual Threads use the ForkJoinPool under the hood, just like we discussed earlier. The stack trace from fourth again hints at the internal jdk class : java.lang.VirtualThread instead of platform thread
If you enjoyed this case study on Java Virtual Threads, read our in-depth comparison of virtual threads vs platform threads in Java 23.
Conclusion
Project Loom aka Virtual threads changes how Java developers should think about concurrency. Virtual threads do not make Java “faster”, but they make it dramatically more scalable for I/O-bound workloads. By decoupling application tasks from OS threads and letting the JVM handle scheduling, Java achieves non-blocking and great levels of throughput.
With all that being said, virtual threads are not a silver bullet. They shine when threads spend time waiting, not when they burn CPU. Being mindful of synchronization related issues and knowing about pinning especially on Java 21 is essential for using them effectively.If used correctly, virtual threads allow Java to scale to millions of concurrent tasks with simple, readable code : something that once required complex frameworks and programming paradigms. By understanding how Java Project Loom enables Java Virtual Threads, developers can design highly scalable systems without resorting to complex asynchronous programming models.

Share your Thoughts!