Java 21 Virtual Threads: Build High-Throughput Services with Structured Concurrency
Why Virtual Threads Matter
When I first migrated a legacy REST endpoint to a reactive stack, the callback hell and back‑pressure plumbing felt like over‑engineering for a service that only needed to handle a few thousand concurrent requests. Java 21’s virtual threads change that calculus: they give you the simplicity of blocking code with the scalability of an event loop, and they do it without a single library dependency.
A Real‑World Scenario
Imagine a typical order‑processing microservice. Each request validates input, calls an external payment gateway, updates a database, and finally publishes an event to Kafka. In the old thread‑per‑request model, a burst of 10 000 concurrent orders would exhaust the 200‑thread pool, causing latency spikes and thread‑starvation. With virtual threads the same code runs on lightweight user‑mode threads that park instead of blocking OS threads, so the JVM can multiplex millions of them on a handful of carrier threads.
Setting Up a Minimal HTTP Server
// SimpleServer.java
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.concurrent.Executors;
import java.util.concurrent.StructuredTaskScope;
public class SimpleServer {
private static final HttpClient CLIENT = HttpClient.newBuilder()
.executor(Executors.newVirtualThreadPerTaskExecutor()) // virtual‑thread executor
.connectTimeout(Duration.ofSeconds(5))
.build();
public static void main(String[] args) throws IOException, InterruptedException {
// Simulate 10 000 concurrent orders
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
for (int i = 0; i < 10_000; i++) {
final int orderId = i;
scope.fork(() -> processOrder(orderId));
}
scope.join(); // wait for all
scope.throwIfFailed(); // propagate first exception
}
}
private static void processOrder(int orderId) throws IOException, InterruptedException {
// 1️⃣ Validate (pure CPU, runs on virtual thread)
if (orderId % 7 == 0) throw new IllegalArgumentException("Invalid order " + orderId);
// 2️⃣ Call payment gateway (blocking I/O, but virtual thread parks)
var request = HttpRequest.newBuilder()
.uri(URI.create("https://pay.example.com/charge\" + orderId))
.POST(HttpRequest.BodyPublishers.noBody())
.timeout(Duration.ofSeconds(10))
.build();
HttpResponse resp = CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() != 200) throw new RuntimeException("Payment failed: " + resp.body());
// 3️⃣ Persist order (JDBC call – also parks)
// try (var conn = dataSource.getConnection()) { … }
// 4️⃣ Publish event (fire‑and‑forget)
// kafkaProducer.send(new ProducerRecord<>("orders", orderId, "PAID\