tl;dr
The reason to choose a platform thread over a virtual thread is not how long-lived the task but rather (a) if the task does little to no blocking or (b) if the task has long-running code that is synchronized
or native. Otherwise, virtual threads are recommended.
Details
I suspect you are overthinking things. The situation is not really that complicated.
In Java 21 and later…
Define any task you want to run as a Runnable
or a Callable
.
Pass that task to an Executor
instance via its execute
method if you don’t care whether it runs concurrently or not.
If you definitely want that task to run concurrently, pass to an ExecutorService
via the submit
or invoke…
methods.
If that task involves blocking (is not CPU-bound), use an executor service that assigns one fresh new virtual thread to each task. “Fresh” means a new, clean, minimal stack, and no pre-existing ThreadLocal
objects. “Blocking” means waiting on something so that the thread cannot do any further work; basically any I/O such as logging, file reading/writing, calls to a database, and network traffic such as sockets & Web Service calls. Virtual threads are extremely “cheap”, meaning fast to create, efficient with memory, and efficient with CPU.
In other words, virtual threads are like facial tissues: Whenever needed, grab a fresh new one, use it, and dispose.
If your task involves long-running code marked synchronized
, either replace the use of synchronized
with a ReentrantLock
or else run it with a platform thread as described next.
If your task (a) does not involve blocking (is CPU-bound such as video encoding), or (b) calls native code (JNI or Foreign Function), then do not use a virtual thread. Submit such tasks to an executor service backed by a platform thread(s). If concerned about overburdening your computer, use an executor service backed by a pool of a limited number of threads.
Platform threads are “expensive”. So virtual threads are preferred, given your task meets the conditions described above. A task in a virtual thread may run briefly or for a long-time, even the entire duration of your app.
When using pooled threads, be careful to clear out your ThreadLocal
objects to avoid inadvertent use by successive tasks in that thread.
Always shutdown an executor service before ending your app. Otherwise the backing threads may run indefinitely, like zombies 🧟♂️. Either use try-with-resources syntax to auto-close, or use boilerplate code given in the ExecutorService
Javadoc.
FYI, virtual threads actually run your task on a platform thread in a pool automatically managed by the JVM.
To learn more, read the JEP linked above. And see talks by Ron Pressler, Alan Bateman, and José Paumard.