Favor Virtual Threads for Blocking Tasks
class TelemetryCollector {
List<Reading> collect(List<Station> stations)
throws InterruptedException, ExecutionException {
ExecutorService executor = Executors.newFixedThreadPool(100);
try {
List<Future<Reading>> futures = new ArrayList<>();
for (Station station : stations) {
futures.add(executor.submit(station::fetchReading));
}
List<Reading> readings = new ArrayList<>();
for (Future<Reading> future : futures) {
readings.add(future.get());
}
return readings;
} finally {
executor.shutdown();
}
}
}
The collector asks every station for its latest reading. Each call blocks while it waits for the network, so the calls run in parallel on a thread pool.
But platform threads are expensive: each one is backed by an operating system thread with its own large stack. So the pool is capped at 100 threads. With 10,000 stations, 9,900 tasks wait in the queue while the 100 threads mostly sit idle, waiting for answers. Choosing the pool size is guesswork: too small, and requests queue up; too large, and memory runs out. And the executor has to be shut down manually in a finally block.
class TelemetryCollector {
List<Reading> collect(List<Station> stations)
throws InterruptedException, ExecutionException {
try (var executor =
Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<Reading>> futures = new ArrayList<>();
for (Station station : stations) {
futures.add(executor.submit(station::fetchReading));
}
List<Reading> readings = new ArrayList<>();
for (Future<Reading> future : futures) {
readings.add(future.get());
}
return readings;
}
}
}
Since Java 21 (JEP 444), Executors.newVirtualThreadPerTaskExecutor() starts a new virtual thread for every task. Virtual threads are cheap, because the JVM manages them instead of the operating system. When a virtual thread blocks on I/O, the JVM sets it aside and uses the underlying carrier thread for other work. So all 10,000 requests can wait at the same time, and there's no pool size left to tune.
The rest of the code stays the same, since virtual threads are ordinary Thread objects that work with Future as before. And since Java 19, an ExecutorService is AutoCloseable: closing it waits for all submitted tasks, so try-with-resources replaces the manual shutdown().
Virtual threads speed up tasks that mostly wait, not CPU-heavy computations. Don't pool them, just create a new one per task. If a resource is scarce, such as database connections, limit concurrent access with a Semaphore instead of a small thread pool.