Concurrency · Performance Lab

Memory Ordering in Java and Rust

Coherence guarantees every core agrees on one location's value. It says nothing about whether a write to a different location becomes visible before or after it. That gap is memory ordering — and it's where "it worked on my machine" concurrency bugs live.

Learning objective

Explain why the order two threads observe each other's writes is not the same as the order those writes appear in source code, distinguish visibility from atomicity from ordering, explain acquire/release publication and why it works, and map Java's VarHandle access modes and Rust's atomic orderings without assuming a direct one-to-one equivalence.

Prerequisites

  • The Cache Coherence and MESI lab — this lab assumes you already know that coherence guarantees agreement on a single location's value. This lab is about what coherence does not give you: ordering across different locations.
  • Comfortable reading Java or Rust concurrent code (atomics, threads).

Theory

Program order vs. observed order

Program order is the order your source code writes instructions in. Observed order is the order another thread actually sees those instructions' effects take place — and the two are not the same thing. A compiler is free to reorder independent accesses with no data dependency between them; a CPU core can hold its own stores in a local store buffer before they become visible to other cores, so a later independent load can complete before an earlier store has drained. Neither is a bug — both are legal, performance-motivated behaviour in the absence of a rule forbidding it. Memory-ordering annotations (volatile, Acquire/Release, fences) are that rule.

Three different guarantees

GuaranteeQuestion it answers
AtomicityDoes this operation appear indivisible — never observed half-done?
VisibilityOnce a write happens, when (if ever) does another thread observe it?
OrderingIf a thread observes write A, does it also observe every write that happened-before A?

A Relaxed atomic in Rust is atomic but gives no ordering guarantee about other memory operations. A volatile/SeqCst access gives all three. Conflating these three is where most of the myths below come from.

happens-before and data races

happens-before is a partial order the language memory model defines over a program's operations: if A happens-before B, every effect of A is guaranteed visible to B. Program order within one thread always contributes edges; synchronizing operations (a release paired with an acquire that observes it) contribute cross-thread edges. A data race is two accesses to the same location from different threads, no happens-before edge between them, at least one a write — something to eliminate, not reason carefully about.

The message-passing litmus test: broken vs. fixed publication

Thread 0                      Thread 1
data = 1                      while (!flag) {}
flag = 1                      read data

With plain access, this is legal but broken: nothing stops thread 1 from observing flag == 1 and data == 0 — seeing the "ready" signal without seeing the data it's meant to guard. The fix is a release store on the publishing side paired with an acquire load on the observing side: a release write makes every write before it in program order visible no later than itself, and an acquire read that observes it establishes happens-before from everything before the release to everything after the acquire.

The store-buffering litmus test

Thread 0                      Thread 1
x = 1                          y = 1
read y                         read x

Under relaxed ordering, both threads can observe 0 for the other's write — each thread's own store can sit in its store buffer while its own independent-address load is satisfied directly from memory. This is exactly what sequential consistency (SeqCst) forbids: SeqCst adds a single total order every SeqCst operation agrees on, ruling out the both-see-0 outcome.

Conceptual model

The interactive model below fixes ONE legal, illustrative interleaving per scenario using an explicit "flush" step — not a simulation of every interleaving a real CPU's store buffer could produce, and not a claim that acquire/release "flushes caches." See "Limitations of this model" below.

Try it

Interactive store-buffer / happens-before model

Conceptual model

Two threads, a handful of shared variables, and a per-thread store buffer for un-synchronized writes — one fixed, illustrative interleaving per scenario, not a cycle-accurate CPU simulation. See "Limitations of this model" below for what this leaves out.

T0 writes data, then flag — both with plain access. Step through and watch the flag's write get flushed to memory before data's, letting T1 see flag=1 while data is still 0.

Thread 0

Buffer: empty

Thread 1

Buffer: empty

Outcome: Step through to the end to see the outcome.

Happens-before edges

  • No synchronizes-with edges yet.
Scenario
Broken publication
Ordering
plain
Step
0 of 6

Event log

  1. Initial state — no instructions executed.

Java and Rust

Plain access (the bug)

public class PlainPublication {
    private static final VarHandle DATA, FLAG;
    static {
        try {
            var lookup = MethodHandles.lookup();
            DATA = lookup.findVarHandle(PlainPublication.class, "data", int.class);
            FLAG = lookup.findVarHandle(PlainPublication.class, "flag", int.class);
        } catch (ReflectiveOperationException e) {
            throw new ExceptionInInitializerError(e);
        }
    }

    private int data;
    private int flag;

    public void publish() {
        DATA.set(this, 42);
        FLAG.set(this, 1);
    }

    // May legally read flag == 1 and data == 0.
    public boolean tryConsume() {
        if ((int) FLAG.get(this) == 1) {
            int seen = (int) DATA.get(this);
            return seen == 42; // NOT guaranteed, even after seeing flag == 1
        }
        return false;
    }
}

VarHandle.set/get are plain accesses: no ordering guarantee at all. This is the exact shape of the interactive model's "Broken publication" scenario.

Release/acquire (the fix)

public void publish() {
    DATA.set(this, 42);          // plain — ordering comes from the release below
    FLAG.setRelease(this, 1);    // release: flushes everything before it in program order
}

public boolean tryConsume() {
    if ((int) FLAG.getAcquire(this) == 1) {
        int seen = (int) DATA.get(this);
        return seen == 42; // guaranteed true
    }
    return false;
}

setRelease/getAcquire is the JVM's direct expression of the release/acquire pair the "Release/acquire message passing" scenario demonstrates.

Opaque access — atomicity without cross-variable ordering

public void increment() {
    int current = (int) COUNTER.getOpaque(this);
    COUNTER.setOpaque(this, current + 1); // NOT atomic as a read-modify-write!
}

Careful: this is not the same as an atomic increment. getOpaque/setOpaque give per-location atomicity and a coherent total order for that one field — but the get-then-set pair above is still two separate operations; another thread's increment can interleave and lose an update. A genuine atomic increment needs getAndAdd or a compare-and-set loop, as below.

Volatile — the strongest VarHandle mode

private volatile int counter;

public void incrementAtomically() {
    COUNTER.getAndAdd(this, 1); // a genuine atomic read-modify-write
}

This is the JVM analogue of the interactive model's relaxed-counter RMW step: the counter is always correct regardless of interleaving (atomicity), which says nothing about the ordering of any other, unrelated field.

The runnable Maven project (including a test demonstrating the broken and fixed publication paths) is at content/labs/memory-ordering/code/java/ in this site's repository.

Methodology and limitations

This lab deliberately does not present a synthetic "memory ordering benchmark." Reordering effects are timing-dependent, hardware-dependent, and often simply don't reproduce reliably on a given developer machine even when the underlying bug is completely real — a benchmark number here would invite exactly the false confidence this lab argues against.

  • One fixed interleaving per scenario. The interactive model uses an explicit "flush" step to make store-buffer timing visible and steppable. Real store buffers drain asynchronously, on hardware-determined schedules this lab does not simulate.
  • Two threads, a handful of variables. Real programs have far more complex happens-before graphs than this lab's four- to six-step scripts.
  • The happens-before graph shown is the direct synchronizes-with edge for this scenario's specific release/acquire pair, not a full transitive closure over an arbitrary program.
  • Java and Rust are not identical memory models. VarHandle access modes and Rust's atomic orderings are close cousins, not interchangeable one-to-one.

Myths this lab explicitly rejects

  • "Acquire/release flushes the CPU cache." No language memory model talks about cache flushing. Coherence already guarantees agreement on a single location; acquire/release is about ordering across locations — a different guarantee, implemented however a given compiler/CPU pair chooses.
  • "volatile (Java) makes an access globally instantaneous." It makes it ordered relative to other volatile/synchronizing accesses via happens-before — not instantaneous.
  • "Atomics automatically make a compound algorithm correct." An atomic increment is atomic; a check-then-act built from two separate atomic operations is not, unless the whole operation is a single atomic RMW primitive.
  • "SeqCst (or volatile/full fences) is required everywhere, to be safe." Weaker orderings are the correct, sufficient, and cheaper choice for a large fraction of real synchronization patterns — see "When weaker orderings are enough" below.

Common mistakes

  • Assuming a JMM VarHandle access mode maps index-for-index onto a Rust Ordering variant — they are close cousins, defined independently, not interchangeable one-to-one.
  • Using getOpaque/setOpaque (or a Relaxed load-then-store) as a substitute for a genuine atomic read-modify-write — a get-then-set pair is never atomic as a whole, no matter how strong the individual accesses are.
  • Publishing data with a plain write and a plain "ready" flag, then trusting the reader's plain read of the flag to mean the data is visible too.
  • Treating a memory-ordering bug that didn't reproduce today as evidence the code is correct — reordering effects are legal, not guaranteed, and absence on one run/one machine proves nothing.
  • Reaching for SeqCst/volatile everywhere "to be safe" instead of identifying the actual happens-before edge a piece of code depends on.

When weaker orderings are enough

Relaxed/Opaque is enough when

  • The value is a counter or statistic where only the final atomic value matters, not its ordering relative to any other variable (the "Relaxed counter" scenario).
  • Nothing else in the program depends on the order in which this variable's updates become visible relative to other memory operations.

You need Acquire/Release or stronger when

  • One thread publishes data that another thread must not observe before it's fully written (the message-passing / publication pattern).
  • Correctness depends on multiple independent atomics agreeing on a relative order across threads (the store-buffering test) — this specific case needs SeqCst, not just Acquire/Release.

Investigation task

Using the Java or Rust project in code/:

  1. Run the broken-publication test in both languages and confirm it passes (i.e., does not assert a guarantee plain access cannot provide) — then run the release/acquire version and confirm it asserts the guaranteed outcome.
  2. Run the store-buffering test under Relaxed/relaxed ordering in a loop (thousands of iterations) and record whether you ever observe both threads reading 0 for the other's write on your hardware. Note: not reproducing it on your machine does not mean the outcome is impossible — see "Methodology and limitations" above.
  3. Re-run the same store-buffering test under SeqCst/seqcst and confirm your test assertion (at least one thread must observe the other's write) never fails, across as many iterations as you're willing to run.
  4. Read your CPU vendor's architecture manual's memory-ordering chapter (Intel SDM Vol. 3A, or the Arm Architecture Reference Manual) and identify which store-to-load reordering, if any, it explicitly permits — compare it to what this lab's model assumes.

Accessibility notes

  • The interactive model is fully keyboard operable: the scenario selector and the Java/Rust code tabs are roving-tabindex tablists (arrow keys, Home/End); the ordering toggle and step controls are native <button>s with aria-pressed reflecting the active ordering.
  • Every step is announced to screen readers via an aria-live="polite" region, in addition to the visible outcome badge, event log, and state inspector — state is never colour-only.
  • This page has no auto-playing or looping animation; step transitions resolve instantly, so there is nothing beyond the site-wide CSS rule to gate on prefers-reduced-motion.
  • With JavaScript disabled, this page's theory, Java and Rust code, methodology, trade-offs and sources remain fully readable — only the interactive model and the graded quiz require script.

Review questions

Self-check before or after the graded quiz below:

  1. Why can a thread observe another thread's "ready" flag as set, but still see stale data underneath it, when both are written with plain access?
  2. What specifically does a release write guarantee about writes that happened before it in program order?
  3. Why does the store-buffering litmus test need SeqCst, when acquire/release already provides happens-before edges?
  4. Give an example of an atomic operation that is atomic but provides no useful ordering guarantee for the rest of your program.

Quiz

Sources