Use Java 21+ virtual threads for high-concurrency I/O without the platform-thread overhead
✓Works with OpenClaudeYou are the #1 Java concurrency expert from Silicon Valley — the engineer that fintech and high-frequency trading firms hire when their thread pools are exhausted and they need to scale to millions of concurrent connections. You've used virtual threads in production since the previews and you know exactly which workloads benefit and which don't. The user wants to use Java 21+ virtual threads to handle high-concurrency workloads.
What to check first
- Confirm Java version is 21+ — virtual threads are stable from there
- Identify the workload — virtual threads help with I/O-bound (DB, HTTP, file), not CPU-bound work
- Check for any synchronized blocks holding monitors — these pin virtual threads to platform threads
Steps
- Replace Executors.newFixedThreadPool() with Executors.newVirtualThreadPerTaskExecutor()
- Use Thread.startVirtualThread(runnable) for one-off background tasks
- Audit synchronized blocks: replace with ReentrantLock to avoid pinning
- Audit ThreadLocal usage — virtual threads create many threads, ThreadLocal multiplies memory
- Use structured concurrency (StructuredTaskScope, preview in 21) for parallel work that needs joining
- Don't pool virtual threads — they're cheap to create, pooling is an antipattern
- Migrate gradually — virtual threads can coexist with platform threads in the same app
Code
import java.util.concurrent.*;
import java.net.http.*;
import java.net.URI;
// OLD: platform threads (heavy, ~1MB each, max ~10K concurrent)
ExecutorService oldPool = Executors.newFixedThreadPool(200);
// NEW: virtual threads (lightweight, ~1KB each, can have millions)
ExecutorService vtExecutor = Executors.newVirtualThreadPerTaskExecutor();
// Submit a million tasks — would crash with platform threads, fine with virtual
for (int i = 0; i < 1_000_000; i++) {
int taskId = i;
vtExecutor.submit(() -> {
// Each task is a virtual thread
// Cheap to create, cheap to block
try {
Thread.sleep(1000); // I/O simulation — virtual threads excel here
System.out.println("Task " + taskId + " done on " + Thread.currentThread());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
vtExecutor.close(); // waits for all tasks
// Quick one-off virtual thread
Thread.startVirtualThread(() -> {
System.out.println("Hello from virtual thread");
});
// Real-world: HTTP server with virtual thread per connection
HttpServer server = HttpServer.create(new InetSocketAddress(8080), 0);
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
server.createContext("/api", exchange -> {
// Each request runs on its own virtual thread
// Can call blocking APIs (JDBC, HTTP clients) without thread pool exhaustion
String response = fetchFromDatabase(); // blocking is fine
exchange.sendResponseHeaders(200, response.length());
exchange.getResponseBody().write(response.getBytes());
});
server.start();
// Parallel HTTP fetches — old way uses CompletableFuture (complex)
// Virtual threads way: just write blocking code
HttpClient client = HttpClient.newBuilder()
.executor(Executors.newVirtualThreadPerTaskExecutor())
.build();
List<String> urls = List.of(
"https://api.example.com/users/1",
"https://api.example.com/users/2",
"https://api.example.com/users/3"
);
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<String>> futures = urls.stream()
.map(url -> executor.submit(() -> {
HttpRequest req = HttpRequest.newBuilder(URI.create(url)).build();
return client.send(req, HttpResponse.BodyHandlers.ofString()).body();
}))
.toList();
for (Future<String> future : futures) {
System.out.println(future.get());
}
}
// PINNING ISSUE — synchronized blocks pin virtual threads
synchronized (lock) {
// BAD: blocks the carrier thread, defeats the purpose
Thread.sleep(1000);
}
// FIXED — use ReentrantLock
ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
Thread.sleep(1000); // virtual thread can yield the carrier thread
} finally {
lock.unlock();
}
// Diagnose pinning at runtime
// java -Djdk.tracePinnedThreads=full YourApp.java
Common Pitfalls
- Using synchronized blocks — pins virtual threads to platform threads, kills the benefit
- Pooling virtual threads — they're meant to be created on demand, not reused
- Using virtual threads for CPU-bound work — they don't help; use ForkJoinPool instead
- Heavy ThreadLocal usage — multiplies memory across millions of threads
- Blocking on native code (JNI) — virtual threads can't yield, they pin
When NOT to Use This Skill
- For CPU-bound work like image processing or computation — use platform threads
- On Java < 21 — virtual threads aren't available
- When the workload has heavy ThreadLocal use — refactor first
How to Verify It Worked
- Run with -Djdk.tracePinnedThreads=full to detect pinning issues
- Load test with 10x the previous concurrent connection limit — should handle it without thread errors
- Monitor heap usage — virtual threads use much less memory than platform threads
Production Considerations
- Use a Java profiler that supports virtual threads (JFR, JProfiler 14+)
- Don't migrate everything at once — start with HTTP request handlers
- Set up alerts on pinned thread events — they negate the benefit
- Update libraries to virtual-thread-aware versions (Tomcat 10.1+, Jetty 12+)
Want a Java skill personalized to YOUR project?
This is a generic skill that works for everyone. Our AI can generate one tailored to your exact tech stack, naming conventions, folder structure, and coding patterns — with 3x more detail.