Race Conditions and Visibility in Java — What the Memory Model Actually Guarantees

by Eric Hanson, Backend Developer at Clean Systems Consulting

The two distinct problems in concurrent Java code

Race conditions and visibility failures are related but different problems. Conflating them leads to fixes that address one while leaving the other.

A race condition occurs when the correctness of a computation depends on the relative timing of operations across threads. A check-then-act sequence where another thread can modify state between the check and the act. An increment operation that reads, modifies, and writes a value non-atomically while another thread does the same.

A visibility failure occurs when a write by one thread is not observable by another thread — not due to timing, but because the JVM, JIT compiler, or CPU has reordered operations or cached values in a way that prevents the write from propagating. A thread can loop forever on a condition that was satisfied by another thread if the read sees a stale cached value.

Both require understanding the Java Memory Model (JMM) — the specification that defines what the JVM guarantees about when writes become visible.

The Java Memory Model — happens-before

The JMM defines visibility in terms of the happens-before relationship. If action A happens-before action B, then B is guaranteed to see the effects of A. The JMM specifies a set of rules that establish happens-before:

  • Program order: within a single thread, every action happens-before the next action in program order.
  • Monitor unlock/lock: an unlock of a monitor happens-before every subsequent lock of that same monitor.
  • Volatile write/read: a write to a volatile field happens-before every subsequent read of that field.
  • Thread start: Thread.start() happens-before any action in the started thread.
  • Thread join: all actions in a thread happen-before Thread.join() returns.
  • Transitivity: if A happens-before B and B happens-before C, then A happens-before C.

If there is no happens-before relationship between a write and a read, the JMM makes no guarantee about visibility. The reading thread may see the written value, the old value, or — in theory — any value that was ever stored in that location.

The visibility failure in practice

The canonical visibility failure:

public class StopFlag {
    private boolean stopped = false; // not volatile

    public void stop() {
        stopped = true;
    }

    public void run() {
        while (!stopped) { // may loop forever
            doWork();
        }
    }
}

If stop() and run() execute on different threads, there is no happens-before relationship between the write to stopped and the read of stopped. The JIT compiler may hoist the read of stopped out of the loop — if it determined stopped is never written by run()'s thread, it may conclude the value never changes and optimize the loop condition away.

The fix: volatile boolean stopped. A volatile write happens-before a subsequent volatile read of the same field, establishing the necessary happens-before.

volatile — what it does and doesn't guarantee

volatile provides two guarantees:

Visibility: a write to a volatile field is immediately visible to all subsequent reads of that field by any thread. No caching, no reordering relative to other volatile operations.

Atomicity for single reads and writes: reading or writing a volatile long or volatile double is atomic (64-bit values without volatile can be written as two 32-bit operations, creating a visibility window where another thread sees half-written data).

volatile does not guarantee atomicity of compound operations:

private volatile int counter = 0;

// Not atomic — three operations: read, increment, write
// Two threads executing this simultaneously can both read 0, both write 1
counter++;

The read-modify-write sequence is not atomic even with volatile. Both threads can read the same value, increment independently, and write the same result — a lost update.

For compound operations, use AtomicInteger:

private final AtomicInteger counter = new AtomicInteger(0);
counter.incrementAndGet(); // atomic compare-and-swap under the hood

volatile is correct for: a flag written by one thread and read by others with no compound operations, publishing a fully constructed immutable object, double-checked locking (covered below).

synchronized — mutual exclusion and visibility

synchronized provides both mutual exclusion (only one thread executes the block at a time) and visibility (all writes made by the synchronized block are visible to any thread that subsequently acquires the same monitor).

The visibility guarantee is broader than volatile: entering a synchronized block flushes the thread's local cache and reads from main memory; exiting flushes writes to main memory. This means all variables written inside a synchronized block — not just the ones explicitly synchronized — are visible to the next thread that acquires the same monitor.

public class Counter {
    private int count = 0; // not volatile — protected by synchronized

    public synchronized void increment() {
        count++; // atomic relative to other synchronized methods on this
    }

    public synchronized int get() {
        return count;
    }
}

The synchronized on both methods ensures that increment's read-modify-write is atomic and that get() sees the latest value written by any increment().

The same monitor requirement. The visibility guarantee holds only when threads use the same monitor. Synchronizing on different objects provides no happens-before between their blocks:

// Thread 1
synchronized (lockA) { value = 42; }

// Thread 2
synchronized (lockB) { System.out.println(value); } // no visibility guarantee

This is a common mistake in code that uses multiple lock objects — assuming synchronization provides global visibility rather than visibility between threads using the same lock.

Double-checked locking — correctly

Double-checked locking is a pattern for lazy initialization that avoids synchronization on every access. The broken version is a classic example of a visibility failure:

// Broken — without volatile, the JIT can reorder object construction
private static ExpensiveObject instance;

public static ExpensiveObject getInstance() {
    if (instance == null) {                    // first check — unsynchronized
        synchronized (MyClass.class) {
            if (instance == null) {            // second check — synchronized
                instance = new ExpensiveObject(); // may be seen partially constructed
            }
        }
    }
    return instance;
}

The problem: instance = new ExpensiveObject() compiles to roughly: allocate memory, write reference to instance, initialize fields. The JIT can reorder the write to instance before field initialization. A thread that passes the first null check may see a non-null but incompletely initialized object.

The fix: volatile on the field. A volatile write cannot be reordered with preceding writes — initialization happens-before the volatile write, which happens-before subsequent reads:

private static volatile ExpensiveObject instance;
// rest unchanged — now correct

The simpler alternative for single-instance lazy initialization: the initialization-on-demand holder idiom, which relies on class loading semantics rather than volatile:

public class ExpensiveObjectHolder {
    private static class Holder {
        static final ExpensiveObject INSTANCE = new ExpensiveObject();
    }

    public static ExpensiveObject getInstance() {
        return Holder.INSTANCE;
    }
}

The Holder class is loaded on first access to getInstance(). Class initialization is thread-safe by JVM specification — the class loader acquires a lock before initializing a class. No explicit synchronization needed.

java.util.concurrent — the right tools for compound operations

AtomicInteger, AtomicLong, AtomicReference, and AtomicReferenceArray provide lock-free atomic operations using compare-and-swap (CAS):

AtomicInteger counter = new AtomicInteger(0);
counter.incrementAndGet();                    // atomic increment
counter.compareAndSet(expected, update);      // conditional update — no update if current != expected
counter.updateAndGet(x -> x + 10);           // atomic lambda application

CAS-based operations are non-blocking — they retry on contention rather than waiting. Under low contention they're faster than synchronized. Under high contention (many threads competing on the same atomic), LongAdder or LongAccumulator are more efficient — they stripe the counter across multiple cells and reduce contention at the cost of a slightly more expensive sum() call.

ReentrantLock provides synchronized-equivalent mutual exclusion with additional capabilities:

private final ReentrantLock lock = new ReentrantLock();

public void process() {
    lock.lock();
    try {
        // critical section
    } finally {
        lock.unlock(); // always in finally
    }
}

// Timed lock acquisition — avoids deadlock by giving up
if (lock.tryLock(100, TimeUnit.MILLISECONDS)) {
    try {
        // critical section
    } finally {
        lock.unlock();
    }
} else {
    // couldn't acquire lock — handle timeout
}

tryLock with a timeout is the ReentrantLock capability that synchronized lacks — useful when holding a lock indefinitely would cause more damage than failing the operation.

Immutability as the elimination of the problem

The cleanest solution to shared mutable state is removing the mutability. An immutable object needs no synchronization — it can be published to any number of threads safely after construction, provided the reference itself is safely published.

Safe publication: making an object visible to other threads in a way that guarantees they see its fully initialized state. The JMM guarantees safe publication through:

  • Storing the reference in a volatile field
  • Storing it in a final field of a properly constructed object
  • Storing it via AtomicReference
  • Storing it inside a synchronized block that another thread subsequently synchronizes on
// Safe publication via final field
public class ImmutablePoint {
    public final int x;
    public final int y;

    public ImmutablePoint(int x, int y) {
        this.x = x;
        this.y = y;
    }
}

final fields are guaranteed visible to all threads after the constructor completes, as long as this doesn't escape during construction. An immutable class with all-final fields is the most thread-safe object Java offers — no synchronization required at any use site.

Finding race conditions

Race conditions are difficult to reproduce because they depend on thread interleaving. Three tools help:

jcstress — the Java Concurrency Stress testing harness from OpenJDK. Runs tests that exercise concurrent access patterns exhaustively and checks for atomicity, visibility, and ordering violations. The right tool for testing concurrent data structures and low-level synchronization code.

Thread sanitizer (via async-profiler or native agents) — detects unsynchronized accesses to shared variables at runtime. Higher overhead than production-safe tools but finds real races.

Code review with the JMM in mind. For each shared mutable field, ask: is there a happens-before between every write and every read by a different thread? If not, it's a potential visibility failure. For each compound operation on shared state, ask: can another thread observe an intermediate state? If yes, it needs atomic operations or synchronization.

The happens-before checklist is not optional for concurrent code. Writing thread-safe Java without it is writing code that works by accident under the current JVM, JIT, and CPU — and may silently break under a different configuration, load pattern, or future JVM version.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

Why “Don’t Touch This Code” Is a Huge Engineering Red Flag

Hearing “don’t touch this code” might seem like harmless advice, but it often signals deep problems in a codebase and the team culture around it.

Read more

Amsterdam Backend Salaries Hit €100K. Here Is How Startups Avoid That Overhead

Your next backend hire in Amsterdam will probably cost you six figures before you even factor in the 30% ruling changes and mandatory benefits. That number used to be reserved for staff engineers. Now it's table stakes for anyone decent.

Read more

Rails Concerns — When They Help and When They Hurt

Rails concerns are one of the most misused features in the framework. Used correctly they share behavior cleanly across unrelated models. Used as a refactoring tool they just relocate complexity without reducing it.

Read more

Async Is Not a Compromise — It Is How the Best Remote Backend Teams Actually Work

Async remote work has a reputation as the fallback option when synchronous isn't possible. That reputation is wrong, and the teams doing backend development best know it.

Read more