Java Concurrency: Threads, Executors, CompletableFuture, and Virtual Threads
Modern Java applications often need to handle thousands of simultaneous operations without creating a thread for every task. Java provides several concurrency mechanisms, from traditional threads and executor pools to CompletableFuture and virtual threads.
This guide explains how these models work, when to use each one, and how to build high-throughput concurrent applications with practical code examples.
1. Choosing the Right Concurrency Model
Java provides multiple approaches to concurrent execution.
Platform Threads: Traditional OS-backed threads suitable for CPU-bound work and tasks requiring explicit thread control.
ExecutorService: Manages reusable worker threads and separates task submission from thread management.
CompletableFuture: Provides asynchronous pipelines, composition, parallel execution, and error handling.
Virtual Threads: Lightweight threads introduced in Java 21 that are designed for applications with large numbers of concurrent blocking I/O operations.
2. Traditional Java Threads
The simplest concurrency mechanism is creating a Thread directly. A thread can execute work independently while the main application continues running.
Running Tasks Concurrently
public class ThreadExample {
public static void main(String[] args) throws InterruptedException {
Thread worker = new Thread(() -> {
System.out.println(
"Running on: " + Thread.currentThread().getName()
);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("Worker finished");
});
worker.start();
worker.join();
System.out.println("Main thread finished");
}
}
3. ExecutorService: Reusing Worker Threads
Creating a new platform thread for every operation can become expensive. ExecutorService provides reusable worker threads and manages task execution through a thread pool.
Fixed Thread Pool
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class ExecutorExample {
public static void main(String[] args) throws InterruptedException {
ExecutorService executor = Executors.newFixedThreadPool(4);
for (int i = 1; i <= 8; i++) {
int taskId = i;
executor.submit(() -> {
System.out.println(
"Task " + taskId +
" running on " +
Thread.currentThread().getName()
);
try {
Thread.sleep(500);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
executor.shutdown();
if (!executor.awaitTermination(10, TimeUnit.SECONDS)) {
executor.shutdownNow();
}
System.out.println("All tasks completed.");
}
}
4. Callable and Future
Runnable does not return a result. Callable allows a task to return a value, while Future provides a handle for retrieving that result.
import java.util.concurrent.*;
public class FutureExample {
public static void main(String[] args) throws Exception {
ExecutorService executor =
Executors.newFixedThreadPool(2);
Future<Integer> future = executor.submit(() -> {
Thread.sleep(1000);
return 42;
});
System.out.println("Doing other work...");
Integer result = future.get();
System.out.println("Result: " + result);
executor.shutdown();
}
}
5. CompletableFuture: Asynchronous Pipelines
CompletableFuture allows asynchronous operations to be chained together without manually coordinating every thread.
Chaining Async Operations
import java.util.concurrent.CompletableFuture;
public class CompletableFutureExample {
public static void main(String[] args) {
CompletableFuture
.supplyAsync(() -> {
System.out.println("Fetching user...");
return "user-42";
})
.thenApply(userId -> {
System.out.println("Loading profile...");
return "Profile for " + userId;
})
.thenAccept(profile -> {
System.out.println("Result: " + profile);
})
.join();
}
}
6. Running Independent Operations in Parallel
Independent operations can be started at the same time using separate CompletableFuture instances and combined with CompletableFuture.allOf().
import java.util.concurrent.CompletableFuture;
public class ParallelFutureExample {
static String fetchUser() {
sleep(500);
return "Alice";
}
static String fetchOrders() {
sleep(800);
return "5 orders";
}
static String fetchNotifications() {
sleep(300);
return "3 notifications";
}
static void sleep(long milliseconds) {
try {
Thread.sleep(milliseconds);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
public static void main(String[] args) {
CompletableFuture<String> user =
CompletableFuture.supplyAsync(ParallelFutureExample::fetchUser);
CompletableFuture<String> orders =
CompletableFuture.supplyAsync(ParallelFutureExample::fetchOrders);
CompletableFuture<String> notifications =
CompletableFuture.supplyAsync(
ParallelFutureExample::fetchNotifications
);
CompletableFuture.allOf(
user,
orders,
notifications
).join();
System.out.println("User: " + user.join());
System.out.println("Orders: " + orders.join());
System.out.println("Notifications: " + notifications.join());
}
}
7. Virtual Threads in Java 21+
Virtual threads are lightweight Java threads designed to support very high concurrency, especially for applications that spend significant time waiting on blocking I/O.
Starting a Virtual Thread
public class VirtualThreadExample {
public static void main(String[] args) throws InterruptedException {
Thread worker = Thread.startVirtualThread(() -> {
System.out.println(
"Running on: " + Thread.currentThread()
);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("Request completed.");
});
worker.join();
}
}
8. High-Concurrency I/O with Virtual Threads
Virtual threads allow applications to represent many concurrent blocking tasks without requiring an equally large number of platform threads.
import java.util.concurrent.Executors;
public class VirtualThreadPoolExample {
static void handleRequest(int requestId) {
System.out.println(
"Request " + requestId +
" started on " +
Thread.currentThread()
);
try {
Thread.sleep(500);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println(
"Request " + requestId + " completed"
);
}
public static void main(String[] args) {
try (var executor =
Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 1; i <= 1000; i++) {
int requestId = i;
executor.submit(() ->
handleRequest(requestId)
);
}
}
System.out.println("All requests completed.");
}
}
9. CPU-Bound vs I/O-Bound Work
Concurrency strategy should be based on whether the application is primarily waiting for external resources or actively consuming CPU.
I/O-Bound: HTTP requests, database queries, file operations, message queues, and remote API calls. Virtual threads are well suited to applications with many blocking I/O operations.
CPU-Bound: Image processing, compression, mathematical calculations, large data transformations, and other computational workloads. A bounded executor can limit CPU parallelism.
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class CpuExecutorExample {
public static void main(String[] args) {
int cores = Runtime.getRuntime().availableProcessors();
ExecutorService executor =
Executors.newFixedThreadPool(cores);
for (int i = 0; i < 100; i++) {
int taskId = i;
executor.submit(() -> {
long result = 0;
for (long j = 0; j < 10_000_000L; j++) {
result += j;
}
System.out.println(
"Task " + taskId +
" completed with " + result
);
});
}
executor.shutdown();
}
}
10. Thread Safety and Shared State
Multiple concurrent tasks accessing shared mutable state can produce race conditions. Operations such as value++ are not atomic.
import java.util.concurrent.atomic.AtomicInteger;
public class AtomicCounter {
private final AtomicInteger counter =
new AtomicInteger();
public void increment() {
counter.incrementAndGet();
}
public int getValue() {
return counter.get();
}
}
11. Blocking Queues for Producer-Consumer Systems
A common concurrency pattern is a producer generating work while consumers process it. BlockingQueue provides built-in coordination and can provide backpressure when bounded.
import java.util.concurrent.*;
public class ProducerConsumerExample {
public static void main(String[] args) throws Exception {
BlockingQueue<String> queue =
new ArrayBlockingQueue<>(10);
ExecutorService executor =
Executors.newFixedThreadPool(2);
executor.submit(() -> {
for (int i = 1; i <= 20; i++) {
try {
queue.put("Task-" + i);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
}
});
executor.submit(() -> {
while (!Thread.currentThread().isInterrupted()) {
try {
String task = queue.take();
System.out.println(
"Processing " + task
);
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
}
});
Thread.sleep(3000);
executor.shutdownNow();
}
}
12. Hybrid Java Concurrency
Real-world applications can combine multiple concurrency mechanisms. Virtual threads can handle high-volume I/O while a bounded executor handles CPU-intensive operations.
HTTP Requests
│
▼
Virtual Threads
│
├──── Database I/O
│
├──── Remote API
│
└──── File I/O
│
▼
CPU-heavy processing
│
▼
Bounded CPU Executor
Conclusion
Java concurrency has evolved from manually managed threads toward higher-level execution models. Use ExecutorService when you need explicit control over worker pools, CompletableFuture for asynchronous pipelines, and virtual threads for applications with large amounts of blocking I/O.
For CPU-bound workloads, focus on controlling parallelism rather than simply increasing the number of threads.