1. Introduction
Virtual threads are a useful feature officially introduced in JDK 21 as a solution to improve the performance of high-throughput applications.
However, the JDK has no built-in task-scheduling tool that uses virtual threads. Thus, we must write our task scheduler, which runs using virtual threads.
In this article, we’ll create custom schedulers for virtual threads using the Thread.sleep() method and the ScheduledExecutorService class.
2. What Are Virtual Threads?
The Virtual thread was introduced in JEP-444 as a lightweight version of the Thread class that ultimately improves concurrency in high-throughput applications.
Virtual threads use much less space than usual operating system threads (or platform threads). Hence, we can spawn more virtual threads simultaneously in an application than platform threads. Undoubtedly, this increases the maximum number of concurrent units, which also increases the throughput of our applications.
One crucial point is that virtual threads are not faster than platform threads. In our applications, they only appear in larger quantities than platform threads so that they can execute more parallel work.
Virtual threads are cheap, so we don’t need to use techniques like resource pooling to schedule tasks to a limited number of threads. Instead, we can spawn them almost infinitely in modern computers without having memory issues.
Finally, virtual threads are dynamic, whereas platform threads are fixed in size. Thus, virtual threads are much more suitable than platform threads for small tasks such as simple HTTP or database calls.
3. Scheduling Virtual Threads
We’ve seen that one big advantage of virtual threads is that they are small and cheap. We can effectively spawn hundreds of thousands of virtual threads in a simple machine without falling into out-of-memory errors. Thus, pooling virtual threads as we do with more expensive resources like platform threads and network or database connections doesn’t make much sense.
By keeping thread pools, we create another overhead of pooling tasks for available threads in the pool, which is more complex and potentially slower. Additionally, most thread pools in Java are limited by the number of platform threads, which is always smaller than the possible number of virtual threads in the program.
Thus, we must avoid using virtual threads with thread-pooling APIs like ForkJoinPool or ThreadPoolExecutor. Instead, we should always create a new virtual thread for each task.
Currently, Java doesn’t provide a standard API to schedule virtual threads as we do with other concurrent APIs like the ScheduledExecutorService’s schedule() method. So, to effectively make our virtual threads run scheduled tasks, we need to write our own scheduler.
3.1. Scheduling Virtual Threads Using Thread.sleep()
The most straightforward approach we’ll see to create a customized scheduler uses the Thread.sleep() method to make the program wait on the current thread execution:
static Future<?> schedule(Runnable task, int delay, TemporalUnit unit, ExecutorService executorService) {
return executorService.submit(() -> {
try {
Thread.sleep(Duration.of(delay, unit));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
task.run();
});
}
The schedule() method receives a task to be scheduled, a delay, and an ExecutorService. Then, we fire up the task using ExecutorService‘s submit(). In the try block, we make the thread that will execute the task to wait for a desired delay by calling Thread.sleep(). Hence, the thread may be interrupted while waiting, so we handle the InterruptedException by interrupting the current thread execution.
Finally, after waiting, we call run() with the task received.
To schedule virtual threads with the custom schedule() method, we need to pass an executor service for virtual threads to it:
ExecutorService virtualThreadExecutor = Executors.newVirtualThreadPerTaskExecutor();
try (virtualThreadExecutor) {
var taskResult = schedule(() ->
System.out.println("Running on a scheduled virtual thread!"), 5, ChronoUnit.SECONDS,
virtualThreadExecutor);
try {
Thread.sleep(10 * 1000); // Sleep for 10 seconds to wait task results
} catch (InterruptedException e) {
Thread.currentThread()
.interrupt();
}
System.out.println(taskResult.get());
}
Firstly, we instantiate an ExecutorService that spawns a new virtual thread per task we submit. Then, we wrap the virtualThreadExecutor variable in a try-with-resources statement, keeping the executor service open until we finish using it. Alternatively, after using the executor service, we can finish it properly using shutdown().
We call schedule() to run the task after 5 seconds, then wait 10 seconds before trying to get the task execution result.
3.2. Scheduling Virtual Threads Using SingleThreadExecutor
We saw how to use sleep() to schedule tasks to virtual threads. Alternatively, we can instantiate a new single-thread scheduler in the virtual thread executor for each task submitted:
static Future<?> schedule(Runnable task, int delay, TimeUnit unit, ExecutorService executorService) {
return executorService.submit(() -> {
ScheduledExecutorService singleThreadScheduler = Executors.newSingleThreadScheduledExecutor();
try (singleThreadScheduler) {
singleThreadScheduler.schedule(task, delay, unit);
}
});
}
The code also uses a virtual thread ExecutorService passed as an argument to submit tasks. But now, for each task, we instantiate a single ScheduledExecutorService of a single thread using the newSingleThreadScheduledExecutor() method.
Then, inside a try-with-resources block, we schedule tasks using the single thread executor schedule() method. That method accepts a task and delay amount as arguments and doesn’t throw checked InterruptedException like sleep().
Finally, we can schedule tasks to a virtual thread executor using schedule():
ExecutorService virtualThreadExecutor = Executors.newVirtualThreadPerTaskExecutor();
try (virtualThreadExecutor) {
var taskResult = schedule(() ->
System.out.println("Running on a scheduled virtual thread!"), 5, TimeUnit.SECONDS,
virtualThreadExecutor);
try {
Thread.sleep(10 * 1000); // Sleep for 10 seconds to wait task results
} catch (InterruptedException e) {
Thread.currentThread()
.interrupt();
}
System.out.println(taskResult.get());
}
This is similar to the usage of the schedule() method of Section 3.1, but here, we pass a TimeUnit instead of ChronoUnit.
3.3. Scheduling Tasks Using sleep() vs. Scheduled Single Thread Executor
In the sleep() scheduling approach, we just called a method to wait before effectively running the task. Thus, it’s straightforward to understand what the code is doing, and it’s easier to debug it. On the other hand, using a scheduled executor service per task depends on the library’s scheduler code, so it might be harder to debug or troubleshoot.
*Additionally, if we choose to use sleep(), we’re limited to scheduling tasks to run after a fixed delay. In contrast, using ScheduledExecutorService, we have access to three scheduling methods: schedule(), scheduleAtFixedRate(), and scheduleWithFixedDelay().*
The ScheduledExecutorService’s schedule() method adds a delay, just like sleep() would. The scheduleAtFixedRate() and scheduleWithFixedDelay() methods add periodicity to the scheduling so we can repeat task execution in fixed-size periods. Therefore, we have more flexibility in scheduling tasks using the ScheduledExecutorService built-in Java library.
4. Conclusion
In this article, we’ve presented a few advantages of using virtual threads over traditional platform threads. Then, we looked at using Thread.sleep() and ScheduledExecutorService to schedule tasks to run in virtual threads.
As always, the source code is available over on GitHub.