Java Memory Model Tutorial: Happens-Before, volatile and Safe Publication

Learn the Java Memory Model through exact start/join, volatile, synchronized, final-field and AtomicInteger examples, with clear happens-before guarantees.

KnowledgeGate Team

Exam prep & CS education

Updated 6 Nov 20266 min read
Java Memory Model Tutorial: Happens-Before, volatile and Safe Publication

One thread executes score = 42; ready = true;, yet another cannot treat those plain writes as one single-threaded trace. The Java Memory Model (JMM) defines legal observations, not stacks, heaps, CPU caches or garbage collection. Exact examples clarify visibility, ordering, atomicity and happens-before.

What the Java Memory Model controls

The JMM determines which shared-state write a read may observe. The rules come from Chapter 17 of the Java SE 21 Language Specification.

Under JLS 17.4.1, static int total, account.balance and scores[2] are shared variables when several threads can reach them. A local int subtotal = 6, a parameter or an exception parameter is not shared, although an object reached through a local reference may contain shared fields.

Ask three questions: visibility, can R observe W's value; ordering, must one action precede another; and atomicity, can another thread interleave with multiple steps?

Use Java Multithreading Basics: Threads, Runnable and Safe Counters with Examples for Thread/Runnable creation, lifecycle, scheduling and counter basics. The JMM explains why start(), join() and synchronization change what another thread may observe.

Build happens-before from four concrete edges

Program order covers one thread. Synchronization can create a synchronizes-with edge between threads. Happens-before contains both and their transitive consequences. If A happens-before B, A's effects are visible to and ordered before B. Wall-clock timing proves nothing.

JLS-backed rule

Exact application

Monitor unlock to a later lock of the same monitor

Unlock lock happens-before a later lock of lock

Volatile write to a later read of the same field

Writing true to ready happens-before a later read that sees true

Thread start

worker.start() happens-before actions in worker

Thread termination and join

Actions in worker happen-before worker.join() successfully returns

These rules come from JLS 17.4.4 and 17.4.5. Main writes input = 7 and starts worker. Worker reads 7, calculates 7 x 6 = 42, writes result = 42 and terminates. After join() returns, main reads 42. Start carries input in; join carries result back.

A data race is two conflicting accesses to one variable, at least one a write, without happens-before ordering. A correctly synchronized, data-race-free program appears sequentially consistent.

Happens-before trace: main writes input 7 then starts the worker, which computes 7 x 6 and writes 42; join returns 42 to main.

Runnable example: start and join publish 42

java
public class StartJoinVisibility {
    static int input = 0;
    static int result = 0;

    public static void main(String[] args) throws InterruptedException {
        input = 7;
        Thread worker = new Thread(() -> result = input * 6, "calculator");
        worker.start();
        worker.join();
        System.out.println("result=" + result);
    }
}

The output is result=42 because 7 x 6 = 42. Start publishes earlier actions to worker, and join publishes completed worker actions back. Neither field needs volatile here.

Remove join(), and main may print result=0 or result=42; no worker-to-main edge orders that read. Thread.sleep(100) is not a repair because JLS 17.3 gives sleep no synchronization semantics.

Runnable example: volatile hands off score 42

With plain score and ready, both fields race. The reader need not leave its loop, and source order does not publish across threads. Repair only ready:

java
public class VolatileHandoff {
    static int score = 0;
    static volatile boolean ready = false;

    public static void main(String[] args) throws InterruptedException {
        Thread reader = new Thread(() -> {
            while (!ready) Thread.onSpinWait();
            System.out.println("score=" + score);
        });
        Thread writer = new Thread(() -> { score = 42; ready = true; });
        reader.start(); writer.start();
        reader.join(); writer.join();
    }
}

After reader observes volatile true, it must print score=42. Writer order, the volatile edge and reader order form one transitive path. This does not promise scheduling fairness. For production one-shot coordination, prefer CountDownLatch to busy spinning.

Volatile handoff: writer stores score 42 before ready; reader observes volatile ready and then score 42, while plain ready provides no happens-before edge.

synchronized protects a 1,000 balance invariant

Visibility is insufficient when a check and update form one invariant:

java
import java.util.Arrays;

public class AccountDemo {
    static class Account {
        private int balance = 1_000;
        synchronized boolean withdraw(int amount) {
            if (balance < amount) return false;
            balance -= amount;
            return true;
        }
        synchronized int balance() { return balance; }
    }
    public static void main(String[] args) throws InterruptedException {
        Account account = new Account();
        boolean[] success = new boolean[2];
        Thread t1 = new Thread(() -> success[0] = account.withdraw(600));
        Thread t2 = new Thread(() -> success[1] = account.withdraw(600));
        t1.start(); t2.start(); t1.join(); t2.join();
        System.out.println(Arrays.toString(success) + ", balance=" + account.balance());
    }
}

The results are [true, false] or [false, true], with balance 400: 1,000 - 600 = 400, so the second check fails. Without synchronization, both can read 1,000, pass, calculate and write 400, then report success after accepting 600 + 600 = 1,200 against 1,000.

synchronized supplies exclusion and visibility through unlock and later lock. Synchronization in Java: Monitors, Race Conditions and Runnable Examples goes deeper into monitor identity, synchronized methods versus blocks, wait/notify and deadlock prevention. The 1,000-balance trace isolates the JMM distinction between visibility and a protected compound invariant.

Safe publication with final fields

An immutable configuration fixes its state during construction:

java
static final class Config {
    final int workers, batchSize, capacity;
    Config(int workers, int batchSize) {
        this.workers = workers;
        this.batchSize = batchSize;
        this.capacity = workers * batchSize;
    }
}
static volatile Config config;
// Publisher: config = new Config(4, 250);
// Reader: Config local = config;

For new Config(4, 250), capacity is 4 x 250 = 1,000. A non-null local can print workers=4, batchSize=250, capacity=1000. The volatile reference publishes the object; final fields express construction-time immutability. JLS 17.5 gives correctly constructed objects special final-field guarantees when this does not escape.

final gives neither deep immutability nor safe later mutation. Without reference publication, a reader may miss the installed configuration.

Choose volatile, synchronized, atomic or final

Requirement

Tool

Example

Publish an independent state transition

volatile

ready

Protect a compound invariant

synchronized

Check and subtract balance

Perform one atomic numeric update

AtomicInteger

Increment a counter

Fix state during correct construction

final

Config.capacity

The invariant chooses the tool. Volatile access synchronizes, but a decrement is still read, subtract and write. From volatile int permits = 4, A and B can both read 4, compute 3 and write 3. Two serial decrements should leave 2. With AtomicInteger(4), two completed decrementAndGet() calls leave 2.

Sleep does not publish state. containsKey then put remains compound on a concurrent map, so use its suitable atomic method. Volatile fields cannot preserve a multi-field invariant; use one lock or publish an immutable snapshot. Happens-before constrains observations, not folklore about private copies or values stored only in main memory.

Java Memory Model exercises and the short version

Work through each scenario and name the guarantee that forces the answer:

  1. Main sets limit = 5, starts a worker that sums 1 + 2 + 3 + 4 + 5, then joins. Main must read 15.

  2. Writer performs ordinary data = 64, then volatile ready = true. After reader sees true, it must read 64. Reversing the writes removes that guarantee for the later data write.

  3. Balance starts at 1,500; two synchronized withdrawals request 900. Exactly one succeeds, and 1,500 - 900 = 600 remains.

  4. An AtomicInteger starts at 5. After 3 x 1,000 = 3,000 completed increments and joins, the value is 3,005.

For practice, explain each of the four answers before running code, then remove one ordering edge and predict which guarantee disappears.

The core guarantees are six rules: plain shared access may race; happens-before carries visibility and ordering; start() publishes earlier actions to a worker; join() publishes completed worker actions back; volatile can publish a state transition but cannot make count++ atomic; synchronized protects multi-step invariants. Continue with the Complete Java Course for the wider language sequence, or browse Coding & Skill Development Courses for the broader programming route.