Home
Java from First Principles / Chapter 18 — Concurrency and Multithreading

Concurrency and Multithreading

Threads, the memory model, synchronization, volatile, atomics, ExecutorService, and why concurrency is the hardest topic in Java.


Why concurrency is hard

Concurrency is the hardest topic in any programming language. Java's no exception. The compiler can't catch concurrency bugs. They appear non-deterministically. They reproduce on production servers under load and never on your laptop. They corrupt state silently. They make debugging feel like archaeology.

The good news: most application code doesn't need to manually deal with threads. Modern frameworks (Spring, Quarkus, Play, Vert.x, Akka) handle threading internally. You write request handlers; they ensure each runs on an appropriate thread. The concurrency hardware is hidden.

The bad news: when you DO need to think about it, you really need to. A misunderstanding here causes data corruption, deadlocks, performance collapses, or all three at once.

This chapter covers the conceptual foundation, the primitives, the modern higher-level tools that replaced most direct thread management, and the rules that prevent the most common bugs. It can't cover every detail — Java concurrency could be its own series — but it'll give you enough to recognise the patterns and reach for the right tool.

Thread lifecycle: states and transitions Thread lifecycle NEW just created new Thread(...) RUNNABLE executing or ready on the CPU or scheduler queue BLOCKED waiting for monitor lock WAITING / TIMED_WAITING wait, join, sleep, park TERMINATED done run() returned or threw start() synchronized lock acquired wait()/sleep()/join() notified / timeout run() ends
A thread starts as NEW, becomes RUNNABLE when start() is called, may bounce between RUNNABLE, BLOCKED (waiting for a lock), and WAITING (sleeping or waiting on a condition), and finally TERMINATED when run() returns. Tools like jstack show you this state for every thread in a running JVM.

Threads — the basic primitive

A thread is an independent path of execution within a JVM process. The JVM starts one (the main thread) and you can create more.

Java
Thread t = new Thread(() -> {
    System.out.println("running on " + Thread.currentThread().getName());
});
t.start();
t.join();   // wait for it to finish

**Key operations:**
- start() — schedules the thread to run. Don't call run() directly — that runs it synchronously on the current thread.
- join() — block the current thread until the target finishes.
- interrupt() — request the target to stop. The target has to check Thread.currentThread().isInterrupted() periodically.
- Thread.sleep(ms) — pause the current thread.

**Thread states** (from Thread.State):
- NEW — created, not started
- RUNNABLE — running or ready to run
- BLOCKED — waiting to acquire a monitor lock
- WAITING — waiting indefinitely (wait, join with no timeout, park)
- TIMED_WAITING — waiting with a timeout
- TERMINATED — done

jstack <pid> shows the state of every thread in a running JVM. Critical for diagnosing freezes — you'll see threads in BLOCKED or WAITING, and the lock they're waiting on.

**Daemon vs user threads.** A daemon thread doesn't prevent the JVM from exiting. User threads do. The JVM exits when all non-daemon threads have finished. Background workers, timers, and cleanup threads are usually daemons; main application threads usually aren't.


The visibility problem

Here's the classic broken concurrent program:

Java
public class Worker {
    private boolean done = false;

    public void start() {
        new Thread(() -> {
            while (!done) {
                doWork();
            }
        }).start();
    }

    public void stop() {
        done = true;
    }
}

You'd expect: thread A loops until thread B calls stop(), at which point A sees done == true and exits.

What actually happens: thread A may loop forever. Even after B sets done = true, A might never see the change.

The reason is **the Java Memory Model**. Each thread can keep variables in CPU registers or per-CPU caches for performance. When B writes done = true, the change might sit in B's CPU cache and never propagate to A's cache. The JIT compiler may also assume done doesn't change inside the loop (since the loop body doesn't modify it) and hoist the check out entirely.

The fix: make done volatile.

Java
private volatile boolean done = false;

volatile tells the JVM "this variable may be modified by other threads — never cache it, always read/write from main memory." It also creates a "happens-before" relationship that prevents the JIT from optimising away the read.

**volatile is the simplest concurrency primitive.** Use it for boolean flags, references, and any field a thread reads without holding a lock.

What volatile does NOT do:
- It does NOT make compound operations atomic. volatile int counter; counter++; is still a race — read, increment, write are three separate steps.
- It does NOT replace synchronization for protecting multi-step invariants.

For atomic operations on primitives, use AtomicInteger, AtomicLong, AtomicReference. For broader protection, use synchronization or a lock.


synchronized — the original mutex

synchronized is Java's built-in mutex. It guarantees that only one thread can execute a block at a time, and gives memory visibility guarantees.

**As a method modifier:**

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

This is equivalent to:

Java
public void deposit(int amount) {
    synchronized (this) {
        balance += amount;
    }
}

The lock used is the instance itself (this). Two threads calling deposit on the same object serialize. Threads calling deposit on different objects run in parallel.

**As a block:**

Java
private final Object lock = new Object();

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

Using a private final lock object is the recommended pattern. It prevents external code from accidentally or maliciously synchronizing on the same object and causing deadlock.

**Memory effects.** Entering a synchronized block establishes a happens-before relationship: all writes done by another thread that previously exited this same block are visible to you. This is why synchronized handles visibility AND mutual exclusion in one mechanism.

**Static synchronized methods** lock on the Class object, not an instance:

Java
public class Counter {
    public static synchronized void increment() { ... }   // locks Counter.class
}

**Common pitfall: synchronizing too coarsely.** Holding a lock across slow operations (I/O, network calls) blocks all other threads waiting for that lock. Hold locks for the shortest possible critical section.


The deadlock pattern

Two threads, two locks, each holding one and waiting for the other:

Java
// Thread A
synchronized (lockA) {
    synchronized (lockB) {
        // ...
    }
}

// Thread B
synchronized (lockB) {
    synchronized (lockA) {
        // ...
    }
}

If A acquires lockA, then B acquires lockB, then both try to acquire the other — they wait forever. Neither can proceed. The JVM doesn't detect this; it just hangs.

**How to prevent it:**
- **Always acquire locks in a fixed global order.** If every thread acquires lockA before lockB, you can't deadlock.
- **Hold locks for as short a time as possible.**
- **Avoid nested locks where possible.** Often you can restructure code to need only one.
- **Use tryLock with a timeout** (from java.util.concurrent.locks.Lock). If you can't get the lock within N seconds, back off and retry.

**Detecting deadlocks in production.** jstack <pid> includes a "Found N deadlocks!" section at the bottom if it detects any. You'll see exactly which threads are waiting on which locks. Most modern profilers (VisualVM, JFR) also surface this.


Atomic classes

java.util.concurrent.atomic provides lock-free primitives for compound operations.

Java
AtomicInteger counter = new AtomicInteger(0);
counter.incrementAndGet();        // atomic ++counter
counter.getAndAdd(5);             // atomic counter += 5; returns old value
counter.compareAndSet(0, 100);   // if counter == 0, set to 100; returns true if changed

Atomics use CPU-level instructions (CAS — compare-and-swap) to perform updates without locks. They're dramatically faster than synchronized for simple counters and flags.

**Available atomics:**
- AtomicInteger, AtomicLong, AtomicBoolean
- AtomicReference<T> — for any object reference
- AtomicIntegerArray, etc. — for arrays
- LongAdder, DoubleAdder — high-contention counters (faster than AtomicLong under heavy load)

**When to use atomics:** any time you have a simple shared counter, flag, or reference that multiple threads update. They're the fastest correct option.

**When NOT to use atomics:** when you need to coordinate multiple variables together. An atomic guarantees that ONE operation is atomic; it can't make a compound update across multiple atomics atomic. For that, you need a lock or an immutable snapshot pattern.


ExecutorService — the modern way to run async work

Direct thread management (creating, starting, joining threads) is rarely needed. Java provides ExecutorService — a thread pool abstraction that handles the lifecycle.

Java
ExecutorService pool = Executors.newFixedThreadPool(10);

Future<String> result = pool.submit(() -> {
    // runs on a pool thread
    Thread.sleep(1000);
    return "done";
});

String value = result.get();   // blocks until the task completes
pool.shutdown();

**Common pool types:**
- newFixedThreadPool(n) — fixed N threads, queue grows unbounded.
- newCachedThreadPool() — creates threads as needed, reuses idle ones, keeps them alive for 60s.
- newSingleThreadExecutor() — exactly one worker thread, serialized tasks.
- newScheduledThreadPool(n) — for delayed and periodic tasks.

**Always shut down executors.** They keep non-daemon threads alive, preventing JVM exit:

Java
pool.shutdown();                 // initiates orderly shutdown
pool.awaitTermination(60, TimeUnit.SECONDS);

**Java 21+: virtual threads.** A massive evolution. Virtual threads are lightweight — millions can run on a JVM, vs thousands for platform threads. The API is the same:

Java
ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor();

Each task runs on its own virtual thread. Blocking on I/O parks the virtual thread without blocking a platform thread. For I/O-heavy workloads (web servers, RPC clients), this is transformative — you write blocking code and get async performance. The "async vs blocking" debate that dominated Java backend design for a decade is largely obsolete in Java 21+.

For new code on Java 21+, prefer virtual threads. For older Java, use traditional fixed pools.


Concurrent collections

Standard collections (ArrayList, HashMap, HashSet) are NOT thread-safe. Modifying them from multiple threads causes silent corruption, infinite loops, or unexpected exceptions.

**ConcurrentHashMap** — the workhorse. Lock-free reads, fine-grained-locked writes. Use it any time a map is shared across threads.

Java
ConcurrentHashMap<String, Integer> counts = new ConcurrentHashMap<>();
counts.merge("hits", 1, Integer::sum);     // atomic increment-or-insert
counts.computeIfAbsent("key", k -> computeExpensive(k));

**CopyOnWriteArrayList / CopyOnWriteArraySet** — every write copies the entire underlying array. Reads are lock-free. Use when reads vastly outnumber writes — for example, registered listener lists.

**BlockingQueue** implementations — for producer/consumer patterns:
- ArrayBlockingQueue — bounded, array-backed.
- LinkedBlockingQueue — bounded or unbounded, linked-node-backed.
- SynchronousQueue — capacity 0, hands off directly from producer to consumer.

**Don't use Collections.synchronizedMap for new code.** It locks the entire map on every operation. ConcurrentHashMap is faster and has a richer API.

**Don't use Hashtable or Vector at all.** They're legacy synchronized versions of HashMap/ArrayList from Java 1.0. Replaced by ConcurrentHashMap and the modern alternatives in 1998. Don't touch them.


Higher-level tools to reach for first

Before reaching for raw threads/synchronized/wait/notify, look at these:

**CompletableFuture<T>** — async computation chaining without callback hell:

Java
CompletableFuture.supplyAsync(() -> fetchUser(id))
    .thenApply(User::getName)
    .thenAccept(System.out::println)
    .exceptionally(e -> { log.error("failed", e); return null; });

Compose async operations declaratively. Handle errors. Combine multiple futures. Replaces most cases where you'd manually manage callbacks.

**Semaphore** — limit concurrent access to a resource:

Java
Semaphore permits = new Semaphore(10);   // max 10 concurrent
permits.acquire();
try {
    doExpensiveOperation();
} finally {
    permits.release();
}

**CountDownLatch** — one-shot barrier:

Java
CountDownLatch ready = new CountDownLatch(3);
// each worker calls ready.countDown() when done
ready.await();   // blocks until count reaches 0

**CyclicBarrier** — reusable barrier where N threads all wait for each other.

**ReentrantLock** — like synchronized but with more features: tryLock with timeout, fairness, condition variables.

**ReadWriteLock** — multiple readers OR one writer. Useful when reads dominate writes.

For 90% of application code, you'll never need any of this. Frameworks handle threading. Use ExecutorService for async work. Use ConcurrentHashMap for shared mutable maps. Use volatile or AtomicReference for shared flags. Reach for the lower-level primitives only when you're genuinely building infrastructure.


Rules that prevent most concurrency bugs

The compressed wisdom.

**1. Prefer immutability.** Immutable objects are inherently thread-safe — no reads can ever see a torn or stale state. Use final fields, return defensive copies, use Records and List.copyOf(). If you can't mutate it, you can't have a race on it.

**2. Confine state to one thread.** A variable used by only one thread doesn't need synchronization. Confined-by-design beats shared-with-locks every time.

**3. Synchronize, don't be clever.** If you do need shared mutable state, just use synchronized or ConcurrentHashMap. Don't try to be cute with double-checked locking or volatile-only patterns unless you understand the JMM deeply.

**4. Hold locks briefly.** Acquire, do the minimal critical section, release. Don't do I/O or call slow methods while holding a lock.

**5. Use higher-level abstractions.** ConcurrentHashMap, AtomicInteger, ExecutorService, CompletableFuture. They're correct, fast, and battle-tested.

**6. Document thread safety.** A class is either thread-safe or it isn't. If it isn't, the javadoc should say so. If it is, the documentation should say which operations are safe.

**7. Test concurrent code.** Tools like jcstress (JCStress) help expose race conditions. But the truth is, concurrent code is hard to test thoroughly. Code review, careful design, and proven primitives matter more than tests.

**8. On Java 21+, prefer virtual threads.** They make the "blocking is bad" mantra obsolete for I/O. Spring Boot 3, Quarkus, and most modern frameworks have first-class support.

Concurrency in Java is huge. This chapter is the foundation. For deep dives, "Java Concurrency in Practice" by Brian Goetz remains the authoritative book — old (2006) but the fundamentals haven't changed.


⁂ Back to all modules