Java / JVM / Virtual Threads
Virtual Threads Changed My Mental Model: Practical Lessons From I/O Fan-Out
Moving an I/O-bound fan-out to virtual threads: what became simpler, what didn’t, and how my thread-pool instincts had to evolve.
Virtual threads changed how I design I/O fan-out in Java services. As an engineering manager who grew up with fixed thread pools, queue sizing, and saturation metrics, I had muscle memory: “pick a pool size, tune it, protect it.” With virtual threads, I can treat blocking I/O more like straightforward code again—without tying throughput to a small pool.
The failure mode
A caller triggers N downstream calls per request (fan-out). With a fixed pool, saturation appears quickly under spikes; with virtual threads, spikes are absorbed but not eliminated. If you translate “we can spawn thousands of threads” into “we don’t need back pressure,” you’ll rediscover failures—just in different places.
What I learned
Think “task-per-thread” for blocking I/O, not “pool-per-service”
Virtual threads make “one virtual thread per blocking task” feasible. You can write linear code for a downstream call and not contort it into callbacks just to avoid blocking. That simplifies failure handling and resource cleanup.
Example fan-out
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Callable<EvidenceResult>> tasks = sources.stream()
.map(source -> (Callable<EvidenceResult>) source::fetchEvidence)
.toList();
List<Future<EvidenceResult>> futures = executor.invokeAll(tasks);
List<EvidenceResult> results = new ArrayList<>(futures.size());
for (Future<EvidenceResult> future : futures) {
results.add(future.get());
}
return aggregate(results);
}
This reads like blocking I/O because it is—but with virtual threads, the scheduler multiplexes them efficiently.
Back pressure still matters
If downstream services or data sources choke under spikes, virtual threads won’t magically fix it. The caller can spin up many virtual threads, but the destination still has limits. You need timeouts, rate limits, and circuit breakers. You also need to avoid accidental fan-in (e.g., mass aggregation that blocks on slowest responders indefinitely).
CPU-bound work belongs elsewhere
Virtual threads help with blocking I/O, not CPU-heavy tasks. If a task saturates CPU, the scheduler can’t help—you need to bound parallelism with a platform-appropriate mechanism (e.g., a limited executor or structured concurrency approach) to avoid oversubscription.
Failure semantics are simpler when code is linear
Exception handling in “straight-line” code is clearer than in callback-based designs. You still need to decide whether partial evidence returns a partial result (with Missing/Unverifiable states) or you fail the whole request. Virtual threads don’t make that decision; they just make the code easier to read.
“Thousands of threads” doesn’t mean “no limits”
Virtual threads are cheap, but not free. Sockets, file descriptors, and memory buffers are finite. The OS still enforces resource ceilings. Guard your fan-out with sensible limits: per-request caps, per-destination timeouts, and global safety checks.
Observability shifts upstream
With fixed pools, saturation appears as queue wait time. With virtual threads, saturation shifts to downstream latency and timeouts. Make sure your metrics capture per-destination latency percentiles and error rates. A spike in virtual-thread creation can be fine if destinations are healthy; it’s not fine if percentiles explode.
Test under synthetic spike and tail latency
I haven’t yet measured performance between virtual threads and a fixed pool for the demo. What I do measure is behavior under artificial spikes: do timeouts fire? Does the aggregation return timely partial results with Missing/Unverifiable? Tests simulate slow destinations and verify we don’t hang.
Timeout per downstream call
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<EvidenceResult> future = executor.submit(source::fetch);
try {
return future.get(timeout.toMillis(), TimeUnit.MILLISECONDS);
} catch (TimeoutException timeoutError) {
future.cancel(true);
return EvidenceResult.unavailable("timeout");
}
}
Trade-offs and failure modes
Trade-offs
Simplicity vs explicit limits: Virtual threads reduce boilerplate, but you still must implement back pressure. Silent defaults can mask saturation until it’s too late.
“Everything virtual” vs CPU realities: Virtual threads help with I/O. For CPU-bound work, a bounded executor or workload partitioning is safer.
Observability differences: You lose fixed-pool queue metrics and gain per-destination latency metrics. Update dashboards and alerts accordingly.
Failure modes
“Soft hangs”: Aggregation waits on the slowest responder forever. Add timeouts and partial-return logic.
“Resource leaks”: Thousands of virtual threads keep sockets open longer than intended. Validate resource cleanup paths.
“Oversubscription”: CPU-heavy tasks in virtual threads degrade the whole process. Bound CPU parallelism explicitly.
Try it yourself
Replace a fixed thread pool fan-out with newVirtualThreadPerTaskExecutor() and add per-destination timeouts. Observe behavior under synthetic spikes.
Add aggregation logic that treats timeouts as Missing and downstream errors as Unverifiable. Verify exhaustive handling with pattern matching.
Measure throughput and p95 latency in a controlled environment to inform whether virtual threads help your workload.