Skip to main content

Concurrency: Threads, Executors, and Virtual Threads

Java's concurrency model has evolved from raw threads (Java 1.0) through the ExecutorService framework (Java 5) to Virtual Threads (Java 21). This page covers the practical patterns you need for everyday concurrent programming.

Thread basics

// Option 1: Runnable (preferred -- does not return a value)
Runnable task = () -> System.out.println("Hello from " + Thread.currentThread().getName());
Thread thread = new Thread(task);
thread.start(); // starts a new OS thread
thread.join(); // wait for completion

// Option 2: Callable (returns a value, can throw)
Callable<Integer> computation = () -> {
Thread.sleep(1000);
return 42;
};

Never call thread.run() -- that executes on the current thread. Always use thread.start().


ExecutorService

Instead of managing threads manually, use an ExecutorService to manage a thread pool:

// Fixed thread pool -- good for CPU-bound work
ExecutorService executor = Executors.newFixedThreadPool(4);

// Submit tasks
Future<Integer> future = executor.submit(() -> {
Thread.sleep(1000);
return 42;
});

// Get result (blocks until done)
Integer result = future.get(); // 42
Integer result = future.get(5, TimeUnit.SECONDS); // with timeout

// Shut down when done
executor.shutdown();
executor.awaitTermination(10, TimeUnit.SECONDS);

Thread pool types

Factory methodBehaviourUse case
newFixedThreadPool(n)Fixed number of threadsCPU-bound work (n = number of cores)
newCachedThreadPool()Creates threads as needed, reuses idle threadsShort-lived, bursty I/O tasks
newSingleThreadExecutor()Single thread, tasks queuedSequential background processing
newScheduledThreadPool(n)Scheduled/periodic executionTimed tasks, polling
newVirtualThreadPerTaskExecutor()One virtual thread per task (Java 21+)High-concurrency I/O

CompletableFuture

CompletableFuture is Java's equivalent of JavaScript's Promise -- it supports chaining, combining, and error handling:

Basic chaining

CompletableFuture<String> future = CompletableFuture
.supplyAsync(() -> fetchUserName(userId)) // runs on ForkJoinPool
.thenApply(name -> name.toUpperCase()) // transform result
.thenApply(name -> "Hello, " + name); // chain another transform

String greeting = future.join(); // blocks and returns result
// "Hello, ALICE"

Error handling

CompletableFuture<String> future = CompletableFuture
.supplyAsync(() -> riskyOperation())
.thenApply(String::toUpperCase)
.exceptionally(ex -> {
System.err.println("Failed: " + ex.getMessage());
return "DEFAULT";
});

Combining futures

CompletableFuture<String> nameFuture = CompletableFuture.supplyAsync(() -> fetchName());
CompletableFuture<Integer> ageFuture = CompletableFuture.supplyAsync(() -> fetchAge());

// Combine two futures
CompletableFuture<String> combined = nameFuture.thenCombine(ageFuture,
(name, age) -> name + " is " + age + " years old");

// Wait for all
CompletableFuture<Void> all = CompletableFuture.allOf(nameFuture, ageFuture);
all.join();

// Wait for first
CompletableFuture<Object> any = CompletableFuture.anyOf(nameFuture, ageFuture);

Running on a specific executor

ExecutorService myPool = Executors.newFixedThreadPool(4);

CompletableFuture<String> future = CompletableFuture
.supplyAsync(() -> fetchData(), myPool) // run on myPool
.thenApplyAsync(data -> process(data), myPool); // also on myPool

Timeout (Java 9+)

CompletableFuture<String> future = CompletableFuture
.supplyAsync(() -> slowOperation())
.orTimeout(5, TimeUnit.SECONDS) // throws TimeoutException
.completeOnTimeout("fallback", 5, TimeUnit.SECONDS); // returns default

Virtual Threads (Java 21)

Virtual threads are lightweight threads managed by the JVM, not the OS. You can create millions of them without exhausting memory or OS thread limits.

Creating virtual threads

// Option 1: Thread.startVirtualThread
Thread vt = Thread.startVirtualThread(() -> {
System.out.println("Running on: " + Thread.currentThread());
});

// Option 2: Thread.ofVirtual()
Thread vt = Thread.ofVirtual()
.name("my-vthread")
.start(() -> doWork());

// Option 3: ExecutorService (recommended for production)
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<String>> futures = new ArrayList<>();

for (int i = 0; i < 10_000; i++) {
futures.add(executor.submit(() -> fetchData()));
}

for (Future<String> f : futures) {
System.out.println(f.get());
}
} // auto-shutdown

Virtual vs platform threads

AspectPlatform threadsVirtual threads
Managed byOS kernelJVM
Memory per thread~1 MB stack~few KB
Max countThousandsMillions
Best forCPU-bound workI/O-bound work (HTTP, DB, file)
Blocking costExpensive (blocks OS thread)Cheap (JVM parks the virtual thread)
Thread pool neededYesNo (one per task is fine)
Synchronized blocksFineCan "pin" carrier thread (use ReentrantLock instead)

When to use virtual threads

// GOOD: I/O-bound workload (HTTP calls, DB queries)
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<Response>> futures = urls.stream()
.map(url -> executor.submit(() -> httpClient.send(url)))
.toList();

for (var f : futures) {
process(f.get());
}
}

// BAD: CPU-bound workload (number crunching)
// Virtual threads add scheduling overhead without benefit
// Use a fixed thread pool sized to CPU cores instead

Avoiding pinning

Virtual threads are pinned (cannot be parked) inside synchronized blocks when they perform blocking operations. Use ReentrantLock instead:

// BAD: can pin the carrier thread
synchronized (lock) {
connection.query("SELECT ..."); // blocking I/O inside synchronized
}

// GOOD: ReentrantLock does not pin
private final ReentrantLock lock = new ReentrantLock();

lock.lock();
try {
connection.query("SELECT ...");
} finally {
lock.unlock();
}

Thread safety

Immutability (best approach)

// Records are immutable by default
record Config(String host, int port) {}

// Shared safely between threads with no synchronisation needed
Config config = new Config("localhost", 8080);

Atomic variables

AtomicInteger counter = new AtomicInteger(0);

// Thread-safe increment
counter.incrementAndGet();
counter.addAndGet(5);
counter.compareAndSet(5, 10);

// AtomicReference for objects
AtomicReference<String> ref = new AtomicReference<>("initial");
ref.updateAndGet(s -> s.toUpperCase());

Synchronized blocks

private final Object lock = new Object();
private int balance = 0;

void deposit(int amount) {
synchronized (lock) {
balance += amount;
}
}

ReentrantLock

More flexible than synchronized (try-lock, timed lock, interruptible):

private final ReentrantLock lock = new ReentrantLock();

void deposit(int amount) {
lock.lock();
try {
balance += amount;
} finally {
lock.unlock(); // always in finally!
}
}

// Try with timeout
if (lock.tryLock(1, TimeUnit.SECONDS)) {
try { /* critical section */ }
finally { lock.unlock(); }
} else {
// Could not acquire lock within timeout
}

Practical example: parallel HTTP fetcher

record UrlResult(String url, int status, String body) {}

List<UrlResult> fetchAll(List<String> urls) {
HttpClient client = HttpClient.newHttpClient();

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<UrlResult>> futures = urls.stream()
.map(url -> executor.submit(() -> {
HttpRequest req = HttpRequest.newBuilder(URI.create(url)).build();
HttpResponse<String> resp = client.send(req, HttpResponse.BodyHandlers.ofString());
return new UrlResult(url, resp.statusCode(), resp.body());
}))
.toList();

return futures.stream()
.map(f -> {
try { return f.get(10, TimeUnit.SECONDS); }
catch (Exception e) { return new UrlResult("error", -1, e.getMessage()); }
})
.toList();
}
}

Common pitfalls

PitfallProblemFix
Shared mutable stateRace conditions, data corruptionUse immutable objects, AtomicXxx, or synchronisation
thread.run() instead of start()Runs on the calling thread, not a new threadAlways call start()
Not shutting down ExecutorServiceThread pool keeps the JVM aliveCall shutdown() in a finally block or use try-with-resources (Java 21+)
Catching InterruptedException and ignoring itBreaks interrupt-based cancellationRe-interrupt: Thread.currentThread().interrupt()
DeadlockTwo threads each waiting for the other's lockLock ordering, timeout-based locking, or lock-free designs
synchronized with virtual threadsPins the carrier thread during blocking I/OUse ReentrantLock instead
Thread pool sized to available processors for I/O workThreads block on I/O, pool is underutilisedUse virtual threads or a larger cached pool for I/O
Future.get() without timeoutBlocks forever if the task never completesAlways use get(timeout, unit)

See also