Backend Development 14 min read

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.

Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
Unlock Massive Concurrency: How Java Virtual Threads Transform Backend Development

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>
JavaBackend DevelopmentConcurrencyVirtual ThreadsJDK21Thread Scheduling
Spring Full-Stack Practical Cases
Written by

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.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.