briefki
All articles

Why a Plain Field Update Might Never Reach Another Thread

Close-up of tower servers in a data center with blue and red lighting.
Photo by panumas nikhomkhai on Pexels

This compiles, passes review, and ships without a single warning:

public class CarHolder {
    private Car car = new Car("BMW X1", "Black");

    public void update(Car next) { car = next; }
    public Car current() { return car; }
}

It also has a real bug: a thread that’s only ever calling current() in a loop, waiting for update() to be called from somewhere else, has no guarantee it will ever see the new value — not “might take a moment”, but potentially never, for the lifetime of the program. Here’s why, in plain terms first, then the technical explanation.

In plain terms

Imagine two people working from the same shared whiteboard, except each of them is also allowed to keep their own personal notepad with a copy of whatever was last written. Most of the time they just glance at the whiteboard and update their notepad, so it doesn’t matter which one they’re reading from. But nothing forces them to keep checking the whiteboard — if a thread/person is busy in a tight loop, it might just keep reading its own notepad over and over, never noticing the whiteboard changed at all.

volatile is the rule “always read from the whiteboard, never trust your notepad” for one specific piece of data. Without it, Java makes no promise about when — or whether — one thread’s write becomes visible to another thread’s reads.

In technical terms

This is governed by the Java Memory Model (JLS Chapter 17). Visibility of a plain field write across threads is only guaranteed when there’s a happens-before relationship between the write and the read — and ordinary field assignment doesn’t establish one on its own. Without that relationship, the compiler and JIT are allowed to assume nothing else touches car, and can legally cache a thread’s read of it in a register, or even hoist the read entirely out of a loop, since nothing in the loop body (as far as that thread’s optimizer can tell) could change it. That’s not a hypothetical worst case — it’s a real, observed failure mode for unsynchronized busy-wait loops on optimizing JVMs.

volatile establishes exactly the happens-before edge that’s missing: every write to a volatile field happens-before every subsequent read of it by any thread, full stop.

public class CarHolder {
    private volatile Car car = new Car("BMW X1", "Black");

    public void update(Car next) { car = next; }
    public Car current() { return car; }
}

That’s enough for this case — one thread writes, others read, and visibility is guaranteed. What volatile does not give you is atomicity for compound operations. If you needed something like “replace car only if it still equals what I last read” (a compare-and-swap), you’d reach for AtomicReference instead, which provides both the visibility guarantee and atomic conditional updates:

private final AtomicReference<Car> car =
    new AtomicReference<>(new Car("BMW X1", "Black"));

public boolean replaceIfUnchanged(Car expected, Car next) {
    return car.compareAndSet(expected, next);
}

The part that surprises people

The object being immutable doesn’t help here at all — and that’s the trap. It’s tempting to assume “I’m using an immutable Car, so this is thread-safe”, but immutability only protects the object’s fields from changing underneath you once you have a reference to it. It says nothing about whether a different thread will ever see that you swapped in a new reference. Those are two separate guarantees, and only one of them comes from making Car immutable.

Immutability and visibility solve different problems — and conflating them is where most bugs in this space come from. An immutable object protects its own state; volatile is about whether other threads ever find out the reference changed at all. Any field that one thread writes and another reads needs an explicit visibility mechanism: there is no “it’s probably fine” version. Use volatile for single reads and writes; reach for AtomicReference the moment you need a conditional update to be atomic as well.

🔗 Source: JLS §17.4: Memory Model