A boolean flag is updated by a writer inside a synchronized block but read by a worker loop without synchronization. Why might the worker never see the update, and how do the synchronized memory effects explain the fix?
answer
- unsynchronized read can be hoisted / cached → infinite loop
- writer flushes, but reader must acquire to see it (handshake)
- no matching acquire = no happens-before = no guarantee
- fix: read under same lock, or make field volatile
- synchronize BOTH ends of shared state
basics
~20 sThe reader can keep using a stale cached copy of the flag because nothing forces it to refresh from main memory. The fix is to read the flag with the same synchronization (same lock, or make it volatile) so entering re-reads the latest value the writer flushed.
solid answer
~50 sWithout synchronization on the read side, the JVM and CPU are free to cache the flag in a register or core cache and never reload it, so the worker's loop can spin forever on a stale value, never observing the writer's update. The writer's synchronized block does flush the new value on release, but that flush only becomes guaranteed-visible to a thread that performs a matching acquire of the same monitor. An unsynchronized read establishes no happens-before edge with the writer's release, so the visibility is not guaranteed. The fix is to make the read participate in the same synchronization: read the flag inside a `synchronized` block on the same lock, where the acquire barrier invalidates the cache and re-reads the flushed value, or declare the flag `volatile` so each read is an acquire that sees the latest write. Either way, the reader is forced to observe the writer's flushed update.
go deeper
Should recognize that an unsynchronized reader can see a stale value and loop forever, and that the fix is to read with the same synchronization or make the field volatile.
Explains the writer-flush / reader-acquire handshake and why synchronizing only one side fails; picks volatile vs synchronized appropriately for a simple flag.
Describes compiler hoisting and caching as the mechanism, names the missing happens-before edge, and distinguishes visibility-only (volatile) from atomicity needs (synchronized/Atomic).
Can reason about the full JMM contract, advise on correct publication patterns across a codebase, audit for one-sided synchronization, and weigh performance trade-offs of volatile vs locks vs lock-free structures.
## The bug, concretely ```java class Worker { private boolean stop = false; // shared flag, NOT volatile private final Object lock = new Object(); void requestStop() { synchronized (lock) { stop = true; } // writer: synchronized } void run() { while (!stop) { // reader: NOT synchronized // do work } } } ``` Another thread calls `requestStop()`, but `run()` may loop forever. Why? ### Why the reader can spin forever Without any synchronization or `volatile` on the read, the Java Memory Model places **no constraint** that the reading thread ever reloads `stop` from main memory. A real JIT compiler may **hoist** the read out of the loop — effectively rewriting `while (!stop)` into `if (!stop) while (true)` — because nothing in the reader's own thread changes `stop`, so the compiler assumes it is constant. The CPU may also keep `stop` in a register or in the core's cache. The writer flips `stop` to `true` and even flushes it (its `synchronized` release publishes the write), but the reader has no reason to look again. Result: an infinite loop on a stale value. This is a **visibility bug**, and it is one of the most common real-world concurrency defects. ### What the writer's synchronized actually did The writer's `synchronized (lock) { stop = true; }` does perform a **release barrier** on exit: `stop = true` is flushed and made available. But a flush is only half of a handshake. The JMM's **monitor-lock happens-before rule** says: an unlock happens-before a *subsequent lock of the same monitor*. The guarantee of seeing the write exists **only for a thread that performs that matching acquire.** Our reader never acquires `lock`, so there is **no happens-before edge** between the writer's release and the reader's read — hence no guarantee. ### The fix via synchronized (acquire on the read) ```java void run() { while (true) { synchronized (lock) { if (stop) break; } // do work } } ``` Now each loop iteration performs a **monitor acquire** on the same `lock`. The acquire barrier **invalidates the cached copy and re-reads** `stop` from main memory, so it observes the value the writer flushed. The acquire/release pair establishes the happens-before edge, and the loop terminates. ### The simpler fix via volatile ```java private volatile boolean stop = false; ``` A `volatile` read behaves like a lightweight **acquire** and a `volatile` write like a lightweight **release**. Each `while (!stop)` read is now guaranteed to see the latest write, with no lock and no mutual exclusion needed (a single boolean flag needs visibility, not atomicity-of-a-compound-action, so `volatile` suffices). This is the idiomatic fix for a simple stop flag. ### The general rule this illustrates **Both ends of a shared mutable variable must use the same synchronization.** Synchronizing only the writer (or only the reader) breaks the acquire/release pairing and loses the guarantee. The memory effects of `synchronized` are a *handshake*: the writer's release flushes, but the reader must perform a matching acquire to be guaranteed to see it. Choose `synchronized` (mutual exclusion + visibility) when you also need atomicity over a compound operation; choose `volatile` (visibility only) for a simple flag. ### Define the terms used - **Stale read:** reading an out-of-date value because the thread used a cached copy instead of re-reading shared memory. - **Hoisting:** a compiler optimization that moves an invariant read out of a loop, assuming it cannot change. - **Happens-before:** the JMM relation guaranteeing one action's effects are visible to and ordered before another; created here only by same-monitor unlock→lock. - **Release / acquire barrier:** the flush-on-exit and invalidate-on-entry effects of monitor release/acquire (and of volatile write/read).
- Why is volatile sufficient for a simple stop flag but not always sufficient for, say, an incrementing counter?A stop flag needs only visibility — see the latest write. volatile provides that. A counter needs read-modify-write atomicity (count++ is three steps); volatile guarantees visibility but not atomicity, so concurrent increments can be lost. That needs synchronized or AtomicInteger.
- If you synchronize the read but on a different lock than the write, is the bug fixed?No. The happens-before edge requires the same monitor. Acquiring a different lock establishes no edge with the writer's release, so the reader can still see a stale value. Both ends must use the same lock (or volatile).
saying these in an interview costs you the question
- Assuming the writer's synchronized alone guarantees the reader sees the update
- Saying the loop 'eventually' sees it so it's fine — it may never
- Adding synchronized only to the read or only to the write
- Thinking volatile is needed for atomicity here when it's actually for visibility