Unlock Massive Concurrency: How Java Virtual Threads Transform Backend Development
This article explains Java virtual threads, contrasting them with platform threads, and shows how to create, schedule, and use them effectively with Thread.Builder and Executors to achieve high‑throughput, low‑latency server applications while avoiding common pitfalls like pooling and pinning.
Virtual Thread Overview
Virtual threads are lightweight threads that reduce the effort of writing, maintaining, and debugging high‑throughput concurrent applications. They are instances of java.lang.Thread and come in two varieties: platform threads and virtual threads.
What Are Platform Threads?
Platform threads are thin wrappers around operating‑system threads. They run Java code on an underlying OS thread for their entire lifetime, so the number of platform threads is limited by the OS. They typically have large stacks and OS‑managed resources, making them suitable for any task but scarce.
What Are Virtual Threads?
Virtual threads are also java.lang.Thread instances but are not bound to a specific OS thread. They run on OS threads, and when a virtual thread performs a blocking I/O operation, the runtime suspends it while the underlying OS thread can execute other virtual threads. Their implementation mirrors virtual memory: many virtual threads are mapped onto a few OS threads.
Virtual threads usually have shallow call stacks and are ideal for a single HTTP client call or a JDBC query. Although they support thread‑local variables, careful use is advised because a single JVM may host millions of virtual threads.
Virtual threads are best for tasks that spend most of their time blocked on I/O; they are not suited for long‑running CPU‑bound work.
Why Use Virtual Threads?
They excel in high‑throughput concurrent applications where many tasks spend time waiting, such as server‑side request handling. Virtual threads do not run faster than platform threads; their advantage is higher throughput and lower latency.
Creating Virtual Threads
Thread & Thread.Builder
The Thread and Thread.Builder APIs provide methods to create both platform and virtual threads, while java.util.concurrent.Executors defines ways to obtain an ExecutorService that starts a new virtual thread for each task.
Example using Thread.ofVirtual() :
<code>Thread t = Thread.ofVirtual().start(() -> System.out.println("Hello"));
t.join();</code>Using Thread.Builder to name a virtual thread:
<code>Thread.Builder builder = Thread.ofVirtual().name("T-VM");
Runnable task = () -> { System.out.println("执行任务"); };
Thread t = builder.start(task);
System.err.printf("线程名称: %s%n", t.getName());
t.join();</code>Creating and starting two named virtual threads:
<code>Thread.Builder builder = Thread.ofVirtual().name("vm-worker-", 0);
Runnable task = () -> {
System.out.printf("线程ID: %d%n", Thread.currentThread().threadId());
};
Thread t1 = builder.start(task);
t1.join();
System.out.println(t1.getName() + " terminated");
Thread t2 = builder.start(task);
t2.join();
System.out.println(t2.getName() + " terminated");</code>Executors
The Executors utility can create an ExecutorService that spawns a new virtual thread for each submitted task:
<code>ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
Future<?> future = executor.submit(() -> System.out.println("Running thread"));
future.get();
System.out.println("Task completed");</code>Virtual Thread Scheduling
OS scheduling applies to platform threads. The Java runtime schedules virtual threads by mounting them onto carrier (platform) threads. When a virtual thread blocks, its carrier becomes idle and can host another virtual thread.
Virtual threads are bound to a carrier during blocking operations and remain bound in the following situations:
Executing code inside a synchronized block or method.
Calling native methods or external functions.
Guidelines for Using Virtual Threads
Virtual threads enable a “thread‑per‑request” style where each incoming request gets its own thread, dramatically increasing throughput while keeping latency low. Because blocking a virtual thread is cheap, simple synchronous code with blocking I/O often yields the best performance.
Example of a non‑blocking asynchronous style that gains little from virtual threads:
<code>HttpClient client = ...;
Executor pool = Executors.newVirtualThreadPerTaskExecutor();
CompletableFuture.supplyAsync(() -> {
HttpRequest request = HttpRequest.newBuilder(URI.create("http://localhost:8088/users/info")).build();
BodyHandler<String> bodyHandler = ...;
try {
return client.send(request, bodyHandler);
} catch (Exception e) { return null; }
}, pool)
.thenCompose(url -> getBodyAsync(url, HttpResponse.BodyHandlers.ofString()))
.thenApply(info::findImage)
.thenAccept(this::process)
.exceptionally(t -> { t.printStackTrace(); return null; });</code>In contrast, a straightforward synchronous version benefits greatly from virtual threads:
<code>try {
String page = getBody(info.getUrl(), HttpResponse.BodyHandlers.ofString());
String imageUrl = info.findImage(page);
byte[] data = getBody(imageUrl, HttpResponse.BodyHandlers.ofByteArray());
info.setImageData(data);
process(info);
} catch (Exception e) { /* handle */ }</code>Do Not Pool Virtual Threads
Although platform threads are scarce and typically managed with thread pools, virtual threads are abundant and should represent individual tasks rather than pooled resources. Converting n platform threads to n virtual threads offers little benefit; instead, create a virtual thread per task.
Incorrect usage with a shared pool:
<code>Future<ResultA> f1 = sharedThreadPoolExecutor.submit(task1);
Future<ResultB> f2 = sharedThreadPoolExecutor.submit(task2);</code>Correct usage with a fresh virtual‑thread executor:
<code>try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<ResultA> f1 = executor.submit(task1);
Future<ResultB> f2 = executor.submit(task2);
// use futures
}</code>The ExecutorService returned by newVirtualThreadPerTaskExecutor() is lightweight; it can be created and closed like any simple object, and its close() method waits for all submitted virtual threads to finish.
This pattern is especially useful for fan‑out scenarios where multiple remote calls are performed concurrently:
<code>void handle() throws Exception {
URL url1 = URI.create("http://www.pack.com").toURL();
URL url2 = URI.create("http://www.akf.com").toURL();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var future1 = executor.submit(() -> fetchURL(url1));
var future2 = executor.submit(() -> fetchURL(url2));
System.out.printf("result1: %s, result2: %s%n", future1.get(), future2.get());
}
}
String fetchURL(URL url) throws IOException {
try (var in = url.openStream()) {
return new String(in.readAllBytes(), StandardCharsets.UTF_8);
}
}</code>Avoid Long‑Running Pinning
A current limitation is that blocking inside a synchronized block or method pins an OS thread, reducing throughput. Frequent or long‑lasting blocking should be avoided or replaced with ReentrantLock instead of synchronized :
<code>synchronized(lockObj) {
frequentIO();
}
// replace with
lock.lock();
try {
frequentIO();
} finally {
lock.unlock();
}</code>Spring Full-Stack Practical Cases
Full-stack Java development with Vue 2/3 front-end suite; hands-on examples and source code analysis for Spring, Spring Boot 2/3, and Spring Cloud.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.