ML
Java / Spring

Java Concurrency and the Memory Model

Visibility, atomicity, and happens-before are three different guarantees, and most concurrency bugs come from confusing them.

May 17, 202611 min readconcurrencyjvm

Most Java concurrency bugs come from conflating three distinct guarantees: visibility (does another thread ever see my write?), atomicity (does a compound operation happen as one indivisible step?), and ordering (in what sequence do other threads observe my writes?). The Java Memory Model (JMM) defines exactly when each holds, and once you internalize it the rest of java.util.concurrent stops looking like magic.

1. Threads and the Java Memory Model

The JMM does not promise that a write performed by one thread becomes visible to another thread in any particular timeframe — or at all. Each thread is permitted to keep values in registers or core-local caches, and the JIT compiler is free to reorder, hoist, and eliminate reads and writes as long as the result is consistent within that single thread. There is no implicit global synchronization point between threads.

That freedom is what makes the canonical visibility bug possible. A thread spins on a plain boolean flag while another thread flips it:

class Worker {
    private boolean running = true; // plain field

    void loop() {
        while (running) {
            // do work
        }
    }

    void stop() {
        running = false; // set from another thread
    }
}

This loop may never terminate. Nothing in the JMM requires the reading thread to ever re-read running. Because nothing inside loop() writes to running, the JIT may treat it as loop-invariant and hoist the read out of the loop — effectively compiling it to if (running) while (true) {}, reading the field once and then looping forever. The write in stop() happens, but the spinning thread is staring at a stale value. This is a visibility failure, and it is the bug interviewers are probing when they ask "why doesn't this loop stop?"

2. happens-before: the only ordering you can rely on

The JMM is defined in terms of the happens-before relation. If action A happens-before action B, then the effects of A (including all its writes) are guaranteed visible to B, and A is ordered before B. Without a happens-before edge between two actions in different threads, there is no guarantee about visibility or ordering between them.

The edges worth memorizing:

  • Program order within a single thread.
  • An unlock on a monitor happens-before every subsequent lock on the same monitor.
  • A write to a volatile field happens-before every subsequent read of that same field.
  • Thread.start() happens-before any action in the started thread; every action in a thread happens-before any other thread returning from a successful join() on it.
  • Releasing/acquiring concurrency utilities (e.g. actions before a CountDownLatch.countDown() happen-before actions after the matching await() returns, and a put into a BlockingQueue happens-before the corresponding take) establish happens-before too.

The crisp interview answer: volatile and synchronized do not just prevent reordering of the field itself — they create a happens-before edge that makes all writes made before the release visible after the acquire.

3. volatile: visibility and ordering, but not atomicity

Marking the flag volatile fixes the spin loop:

class Worker {
    private volatile boolean running = true;

    void loop() {
        while (running) { /* do work */ }
    }

    void stop() {
        running = false;
    }
}

Now the JIT may not hoist the read, every read is guaranteed to observe the most recent write rather than a cached stale value, and the write in stop() happens-before the next read. The loop terminates. volatile is exactly the right tool here because the flag is a single, independent value — there is no read-modify-write involved.

The trap is assuming volatile also buys atomicity. It does not. Consider:

private volatile int counter = 0;

void increment() {
    counter++; // NOT atomic
}

counter++ is three operations: read, add one, write back. volatile guarantees each individual read and each individual write is visible across threads, but two threads can both read the same value, both add one, and both write back the same result — losing an update. volatile gives you visibility and ordering; it gives you nothing for compound actions. The crisp answer: volatile is correct only when a write does not depend on the current value.

4. synchronized: mutual exclusion plus visibility

synchronized is the heavier tool, and it solves both problems at once. It provides mutual exclusion (only one thread holds a given monitor at a time) and visibility (the unlock-then-lock happens-before edge from section 2). Wrapping the read-modify-write fixes the lost-update race:

class Counter {
    private int count = 0; // no volatile needed; the lock covers visibility

    synchronized void increment() {
        count++;
    }

    synchronized int get() {
        return count;
    }
}

Two things are easy to get wrong here. First, get() must also be synchronized on the same monitor — otherwise an unsynchronized reader has no happens-before edge to the writes and may see a stale count. The lock is what publishes the value, not the field itself. Second, the monitor identity matters: synchronizing on two different objects gives you two independent locks and zero mutual exclusion. Lock on the same instance (or a dedicated private final Object lock = new Object();) for every access to the shared state it protects.

5. AtomicInteger and CAS: lock-free read-modify-write

Taking a lock to bump a counter is correct but heavyweight when the only operation is a single read-modify-write. java.util.concurrent.atomic offers a lock-free alternative built on compare-and-set (CAS), a hardware primitive that atomically updates a value only if it still holds an expected prior value:

private final AtomicInteger counter = new AtomicInteger();

void increment() {
    counter.incrementAndGet(); // atomic, no lock
}

Under the hood incrementAndGet() is a retry loop: read the current value, compute the new one, attempt a CAS, and if another thread won the race in the meantime, re-read and try again. There is no blocking and no monitor. The same pattern is exposed directly for arbitrary updates:

AtomicReference<State> ref = new AtomicReference<>(initial);
ref.updateAndGet(s -> s.withIncrementedVersion());

Note that the function passed to updateAndGet must be side-effect-free, because it can be re-applied multiple times under contention. Atomics shine for counters, sequence generators, and single-variable state; once you need to update several fields together as a unit, you are back to a lock or an immutable object swapped atomically via AtomicReference.

6. ConcurrentHashMap and the right concurrent collections

A plain HashMap mutated from multiple threads is not just at risk of lost updates — concurrent resizes can corrupt internal structure (in older JDKs a racy resize could even spin the CPU at 100% on an infinite-loop bucket chain). The fix is not to wrap it in Collections.synchronizedMap (which serializes every operation behind one lock and still forces you to hand-synchronize iteration); it is ConcurrentHashMap, which allows concurrent reads and fine-grained, per-bin locking for writes.

The subtlety: individual methods are atomic, but a get followed by a put is two operations and therefore a race. Use the atomic compound methods instead:

ConcurrentHashMap<String, Integer> counts = new ConcurrentHashMap<>();

// WRONG: check-then-act race
if (!counts.containsKey(key)) counts.put(key, 0);

// RIGHT: atomic
counts.putIfAbsent(key, 0);
counts.merge(key, 1, Integer::sum);          // atomic increment per key
counts.computeIfAbsent(key, k -> new ArrayList<>());

The remapping function in compute/merge/computeIfAbsent runs while holding the bin lock, so keep it short and never call back into the same map from inside it. Note too that the atomicity covers only the insertion into the map: a value such as the ArrayList above is itself not thread-safe, so if multiple threads will mutate that list you must use a thread-safe value type or synchronize on it.

7. Prefer ExecutorService and CompletableFuture over raw threads

Spawning new Thread(r).start() by hand is almost always the wrong abstraction: no bounded pool, no backpressure, no reuse, no clean shutdown, and exceptions vanish silently. Submit work to an ExecutorService instead, which decouples task submission from the threads that run them:

ExecutorService pool = Executors.newFixedThreadPool(8);
try {
    Future<Integer> f = pool.submit(() -> compute());
    int result = f.get(); // surfaces a task exception as ExecutionException
} finally {
    pool.shutdown();
}

Submitting a task to an executor happens-before the task begins running, and the task's completion happens-before Future.get() returns — so values you publish through the task are visible to the caller without any extra synchronization. For composing asynchronous steps without blocking a thread on get(), reach for CompletableFuture:

CompletableFuture
    .supplyAsync(() -> loadUser(id), pool)
    .thenApply(User::profile)
    .thenAccept(this::render)
    .exceptionally(ex -> { log.error("failed", ex); return null; });

Pass your own executor to the *Async variants rather than relying on the default, which is the JVM-wide common ForkJoinPool — easily starved by blocking I/O and shared with everything else in the process. On Java 21+, virtual threads (Executors.newVirtualThreadPerTaskExecutor()) make a thread-per-task model cheap for I/O-bound work, but the memory-model rules in this post are unchanged — visibility and atomicity guarantees come from happens-before edges, not from the kind of thread.

Rules of thumb

  • Visibility, atomicity, and ordering are three separate problems. Name which one you have before choosing a tool.
  • Use volatile only for single-variable flags where the new value does not depend on the old one. x++ on a volatile is still a race.
  • Reach for synchronized (or a Lock) when you need both mutual exclusion and visibility, and lock the same monitor for reads and writes of the shared state.
  • Prefer atomics for single-variable read-modify-write and ConcurrentHashMap with its atomic merge/computeIfAbsent methods over check-then-act.
  • Never hand-roll new Thread() for application work — use an ExecutorService or CompletableFuture with an executor you own and shut down.
SharePostLinkedIn

Reader Discussion

2 replies// weighed in

TopNewestAuthor
Add to the thread
Disagree, agree harder, or share your own experience…
Email instead →markdown okbe kind
  1. Isabella Costa· Junior EngineerKind words

    saved this. sharing at standup tomorrow — we've had exactly this problem for 2 sprints and nobody on the team had framed it this way 🙏

    May 19, 2026·2 days later
  2. Kenta Yamada· Tech LeadAsks

    would love a war-story follow-up. principles are clear; the actual debugging session is where the interesting stuff lives. there's a real shortage of "here's the dashboard, here's the thread we pulled, here's where we got stuck for 90 mins" content.

    May 21, 2026·4 days later

Worked on something similar? Email ducminhldm@gmail.com — I read every one. The good ones become future posts.

Comments seeded · live discussion via email