Java Concurrency: Threads, ExecutorService and CompletableFuture
ProgrammingLesson 2 of 48 min read
What you'll learn
- Explain why
count++loses updates and fix it withsynchronizedor an atomic class - Describe what the Java memory model means by visibility and when
volatileis enough - Run tasks on a thread pool with
ExecutorService, read results from aFutureand shut the pool down correctly - Compose, combine, time-limit and recover asynchronous calls with
CompletableFuture
On this page
Four turnstiles at a stadium each scan 200,000 entries into one shared counter. The finished program should report 800,000. Instead it reports a smaller number, and a different smaller number on the next run. Nothing threw an exception and nothing crashed, which is exactly what makes concurrency bugs so unpleasant: the program is wrong quietly.
A race condition in one line
The culprit is plainSold++. It looks like one operation, but the CPU does three: read the current value, add one, write it back. If two threads both read 41 before either writes, both write 42 and one sale vanishes. This is a race condition: the result depends on how the threads happen to interleave. The program below runs the same loop three ways so you can compare them side by side.
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
public class LostUpdates {
static int plainSold = 0;
static int lockedSold = 0;
static final Object lock = new Object();
static final AtomicInteger atomicSold = new AtomicInteger();
static final int GATES = 4;
static final int SALES_PER_GATE = 200_000;
public static void main(String[] args) throws InterruptedException {
List<Thread> gates = new ArrayList<>();
for (int g = 0; g < GATES; g++) {
Thread t = new Thread(() -> {
for (int i = 0; i < SALES_PER_GATE; i++) {
plainSold++; // read, add, write: three steps
synchronized (lock) { lockedSold++; }
atomicSold.incrementAndGet(); // one atomic hardware-level step
}
});
gates.add(t);
t.start();
}
for (Thread t : gates) {
t.join(); // wait for every gate to finish
}
int expected = GATES * SALES_PER_GATE;
System.out.println("expected = " + expected);
System.out.println("plain lost some: " + (plainSold < expected));
System.out.println("synchronized = " + lockedSold);
System.out.println("atomic = " + atomicSold.get());
}
}Output
expected = 800000 plain lost some: true synchronized = 800000 atomic = 800000
Only the plain int comes up short (the program prints a boolean rather than the lost count, because that count changes from run to run). The other two are correct for different reasons, and choosing between them is your first design decision.
| Approach | How it protects the counter | Best for | Cost |
|---|---|---|---|
synchronized block | Only one thread holds the lock at a time, so the three steps never interleave | Protecting several variables that must change together | Threads queue up behind the lock |
AtomicInteger | A single indivisible read-modify-write using a CPU-level compare-and-set | One counter, flag or reference | Retries under heavy contention |
| Confinement | Each thread keeps its own counter and you add them at the end | Statistics and totals | Needs a merge step |
Visibility: when a thread cannot see someone else's write
Mutual exclusion is half the story. The Java memory model also decides when one thread is guaranteed to see another thread's write. Without a synchronisation action, a thread may keep reading a cached copy, and the compiler or JIT may even hoist a read out of a loop. A write is guaranteed to become visible only across a happens-before edge: releasing a lock and then acquiring the same lock, writing and then reading a volatile field, Thread.start(), and Thread.join() all create one.
This is the classic shape of the bug: a worker spins on a plain boolean while another thread tries to stop it.
public class StopFlagBroken {
private static boolean running = true; // no volatile
public static void main(String[] args) throws InterruptedException {
Thread worker = new Thread(() -> {
while (running) { } // may be hoisted into an endless loop
});
worker.start();
Thread.sleep(100);
running = false; // worker is not guaranteed to ever see this
worker.join();
}
}When we compiled and ran this exact class on a HotSpot JVM, it never finished. The language permits that outcome but does not promise it, so another JVM or a different loop body might happen to stop. That unpredictability is the point. Marking the flag volatile fixes it:
import java.util.concurrent.TimeUnit;
public class StopFlag {
// Without volatile, the worker is allowed to keep using a stale copy of this flag.
private static volatile boolean running = true;
public static void main(String[] args) throws InterruptedException {
Thread worker = new Thread(() -> {
long spins = 0;
while (running) {
spins++;
}
System.out.println("worker saw the flag change, spun > 0 times: " + (spins > 0));
});
worker.start();
TimeUnit.MILLISECONDS.sleep(100);
running = false; // a write that other threads are guaranteed to see
worker.join();
System.out.println("main finished");
}
}Output
worker saw the flag change, spun > 0 times: true main finished
Stop creating threads by hand: ExecutorService
Creating a Thread per task costs memory, gives you no way to get a result back, and loses exceptions in a thread nobody is watching. An executor separates *what to run* from *which thread runs it*. You submit a Runnable or a Callable<V>, and you get a Future<V> that you can ask for the result. Future.get() blocks until the task is done and rethrows any failure wrapped in an ExecutionException.
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.ArrayList;
public class GateCounts {
public static void main(String[] args) throws Exception {
Map<String, Integer> entries = new ConcurrentHashMap<>();
ExecutorService pool = Executors.newFixedThreadPool(3);
List<Future<Integer>> receipts = new ArrayList<>();
for (String gate : List.of("north", "south", "north", "east", "south", "north")) {
// submit(Callable) returns a Future that carries the result or the exception
receipts.add(pool.submit(() -> {
int counted = 0;
for (int i = 0; i < 1_000; i++) {
entries.merge(gate, 1, Integer::sum); // atomic read-modify-write
counted++;
}
return counted;
}));
}
int total = 0;
for (Future<Integer> receipt : receipts) {
total += receipt.get(); // blocks until that task is done
}
pool.shutdown();
pool.awaitTermination(5, TimeUnit.SECONDS);
System.out.println(new TreeMap<>(entries));
System.out.println("tasks returned " + total);
}
}Output
{east=1000, north=3000, south=2000}
tasks returned 6000Two details here matter in production. ConcurrentHashMap.merge does the whole check-and-update atomically, whereas if (!map.containsKey(k)) map.put(k, 1) would be a race even on a concurrent map. And shutdown() followed by awaitTermination is the normal exit: pool threads are non-daemon, so a forgotten shutdown() keeps the JVM alive after main returns.
| Factory method | Threads | Watch out for |
|---|---|---|
newFixedThreadPool(n) | Exactly n, reused | The task queue is unbounded, so a slow consumer can exhaust memory |
newCachedThreadPool() | Grows on demand, idle ones expire | A burst of tasks can create thousands of threads |
newSingleThreadExecutor() | One, tasks run in order | One slow task blocks everything behind it |
newScheduledThreadPool(n) | n, supports delays and periodic tasks | A task that throws silently cancels its future runs |
newVirtualThreadPerTaskExecutor() (Java 21) | One cheap virtual thread per task | Not for CPU-bound work; see below |
CompletableFuture: composing asynchronous work
Future.get() forces you to block and then glue results together by hand. CompletableFuture lets you describe the dependency graph instead. A shipping app asks three couriers for a quote at the same time, gives up on a courier that is too slow, substitutes a placeholder for one whose API fails, and picks the cheapest of whatever comes back. The pieces are supplyAsync to start work, thenApply or thenCompose to transform a result, thenCombine to merge two independent futures, exceptionally or handle to recover, and completeOnTimeout or orTimeout (both added in Java 9) to bound the wait.
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class QuoteAggregator {
record Quote(String courier, int rupees) {}
static Quote fetch(String courier, int delayMs, int price, boolean down) {
try {
Thread.sleep(delayMs); // pretend this is a network call
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException(e);
}
if (down) {
throw new IllegalStateException(courier + " API is down");
}
return new Quote(courier, price);
}
static Quote cheaper(Quote a, Quote b) {
return a.rupees() <= b.rupees() ? a : b;
}
public static void main(String[] args) {
ExecutorService pool = Executors.newFixedThreadPool(3);
long start = System.nanoTime();
try {
CompletableFuture<Quote> swift = CompletableFuture
.supplyAsync(() -> fetch("SwiftShip", 400, 180, false), pool)
.completeOnTimeout(new Quote("SwiftShip (default rate)", 999), 250, TimeUnit.MILLISECONDS);
CompletableFuture<Quote> metro = CompletableFuture
.supplyAsync(() -> fetch("MetroPost", 200, 150, false), pool);
CompletableFuture<Quote> budget = CompletableFuture
.supplyAsync(() -> fetch("BudgetBox", 100, 90, true), pool)
.exceptionally(ex -> {
System.out.println("BudgetBox failed: " + ex.getCause().getMessage());
return new Quote("BudgetBox (unavailable)", Integer.MAX_VALUE);
});
Quote best = swift
.thenCombine(metro, QuoteAggregator::cheaper)
.thenCombine(budget, QuoteAggregator::cheaper)
.join();
long ms = (System.nanoTime() - start) / 1_000_000;
System.out.println("Best: " + best);
System.out.println("ran in parallel (under 600 ms): " + (ms < 600));
} finally {
pool.shutdown();
}
}
}Output
BudgetBox failed: BudgetBox API is down Best: Quote[courier=MetroPost, rupees=150] ran in parallel (under 600 ms): true
Read the output against the delays in the code. SwiftShip needs 400 ms but the timeout fires at 250 ms and substitutes a default quote of 999. BudgetBox fails, so the exceptionally stage prints the cause and returns a quote of Integer.MAX_VALUE. MetroPost wins at 150. Because the three calls overlap, the whole thing takes roughly the slowest wait rather than the sum of all three. Note ex.getCause(): a failure inside an async stage reaches exceptionally wrapped in a CompletionException, so the original exception is the cause.
Pass your own executor, as the example does, for anything that blocks. Without one, supplyAsync uses ForkJoinPool.commonPool(), which is shared by the whole JVM and sized for CPU-bound work, so a few blocking calls can starve unrelated code.
Virtual threads, Java 21 and later
Virtual threads are lightweight threads scheduled by the JVM, final in Java 21. Blocking in one is cheap, so a thread-per-task design becomes practical again for I/O-bound services. They do not make CPU-bound code faster and they do not remove the need for locks or atomics. This snippet needs Java 21 and, because ExecutorService became AutoCloseable in Java 19, uses try-with-resources instead of shutdown().
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
// Needs Java 21 or newer (virtual threads and ExecutorService as AutoCloseable)
public class VirtualDemo {
public static void main(String[] args) throws Exception {
List<Future<Integer>> results = new ArrayList<>();
try (ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 1; i <= 1_000; i++) {
int id = i;
results.add(pool.submit(() -> {
Thread.sleep(50); // blocking is cheap on a virtual thread
return id;
}));
}
} // close() waits for all submitted tasks
long sum = 0;
for (Future<Integer> f : results) {
sum += f.get();
}
System.out.println("tasks: " + results.size() + ", sum of ids: " + sum);
}
}Output
tasks: 1000, sum of ids: 500500
Common mistakes
- Calling `run()` instead of `start()`. The code executes on the current thread, so nothing is concurrent and no new thread appears in a stack dump.
- Forgetting `shutdown()`. The program prints its last line and then hangs because the pool's non-daemon threads are still alive.
- Check-then-act on a concurrent collection.
ConcurrentHashMapmakes each call safe, not your two-call sequence. Usemerge,computeIfAbsentorputIfAbsent. - Swallowing `InterruptedException`. An empty catch block makes a task impossible to cancel. Restore the flag with
Thread.currentThread().interrupt(), asfetchdoes above, or rethrow. - Calling `Future.get()` straight after `submit()` in a loop. Each call blocks, so the tasks effectively run one after another. Submit them all first, then collect.
- Locking in inconsistent order. If thread A takes lock 1 then lock 2 while thread B takes lock 2 then lock 1, both wait forever. The symptom is a program that hangs with no CPU use;
jcmd <pid> Thread.printreports "Found one Java-level deadlock". Fix it by always acquiring locks in one agreed order. - Blocking calls inside the common pool. A
supplyAsyncwithout an executor that waits on slow I/O can stall otherCompletableFutureusers in the same JVM.
How this shows up in real projects and interviews
Most application developers never call new Thread(). Concurrency arrives through frameworks: a web server's request pool, @Async in Spring Boot, a message consumer's worker threads. What you actually write are small, careful fixes: an atomic counter, a ConcurrentHashMap.merge, a CompletableFuture that fans out three service calls. The valuable instinct is spotting shared mutable state and asking who else can touch it.
Interviewers love the lost-update demo, volatile versus synchronized, how to avoid deadlock, and what happens when you forget shutdown(). A strong answer names the happens-before rule instead of saying "it flushes the cache". For the data structures behind these classes, see Java Collections, and for the wider picture of scaling work across machines, System Design basics.
Try it yourself
Modify QuoteAggregator so that it asks five couriers instead of three, builds a List<CompletableFuture<Quote>>, waits for all of them with CompletableFuture.allOf(...), and prints the quotes sorted by price. Give one courier a delay long enough to time out and one that throws, and confirm the program still prints a ranking.
Show a hint
Convert the list with futures.toArray(CompletableFuture[]::new) for allOf, call .join() on it, then stream futures and map each with CompletableFuture::join. Attach completeOnTimeout and exceptionally to each future before you collect them.
Key takeaways
count++is read, add, write; without a lock or an atomic type, concurrent increments overwrite each other.- Visibility is separate from atomicity:
volatileguarantees other threads see a write, but not that a compound update is safe. - Submit work to an
ExecutorService, collectFutures after submitting everything, and alwaysshutdown()the pool. CompletableFuturecomposes parallel calls withthenCombine, timeouts andexceptionally, and you should give it an executor when tasks block.- Virtual threads (Java 21) make blocking I/O cheap but do not fix races or speed up CPU-bound loops.
Test yourself
A trick from this lesson's ride: One innocent line, three hidden steps: read, add, write. Make them one step. Four more tricks like it wait along the route, each followed by a question.
Frequently asked questions
What is the difference between synchronized and volatile in Java?
synchronized gives mutual exclusion and visibility: only one thread runs the block at a time, and its writes are visible to the next thread that takes the same lock. volatile gives only visibility and ordering for a single variable, so it suits flags but not compound actions like count++.
What is the difference between Future and CompletableFuture in Java?
A Future is a handle you can only block on with get(). A CompletableFuture can also be chained, combined with other futures, given a timeout and recovered from errors without blocking, so you describe a pipeline of dependent steps and let it run asynchronously.
Why does my Java program not exit after main finishes?
The JVM exits only when all non-daemon threads have ended. An ExecutorService created with Executors.newFixedThreadPool keeps its worker threads alive until you call shutdown(), so forgetting that call leaves the program hanging after the last line prints.