Create your own
Lesson illustration

Identifying Race Conditions with Java Happens-Before Rules

Welcome back. In the previous lesson, you practiced making dependencies explicit in dynamic programming: define the state, identify which earlier states it relies on, and fill in an order that respects those dependencies. Java concurrency requires the same discipline, except the dependency graph now spans threads.

A backend service commonly handles many requests concurrently. If a Spring singleton, cache wrapper, or in-memory rate limiter exposes mutable state to those request threads, correct-looking Java code can still fail because operations interleave or because one thread has no guarantee of seeing another’s update. This lesson gives you a reliable way to inspect an execution trace, draw the relevant happens-before relationships, and decide whether it contains a race. Study time: about 40 minutes.


1. The question that source order cannot answer

Within one thread, Java follows program order: if a statement appears before another statement in that thread, the first action happens-before the second.

Across threads, source order is not enough. Consider:

class Counter {
    private int count = 0;

    void increment() {
        count++;
    }
}

Two request-handling threads may call increment() at nearly the same time. The source code has only one line, but the operation conceptually includes several actions:

  1. Read the shared value of count.
  2. Compute a new local value, old value plus one.
  3. Write the computed value back to count.

Java does not make that entire read-modify-write operation atomic merely because int reads and writes themselves are simple operations.

The Java Memory Model uses happens-before, abbreviated , to specify when one action is guaranteed to be visible to and ordered before another action. It is not a claim that actions occur at a particular wall-clock time. It is a correctness guarantee for reasoning about shared memory.

Two ideas must remain separate:

ConceptMeaning
Mutual exclusionOnly one thread may execute a critical section at a time.
Visibility and orderingA thread is guaranteed to observe another thread’s earlier actions.
Happens-beforeThe formal relation that provides ordering and visibility guarantees across threads.

A lock provides both mutual exclusion and a happens-before edge when threads use the same monitor correctly. A volatile field provides visibility and ordering for reads and writes of that field, but does not provide mutual exclusion.

Before continuing, read the core JLS rules. This is specification-level material, but focus on the operational rules you can apply in an interview trace rather than every formal detail.

Chapter 17. Threads and Locks

Read the relevant parts of the Java Language Specification, Chapter 17. This is the authoritative source for monitors, synchronization order, happens-before, and the definition of a data race.

In Section 17.1, “Synchronization,” read the monitor explanation beginning with “Each object in Java is associated with a monitor” through monitor release. Notice that a synchronized block locks one specific object. Then read Section 17.4.4, “Synchronization Order.” Start at “A synchronization order is a total order over all of the synchronization actions of an execution.” Read the monitor and volatile rules, including the bullets for monitor unlock and lock, volatile write and read, start(), and join(). Finally, in Section 17.4.5, “Happens-before Order,” read the listed rules and the paragraph defining a data race. End with the synchronization guarantee. Focus on program order, synchronizes-with, and transitivity.

The happens-before rules worth memorizing

For this course, these are the highest-value rules:

  1. Program order: Earlier actions in one thread happen-before later actions in that same thread.

  2. Monitor lock rule: An unlock of monitor happens-before a later lock of that same monitor .

  3. Volatile rule: A write to a volatile field happens-before a later read of that same field.

  4. Thread start rule: Actions before thread.start() happen-before actions in the started thread.

  5. Thread join rule: All actions in a thread happen-before another thread successfully returns from join() on it.

  6. Transitivity: If and , then .

The first and last rules let small synchronization facts create a larger guarantee. For example, a normal write before releasing a lock becomes visible to another thread that later acquires that lock, because program order and the monitor-lock rule compose transitively.


2. From a lost update to a data race

A data race, in the Java Memory Model sense, exists when two threads perform conflicting accesses to the same shared variable, at least one access is a write, and the accesses are not ordered by happens-before.

A race condition is broader: the program’s correctness depends on an undesirable timing or interleaving of threads. Data races commonly create race conditions, but even individually thread-safe operations can form a higher-level check-then-act race if the entire business operation is not atomic.

First, focus on the classic read-modify-write failure. Suppose count starts at .

Trace stepThread 1Thread 2Shared count
Initial state0
1reads count, gets 00
2reads count, gets 00
3computes local result 10
4writes 11
5computes local result 11
6writes 11

Both calls completed, but the final result is , not . Thread 2 overwrote Thread 1’s update because both calculations used the same stale starting value.

The local calculations are not themselves shared-memory conflicts. The important conflicting actions are:

  • Thread 1’s read of count and Thread 2’s write of count.
  • Thread 2’s read of count and Thread 1’s write of count.
  • The two writes to count.

There is program order within each thread, but there is no synchronization action connecting the threads. Therefore, there is no happens-before ordering that protects the conflicting shared accesses.

This is not repaired by observing that a particular test run happened to produce . A safe-looking schedule can occur by chance. The defect is that the harmful trace is permitted and there is no correctness guarantee ruling it out.

Watch this concise walkthrough for a visual trace of the lost-update pattern and the idea of a critical section.

Race Conditions in Java Multithreading

Watch “Race Conditions in Java Multithreading” by Jakob Jenkov. It demonstrates why a one-line increment is a read-modify-write operation and how synchronization changes the trace.

Begin with the definition to distinguish read-modify-write and check-then-act races. Then watch the counter trace; pause at the interleaving where both threads read zero. Finish with the synchronized fix, focusing on why the whole critical section, rather than only its final write, must be protected.

A trace-analysis procedure

When asked to diagnose concurrent code, do not start by guessing what the CPU does. Use this sequence:

  1. List shared state.
    Local variables and method parameters are generally thread-confined; fields, array elements, map entries, and referenced mutable objects may be shared.

  2. Expand compound operations.
    Treat count++, balance += amount, if (x) { ... }, and containsKey followed by put as multiple logical steps.

  3. Mark conflicting accesses.
    Look for accesses to the same location with at least one write.

  4. Draw program-order edges within each thread.
    These are automatic.

  5. Find synchronization bridges across threads.
    Look for the same monitor, a volatile write and later read of the same field, start(), join(), or a higher-level concurrent primitive with documented guarantees.

  6. Apply transitivity.
    A write inside a critical section may happen-before a later read inside a later critical section, even though the read and write are not synchronization actions themselves.

  7. State the failure precisely.
    Identify the unordered conflicting accesses and the violated invariant, such as “two increments must increase the counter by two.”

This gives an interview-quality explanation:

count++ is a read-modify-write critical section. Both threads can read the same old value and subsequently write the same new value. The conflicting accesses have no happens-before relationship because no common lock, volatile publication, or other synchronization connects the threads. Therefore a lost update is possible.”


3. Using synchronized: one monitor, one critical section

A synchronized block locks the monitor associated with its object expression:

synchronized (monitor) {
    // critical section
}

Only one thread can hold that monitor at a time. Leaving the block releases it, including when the block exits because of an exception.

final class Counter {
    private final Object monitor = new Object();
    private int count;

    void increment() {
        synchronized (monitor) {
            count++;
        }
    }

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

Here, count++ is entirely inside the critical section. Suppose Thread 1 increments first and exits the block; Thread 2 later enters it.

  1. Thread 1’s read and write of count occur before Thread 1 unlocks monitor, by program order.
  2. Thread 1’s unlock happens-before Thread 2’s later lock of that same monitor.
  3. Thread 2’s lock happens-before Thread 2’s later read of count, by program order.
  4. By transitivity, Thread 1’s update happens-before Thread 2 reads count.

The visibility guarantee matters just as much as exclusion. Thread 2 cannot enter halfway through Thread 1’s increment, and once it enters, it is guaranteed to observe the update that Thread 1 made before releasing the monitor.

The Monitor Lock rule diagram depicts two threads synchronizing on the same lock. Whichever thread releases that monitor first establishes a happens-before guarantee for the other thread’s later acquisition of the same monitor.

Common lock mistakes

Locking different objects does not coordinate access.

synchronized (new Object()) {
    count++;
}

This creates a fresh monitor for every call. Each thread can acquire its own object’s monitor, so nothing is mutually exclusive.

Similarly, these do not protect the same state unless they intentionally use the same monitor:

synchronized (this) { ... }
synchronized (otherObject) { ... }

Be especially alert to this in backend code:

  • An instance synchronized method locks that particular this.
  • A static synchronized method locks the class object.
  • If a static field is protected by per-instance locks, separate instances can mutate it concurrently.

The correct rule is not “the code uses synchronized.” It is:

Every access that must coordinate must use the same lock, and the lock must cover the full invariant-preserving operation.


4. Using volatile: publish a state change, not a compound update

volatile is appropriate when one thread publishes a state change and another thread observes it. A typical example is a readiness flag:

final class ResultBox {
    private int result;
    private volatile boolean ready;

    void publish(int computed) {
        result = computed;
        ready = true;
    }

    Integer tryRead() {
        if (!ready) {
            return null;
        }
        return result;
    }
}

Assume a consumer reads ready as true from the publisher’s write. The guarantee is:

  1. The write to result occurs before the write ready = true in the publisher, by program order.
  2. The volatile write to ready happens-before the consumer’s later volatile read of ready.
  3. The read of ready occurs before the read of result in the consumer, by program order.
  4. Therefore, the result write happens-before the consumer reads result.

The publication write must occur after the data writes. The consumer must read the volatile signal before consuming the associated ordinary state.

The Volatile Variable Rule diagram shows that a write to a shared volatile field happens-before a later read of that same field in another thread, making writes that occurred before the volatile publication visible after the volatile read.

volatile is not a replacement for a lock in the counter example:

private volatile int count;

void increment() {
    count++;
}

This still has a lost-update race. Each individual read and write has volatile visibility semantics, but two threads can both read before either performs its write; each can then write . volatile supplies ordering and visibility, not the “only one thread at a time” property needed for a read-modify-write operation.

For independent numeric increments, use a suitable atomic type such as AtomicInteger; for a larger invariant involving several fields or decisions, use a lock or a higher-level concurrency design.

Do not mistake timing for synchronization

Neither of these establishes a happens-before relationship between worker threads:

Thread.sleep(100);
Thread.yield();

Adding a delay may make a race harder to reproduce, but it does not make the code correct.

join() has a narrower, useful role:

worker1.start();
worker2.start();

worker1.join();
worker2.join();

System.out.println(counter.get());

Each successful join() ensures that the joined worker’s actions happen-before the main thread continues. It helps the main thread safely observe completed work. It does not make worker1 and worker2 coordinate with each other while both are performing their increments.


5. Data races versus check-then-act races

A useful final distinction for system-design and backend interviews:

  • A data race is an unordered conflicting memory access under the Java Memory Model.
  • A check-then-act race occurs when a decision and its dependent action must be atomic but are split apart.

For example, ConcurrentHashMap protects its own internal structure, but this is not one atomic business operation:

if (!users.containsKey(userId)) {
    users.put(userId, createUser(userId));
}

Two threads can both observe that the user is absent, both create an object or trigger an external side effect, and then race to insert. The map remains structurally safe, but the application invariant “create this user only once” may be violated.

The fix might be an atomic map operation such as putIfAbsent or computeIfAbsent, or a lock covering the entire decision and action. The key question is always:

What invariant must be true, and what complete sequence of reads, checks, writes, and side effects must occur as one coordinated operation?


Key takeaways

  • Happens-before is Java’s formal guarantee of cross-thread ordering and visibility; it is not merely source-code order or elapsed time.
  • A data race involves conflicting shared-memory accesses that lack a happens-before relationship.
  • Expand compound expressions such as count++ into read, compute, and write steps before analyzing a trace.
  • synchronized provides mutual exclusion and visibility only when the same monitor protects the entire critical section.
  • A volatile write followed by a later volatile read of the same field provides a publication edge, but volatile does not make compound operations atomic.
  • sleep() and yield() are timing tools, not synchronization tools; join() orders a completed worker with the joining thread, not peer workers with each other.
  • A thread-safe collection can still participate in a higher-level check-then-act race if several individually safe calls do not preserve the business invariant atomically.

Next, you will configure an ExecutorService for bounded concurrency and graceful shutdown. That will turn these low-level correctness rules into a practical server-side policy: limiting how much concurrent work your application accepts and ensuring worker tasks finish predictably during shutdown.

Can't find a good explanation? Sign up and we'll make it for you

Sign up