๐ Introduction
Java has long been a reliable language for concurrent programming. But for I/O-heavy, high-concurrency systems, traditional OS thread-based models introduced scaling limits.
Virtual Threads (from Java 21โs Project Loom) revolutionize this by allowing 10,000+ lightweight concurrent threads โ without the headaches of thread pool sizing or async callbacks.
In this post, Iโll explain what Virtual Threads are, how they work internally with continuations, and where you should โ and shouldnโt โ use them. Iโll also show you how to write and benchmark them with real code.
๐ What Are Virtual Threads?
A virtual thread is a lightweight user-space thread managed by the JVM. When a virtual thread performs a blocking operation (like a file read or HTTP call), the JVM parks the thread and frees up the underlying OS thread to handle other work.
When the blocking operation completes, the virtual thread is unparked and resumed by any available OS thread.
๐ How To Spawn Virtual Threads
Creating virtual threads is as easy as using Thread.ofVirtual():
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); executor.submit(() -> System.out.println("Task in virtual thread")); executor.shutdown();
Or use an ExecutorService:
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); executor.submit(() -> System.out.println("Task in virtual thread")); executor.shutdown();
๐ Carrier Threads: The Backbone Behind Virtual Threads
While virtual threads themselves are lightweight and managed by the JVM, they still need to run on actual OS threads when active. These are called carrier threads.
When a virtual thread performs work:
- Itโs picked up by a carrier thread (native OS thread) from a small internal ForkJoinPool.
- If the virtual thread blocks on I/O:
- The JVM parks the virtual thread (saves its continuation).
- The carrier thread is freed up for other work.
- When the I/O operation completes:
- The virtual thread is unparked.
- Scheduled on any available carrier thread to resume execution. Key point: A virtual thread is not bound to a specific carrier thread. It can run on any available carrier thread when ready โ enabling extreme concurrency with minimal OS thread usage.
๐ Where Do Carrier Threads Come From?
By default:
The JVM uses a ForkJoinPool internally to manage these carrier threads.
Its size is typically equal to Runtime.getRuntime().availableProcessors()
but can be configured via:
-Djdk.virtualThreadScheduler.parallelism=16
๐ Benchmark: OS Threads vs Virtual Threads
Letโs see how they perform for an I/O-heavy task like making multiple HTTP requests.
Benchmark Code:
import java.net.URI; import java.net.http.; import java.util.concurrent.; import java.util.stream.IntStream; public class ThreadBenchmark { static final int TASK_COUNT = 1000; public static void main(String[] args) throws Exception { System.out.println("Benchmarking with OS Threads..."); benchmarkWithExecutor(Executors.newFixedThreadPool(100)); System.out.println("\nBenchmarking with Virtual Threads..."); benchmarkWithExecutor(Executors.newVirtualThreadPerTaskExecutor()); } private static void benchmarkWithExecutor(ExecutorService executor) throws Exception { var client = HttpClient.newHttpClient(); var start = System.currentTimeMillis(); var futures = IntStream.range(0, TASK_COUNT) .mapToObj(i -> executor.submit(() -> { try { var request = HttpRequest.newBuilder(URI.create("https://httpbin.org/get")).build(); var response = client.send(request, HttpResponse.BodyHandlers.ofString()); System.out.println(Thread.currentThread() + " got response " + i); } catch (Exception e) { e.printStackTrace(); } })) .toList(); for (var future : futures) { future.get(); } var end = System.currentTimeMillis(); System.out.println("Time taken: " + (end - start) + " ms"); executor.shutdown(); } }
โก Try changing TASK_COUNT
to 10,000 โ youโll notice virtual threads handling it gracefully, while OS thread pools may choke or exhaust memory.
๐ How Virtual Threads Work (Continuations Behind the Scenes)
When a virtual thread blocks:
The JVM captures its continuation โ the current call stack, local variables, and program counter.
The virtual thread is parked (suspended)
The underlying OS thread is released for other tasks
Once the blocking operation completes, the virtual thread is unparked and resumed โ potentially on a different OS thread.
This is made possible by jdk.internal.vm.Continuation, which acts like a bookmark in the threadโs execution flow.
๐ Similarity to Other Languages
Language | Mechanism |
---|---|
Go | Goroutines |
Kotlin | Coroutines |
Python | AsyncIO + async/await |
JavaScript | Async/await + microtask queue |
Java 21+ finally joins this club with a clean, native solution.
๐ When to Use Virtual Threads โ
- HTTP servers
- Database access
- Microservices
- File and network I/O
- High concurrency workloads with blocking calls
๐ When Not to Use โ
- CPU-bound computations
- (Use a FixedThreadPool sized to available cores)
- Native blocking code via JNI โ still blocks OS thread
- Not a replacement for parallel processing
๐ Recap Table
Feature | OS Threads | Virtual Threads |
---|---|---|
Backed by | OS kernel thread | Lightweight, JVM-managed |
Blocking call | Ties up OS thread | Parks virtual thread |
CPU-bound parallelism | Excellent | Same as OS threads |
I/O concurrency | Limited | 10k+ scalable threads |
Scheduling | OS kernel | JVM continuation scheduling |
๐ Final Thought
Virtual Threads make asynchronous-like scalability achievable with simple, blocking code in Java. Project Loom modernizes concurrency without losing Javaโs type safety or predictability.
If you build server-side or networked Java apps โ this is the future.
๐ฌ Would love to hear your thoughts โ drop a comment or share your experience with Virtual Threads!
Top comments (0)