Virtual threads are intended for transient and I/O-bound loads while platform threads are used for long-running CPU-bound tasks.
Virtual threads have less startup/shutdown overhead and are more efficient when I/O bound, but they aren't as good at scheduling CPU-bound tasks.
Before JDK 19, all that was available was platform threads. They were simply a Java wrapper to an OS-level thread. You had a chunk of code that could run concurrently, but you had to hand it off to the system. This involved slow syscalls and allocations, alongside reliance on the OS's scheduler. It wasn't a huge deal for big tasks that stay running, but it wasn't great for transient tasks or blocking I/O operations, as the system still had to perform a context switch only to find the thread blocked.
Additionally, some operating systems limit the amount of threads a program can have. This causes issues for large server-type software with a lot of concurrency.
This is why virtual threads were introduced. They are "threads" that are really run on top of a pool of OS-level threads, kept alive by the VM. This cuts down on context switches and expensive allocations, as well as limiting the thread count.
For example, if you wanted to thread a basic image filter, virtual threads would work well (if the filter was very lightweight). With platform threads, the VM would have to make a thread (with all of the syscalls), add some handles, run it for under 100 milliseconds, then destroy it (with more costly syscalls). Virtual threads allow the VM to quickly pick one out of a pool and get it running.
This is also beneficial for blocking I/O. If a thread is running and is waiting on a lock or a buffer, the VM can just skip it without wasting time on context switches, and even reassign the underlying OS thread to another task.
The main problem with virtual threads is that they're based on a pool of platform threads. This isn't normally an issue if used well, but if you have too many tasks, it becomes a problem.
If you have a pool of 12 platform threads supporting 12 virtual threads, the JVM doesn't really need to get involved. However, if you add 1 more virtual thread, the JVM has to do its own scheduling.
If you use virtual threads properly, the JVM would remove a thread blocked on I/O or a lock to run another. Additionally, short CPU-heavy tasks wouldn't cause too much of an issue as the OS thread wouldn't be locked up for too long, and the overhead dies with the thread.
However, if they're all long-running CPU-bound tasks, you run into problems. The JVM's scheduler is going to be less efficient (especially since the OS is also scheduling the underlying OS threads with everything else). In the end, you get roughly double the overhead from 2 schedulers, and need to deal with a lot of context switching.
This is why platform threads are still used. If you want to thread a long file transfer or mass hashing operation, you shouldn't take up virtual thread time if you're not going to benefit from it.
Additionally, platform threads have more memory and stack space for complex operations.
To effectively use virtual threads:
Likewise, for platform threads:
Virtual threads are very useful, but not universally so. Use virtual threads for small or "sparse" (mostly I/O blocked) tasks, and use platform threads for heavy/long tasks, where the overhead is less important and the scheduling more of an issue.