Skip to main content
PProfileMatch

Java Concurrency: Threads, ExecutorService and CompletableFuture

ProgrammingLesson 2 of 48 min read

What you'll learn

  • Explain why count++ loses updates and fix it with synchronized or an atomic class
  • Describe what the Java memory model means by visibility and when volatile is enough
  • Run tasks on a thread pool with ExecutorService, read results from a Future and shut the pool down correctly
  • Compose, combine, time-limit and recover asynchronous calls with CompletableFuture

On this page

  1. A race condition in one line
  2. Visibility: when a thread cannot see someone else's write
  3. Stop creating threads by hand: ExecutorService
  4. CompletableFuture: composing asynchronous work
  5. Common mistakes
  6. How this shows up in real projects and interviews

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.

LostUpdates.java
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.

ApproachHow it protects the counterBest forCost
synchronized blockOnly one thread holds the lock at a time, so the three steps never interleaveProtecting several variables that must change togetherThreads queue up behind the lock
AtomicIntegerA single indivisible read-modify-write using a CPU-level compare-and-setOne counter, flag or referenceRetries under heavy contention
ConfinementEach thread keeps its own counter and you add them at the endStatistics and totalsNeeds 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.

StopFlagBroken.java (do not ship this)
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:

StopFlag.java
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.

GateCounts.java
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 6000

Two 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 methodThreadsWatch out for
newFixedThreadPool(n)Exactly n, reusedThe task queue is unbounded, so a slow consumer can exhaust memory
newCachedThreadPool()Grows on demand, idle ones expireA burst of tasks can create thousands of threads
newSingleThreadExecutor()One, tasks run in orderOne slow task blocks everything behind it
newScheduledThreadPool(n)n, supports delays and periodic tasksA task that throws silently cancels its future runs
newVirtualThreadPerTaskExecutor() (Java 21)One cheap virtual thread per taskNot 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.

QuoteAggregator.java
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().

VirtualDemo.java
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. ConcurrentHashMap makes each call safe, not your two-call sequence. Use merge, computeIfAbsent or putIfAbsent.
  • Swallowing `InterruptedException`. An empty catch block makes a task impossible to cancel. Restore the flag with Thread.currentThread().interrupt(), as fetch does 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.print reports "Found one Java-level deadlock". Fix it by always acquiring locks in one agreed order.
  • Blocking calls inside the common pool. A supplyAsync without an executor that waits on slow I/O can stall other CompletableFuture users 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: volatile guarantees other threads see a write, but not that a compound update is safe.
  • Submit work to an ExecutorService, collect Futures after submitting everything, and always shutdown() the pool.
  • CompletableFuture composes parallel calls with thenCombine, timeouts and exceptionally, 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.

Ride this lesson

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.