We were discussing a concurrency issue with my team recently when the topic of virtual threads arose. That discussion made me realize my knowledge about them was limited and I mostly just knew the buzzword: "virtual threads are good for I/O operations.". But I didn’t actually know what that meant. I decided to fill this gap, and in this article, I would like to share my findings.
Environment
To run a few experiments, I created a small pet project using two Java versions:
- OpenJDK 64-Bit Server VM Temurin-25.0.2+10
- OpenJDK 64-Bit Server VM Temurin-21.0.6+7
I am not going to describe what virtual threads and carrier threads are in detail, cause there are already a lot of articles about that on the web. But what is important, I ran all examples with:
to make the results easier to understand. So even though I was running these examples on a multi-core processor, I always had just a single carrier thread active unless ForkJoinPool decided to compensate for a blocking operation.
Experiment
I decided to test 6 use cases:
- CPU-bound task
- Task that can be parked
- Blocking task (File I/O) - this is the only example which I also ran on JDK 21 and there was a good reason for that
- Multiple virtual threads with mixed workload - two operations which may be parked and one CPU-bound task
- Mixed workload within a single virtual thread - same as above: two operations which may be parked and one CPU-bound task between them
I know that the results for some of these seem obvious, but I still think it is worth running them to get the full picture.
Also, a few words of explanation regarding the code being run. To interfere as little as possible with tasks sent to the ExecutorService, I created a record:
which carries the most important info about each executed task. Such events are added to ConcurrentLinkedQueue and printed at the end of each run, sorted by timestamp like this:
All examples were run on JDK 25 with one exception, which I will point out.
Before I start, a short disclaimer: the goal of this article is not to dive deep into the Virtual Threads implementation or every possible use case, but rather to show the general rules that govern their behavior. If you want to get an in-depth explanation, I encourage you to take a look at the official JEP 444 documentation, where you can find explanations for all the cases described below.
That said, let's start exploring!
CPU-bound task
The first example is pure CPU-bound computation. Here is the code being executed:
The computing function essentially keeps the CPU busy for a specific amount of time defined in milliseconds. Here are results from that run:
This is expected. The CPU-bound task occupies the carrier thread until it finishes. Only after that can the CHECKPOINT event be added to the queue. I don’t think this run needs any further explanations.
Task that can be parked
The next example looks as follows:
The sleeping function just runs Thread.sleep for 500ms. This is enough to demonstrate the full potential of virtual threads. This is result:
This time CHECKPOINT runs right after sleeping has started. Under the hood, parkNanos function was triggered and ultimately reached yieldContinuation, which unmounts the virtual thread from the carrier thread. There are also two other methods which call yieldContinuation:
So callers of these methods can unleash the full potential of virtual threads.
Blocking task (File I/O)
Let’s jump to the third example, where I read a file from the disk. Here is the exact code:
In this case, readFile simply reads all bytes of a given file. Here is the result of running this code:
This was quite an unexpected result for me, since CHECKPOINT had to wait until the file was actually read. To make things even more interesting, here are the results after switching to JDK 21:
Here the behavior is similar to the previous scenario with unmounting/parking virtual thread. But notice that this time CHECKPOINT ran on a different carrier thread: ForkJoinPool-1-worker-2. ForkJoinPool spawned a compensating thread.
To understand the difference, let’s take a look at the source code:
In both cases, we see a call to Blocker.begin. However, after changes from JDK-8329593, in JDK 25, attempted is false, hence there is no attempt to spawn a compensating carrier thread.
While calling Blocker.begin triggers a compensation operation in both versions, in JDK 25 for this specific use case, it doesn't actually happen. Effectively, we end up with blocking code just like standard platform threads.
What I want to show here is that if you plan to use virtual threads, you should carefully check the underlying implementation to avoid unexpected behavior at runtime. Especially if you are introducing virtual threads into critical parts of your system.
Now let’s move on to my final examples, which are quite similar. In both cases, I run three operations in the same order: sleeping -> computing -> sleeping. The main difference is that in one case I submit them as separate tasks to the ExecutorService, and in the other, I combine them into a single task.
Multiple virtual threads with mixed workload
In this example, I executed the following code:
This is the result:
As we can see from this, when a task is running CPU computation, no other virtual threads can make progress. The continuation of the first sleep operation only started after the CPU task ended, and the second sleep started right after that.
What this example teaches us: if you submit CPU-intensive tasks alongside I/O tasks that allow virtual threads to unmount, you can starve your I/O tasks if all carrier threads are busy running CPU-intensive work at that moment. Keep that in mind.
The entire code took ~1500 milliseconds to run, which is not bad considering each sleep lasts 500ms and CPU computation lasts 1000ms. We were able to run one sleep (I/O) in the background concurrently.
Mixed workload within a single virtual thread
Last example looks like this:
Here we execute the exact same operations as before, but this time inside a single virtual thread. And these are the results:
This time all operations ran synchronously and the entire task took ~2000ms. In this case, a virtual thread behaves no differently than a platform thread.
Conclusion
Of course, all the examples I presented here are far simpler than real production systems. But in my opinion, they can help you understand how virtual threads behave in different scenarios, making it easier to analyze more complex behavior later on.
For me, the most important lesson from this is to carefully check the implementation of our I/O operations so we don’t get surprised by strange issues in production.
And personally, I would try to use virtual threads for tasks where they truly shine - operations where the virtual thread will actually be unmounted from the carrier thread. Also, in my opinion, it is always better to isolate such tasks from CPU-intensive ones, because mixing them can be problematic and can bite us when we least expect it.
Reviewed by Gabriel Kępka

