Question & answer
In Java applications in 2026, one of the most important concurrency upgrades is virtual threads in Java 21. They let me keep the simple thread-per-task programming model, but at a scale that was not practical with classic platform threads.
I use virtual threads mainly for I/O-bound business workloads such as web requests, database calls, HTTP integrations, messaging consumers, and service orchestration. They are not a magic speed booster for CPU-heavy algorithms, but they are a major simplification for concurrent enterprise applications.
1. What virtual threads are
Java now has two main thread types:
Platform threads
Traditional threads backed more directly by OS threads.
Virtual threads
Lightweight threads scheduled by the JDK, allowing very high concurrency with simpler code.
The main benefit is that many virtual threads can be multiplexed onto a smaller number of platform threads. This makes it possible to run a very large number of blocking tasks without the high memory and scheduling cost of traditional threads.
Before virtual threads, teams often had to choose between readable blocking code and complex asynchronous code. Virtual threads reduce that tradeoff dramatically.
2. When to use virtual threads
Very good fit
- Calling databases
- Calling external REST APIs
- Reading from queues
- File and network I/O
- Serving many concurrent client requests
Less useful
- Pure CPU-bound number crunching
- Heavy in-memory batch processing
- Cases where cores are the main limit
- Code that holds locks while doing slow blocking work
3. Basic example
This is the simplest way to start a virtual thread.
public class VirtualThreadBasicExample {
public static void main(String[] args) throws InterruptedException {
Thread vt = Thread.ofVirtual().start(() -> {
System.out.println("Running in: " + Thread.currentThread());
});
vt.join();
}
}
Here, Thread.ofVirtual() creates a virtual thread builder and start(...) starts it immediately.
4. The most practical pattern: one virtual thread per task
In real applications, the most practical style is usually Executors.newVirtualThreadPerTaskExecutor().
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public class VirtualThreadExecutorExample {
public static void main(String[] args) throws Exception {
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<String> f1 = executor.submit(() -> fetchCustomer());
Future<String> f2 = executor.submit(() -> fetchOrders());
String customer = f1.get();
String orders = f2.get();
System.out.println(customer);
System.out.println(orders);
}
}
static String fetchCustomer() throws InterruptedException {
Thread.sleep(500); // simulate blocking I/O
return "Customer loaded";
}
static String fetchOrders() throws InterruptedException {
Thread.sleep(700); // simulate blocking I/O
return "Orders loaded";
}
}
This is powerful because it keeps the code straightforward while allowing concurrent blocking operations.
5. Real business example: service aggregation
Imagine an order API that needs data from customer, pricing, and inventory services. Virtual threads make this easy to express in a clean way.
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public class OrderAggregationService {
public OrderSummary loadOrderSummary(String orderId) throws Exception {
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<Customer> customerFuture =
executor.submit(() -> customerService(orderId));
Future<Pricing> pricingFuture =
executor.submit(() -> pricingService(orderId));
Future<Inventory> inventoryFuture =
executor.submit(() -> inventoryService(orderId));
Customer customer = customerFuture.get();
Pricing pricing = pricingFuture.get();
Inventory inventory = inventoryFuture.get();
return new OrderSummary(orderId, customer, pricing, inventory);
}
}
private Customer customerService(String orderId) throws InterruptedException {
Thread.sleep(200);
return new Customer("C-100", "Janaka");
}
private Pricing pricingService(String orderId) throws InterruptedException {
Thread.sleep(300);
return new Pricing(149.99);
}
private Inventory inventoryService(String orderId) throws InterruptedException {
Thread.sleep(250);
return new Inventory(true);
}
record Customer(String id, String name) {}
record Pricing(double amount) {}
record Inventory(boolean available) {}
record OrderSummary(String orderId, Customer customer, Pricing pricing, Inventory inventory) {}
}
This is a strong example because it shows concurrency, readability, and a realistic enterprise use case.
6. What virtual threads do not solve
| Misunderstanding | Reality |
|---|---|
| They make CPU-heavy code faster | No. CPU work is still limited by available cores. |
| They remove the need for limits | No. You still need timeouts, connection-pool awareness, and downstream protection. |
| They replace all concurrency design | No. You still need good architecture and resource management. |
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 1_000_000; i++) {
executor.submit(() -> expensivePrimeCalculation());
}
}
This is not a good idea for a CPU-bound workload. Virtual threads are about scalable blocking concurrency, not unlimited compute performance.
7. Pinning: the advanced topic
One important advanced concept is pinning. A virtual thread usually mounts onto a carrier platform thread when it runs and unmounts when it blocks in a scheduler-friendly way. But some situations can stop that smooth release, keeping the carrier thread busy longer than expected.
Be careful with
- Long-running synchronized sections
- Blocking calls inside synchronized code
- Some native or foreign calls
- Holding locks while waiting on slow I/O
Bad example
public synchronized String loadData() throws Exception {
Thread.sleep(3000); // slow blocking work while holding monitor
return "done";
}
Better example
public String loadData() throws Exception {
String result = remoteCall();
synchronized (this) {
updateCache(result);
}
return result;
}
private String remoteCall() throws InterruptedException {
Thread.sleep(3000);
return "done";
}
private void updateCache(String result) {
// fast shared-state update
}
8. Virtual threads vs reactive programming
Virtual threads
- Readable imperative code
- Strong fit for blocking I/O
- Simpler debugging for many teams
- Easy adoption in service layers
Reactive
- Useful for streaming and back-pressure
- Good for fully non-blocking stacks
- Can be more complex mentally
- Useful when already adopted deeply
A balanced answer is this: if the application mainly needs high concurrency for blocking I/O, virtual threads are often the simpler and better default. If the system already uses a mature reactive stack for streaming and fine-grained back-pressure, reactive can still be the better fit.
9. Best practices
- Use virtual threads mainly for blocking I/O concurrency.
- Prefer one virtual thread per task.
- Keep the business code imperative and clear.
- Avoid long blocking operations inside synchronized blocks.
- Still enforce downstream limits and timeouts.
- Measure response time, throughput, pool usage, and failure behavior.
- Test older libraries carefully under high concurrency.
import java.util.concurrent.*;
public class VirtualThreadTimeoutExample {
public static void main(String[] args) {
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<String> future = executor.submit(() -> {
Thread.sleep(1500);
return "Remote result";
});
String result = future.get(1, TimeUnit.SECONDS);
System.out.println(result);
} catch (TimeoutException e) {
System.out.println("Timed out waiting for result");
} catch (Exception e) {
e.printStackTrace();
}
}
}
10. Common mistakes
- Using virtual threads as if they are a CPU-scaling tool
- Forgetting database pool and downstream service bottlenecks
- Ignoring timeouts and cancellation
- Holding locks during slow I/O
- Adding unnecessary async complexity on top of simple virtual-thread code
Final polished answer
In Java applications in 2026, I use virtual threads from Java 21 as the modern default for many I/O-bound concurrency cases. They let me keep a simple thread-per-task model, so I can write readable blocking code for things like HTTP calls, database access, and service orchestration, while still scaling to a very large number of concurrent tasks.
In practice, I usually use Executors.newVirtualThreadPerTaskExecutor() and submit independent tasks such as remote service calls or database operations. That gives me concurrency without pushing the code into callback-heavy or overly reactive designs. It improves readability, debugging, and maintainability.
But I still apply judgment: virtual threads are best for blocking I/O workloads, not for raw CPU-bound parallelism. For CPU-heavy tasks, I still use bounded concurrency based on available cores.
I also pay attention to production concerns such as timeouts, cancellation, downstream limits, and pinning. So my approach is not just “use virtual threads everywhere,” but “use virtual threads where they simplify concurrency and improve throughput safely.”
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<String> customer = executor.submit(() -> customerService.loadCustomer(id));
Future<String> orders = executor.submit(() -> orderService.loadOrders(id));
return customer.get() + " | " + orders.get();
}