Design-wise, why is guarding a shared field's writes and reads with two different monitors a correctness bug even though each access is technically 'synchronized', and how would you reason about and prevent this class of error at scale?
answer
- 'same monitor' is load-bearing in the happens-before rule
- write-lock A + read-lock B = no edge, no exclusion, stale reads
- one documented lock per mutable shared field/invariant
- @GuardedBy + private final locks + Error Prone/SpotBugs
- monitor = key to a specific safe; two safes protect nothing in common
basics
~20 sThe happens-before edge only forms between unlock and lock of the same monitor. If writes use lock A and reads use lock B, there's no edge between them, so a reader can see stale or reordered data even though both sides 'use a lock'. Prevent it by binding each piece of shared state to one documented lock.
solid answer
~50 sJava's visibility guarantee is the monitor-lock happens-before rule: an unlock on a monitor happens-before a subsequent lock on the same monitor. 'Same monitor' is load-bearing. If writers synchronize on lock A and readers on lock B, neither acquire matches the other's release, so no happens-before edge is established and the reader has no guarantee of seeing the writer's flushed value — it may read stale or partially-published state, despite both being inside synchronized blocks. There is also no mutual exclusion between the two, so they can run concurrently and corrupt invariants. At scale you prevent this by establishing a lock-ownership discipline: every mutable shared field is guarded by exactly one, documented lock; use @GuardedBy annotations, keep lock objects private and final, prefer confining state behind a single lock or using java.util.concurrent / immutable structures, and enforce via code review and static analysis. The mental model: a lock is a key for a specific safe; two keys for two different safes protect nothing in common.
go deeper
Should grasp the basic takeaway: writes and reads of a shared field must use the same lock, or the reader may see stale data.
Explains that the happens-before edge needs the same monitor and that split locks give neither visibility nor exclusion; uses a single lock per field.
Reasons from the JMM rule, identifies the bug in code, knows it's probabilistic/platform-dependent, and applies private-final-lock and one-lock-per-invariant discipline.
Designs and enforces a codebase-wide lock-ownership policy: @GuardedBy, static analysis, immutability/confinement/j.u.c., review gates, and can teach why testing alone is insufficient for memory-model correctness.
## Why 'both are synchronized' is not enough It is tempting to think that as long as every access to a shared field sits inside *some* `synchronized` block, the field is safe. It is not. The Java Memory Model's guarantee is specific. ### The exact rule The **monitor-lock happens-before rule**: *an unlock (monitor release) on monitor M happens-before every subsequent lock (monitor acquire) on the same monitor M.* The phrase **'the same monitor M'** is the entire game. The acquire/release barriers do flush and invalidate, but the *guarantee that one thread sees another's writes* is created only when the acquiring thread and the releasing thread used the **same** monitor object. ### The buggy design ```java class Account { private long balance; private final Object writeLock = new Object(); private final Object readLock = new Object(); // different monitor! void deposit(long amt) { synchronized (writeLock) { balance += amt; } // release writeLock } long balance() { synchronized (readLock) { return balance; } // acquire readLock } } ``` Every access is inside a `synchronized` block, so it 'looks' thread-safe. But: 1. **No happens-before edge.** `deposit` releases `writeLock`; `balance()` acquires `readLock`. These are different monitors, so there is **no** unlock→lock edge. A reader has **no guarantee** of seeing the latest `balance`; it can read a stale value indefinitely, or a torn/partially-published value for non-atomic types. 2. **No mutual exclusion between read and write.** Holding `readLock` does not block a thread holding `writeLock`. They run concurrently, so a read can observe an intermediate state, and two writers... well, here writers share `writeLock` so they're serialized among themselves, but reads race writes freely. The code compiles, passes casual testing (visibility bugs are timing- and hardware-dependent), and fails sporadically in production — the worst kind of defect. ### Why it's so insidious Visibility/ordering bugs are **probabilistic** and **platform-dependent**. On a strongly-ordered CPU (e.g., x86) you may rarely observe staleness; on a weakly-ordered one (ARM, POWER) it surfaces more. They often hide for months, then appear under load or on new hardware. This is exactly why reasoning from the JMM contract — not from testing — is essential. ### How to reason about it Ask, for each shared mutable field: **'Which single monitor (or volatile, or immutability) guards every read and every write of this field?'** If the answer is 'more than one lock' or 'a lock for writes but a bare read', there is a hole. The correct invariant is: **all accesses to a given piece of mutable shared state are ordered by the same synchronization mechanism.** ### Preventing it at scale (the principal-level part) 1. **Lock-ownership discipline / documented policy.** For every mutable shared field, name the one lock that guards it, in code. Use **`@GuardedBy("lock")`** (JSR-305 / Error Prone) so the policy is machine-checkable. Error Prone's `GuardedBy` checker flags accesses outside the named lock. 2. **Make lock objects private and final.** A `private final Object lock` cannot be reassigned or locked by outside code, preventing accidental cross-locking and lock-object aliasing bugs. Never lock on `this`/public objects exposed to clients, and never on interned values (`String` literals, boxed small `Integer`s) that other code may share. 3. **Minimize the number of locks per invariant.** Each *invariant* spanning multiple fields must be guarded by **one** lock held across all of them. Splitting related state across locks reintroduces the same class of bug at the invariant level. 4. **Prefer higher-level constructs.** Confinement (don't share), immutability (`final` fields, value objects — safely published, no locks needed), `java.util.concurrent` collections and synchronizers, and atomics reduce the surface where hand-rolled locking can be inconsistent. 5. **Enforce via review + static analysis.** Code review checklists ('what guards this field?'), Error Prone/SpotBugs (`IS2_INCONSISTENT_SYNC`, inconsistent synchronization detectors), and architectural tests catch one-sided or split synchronization. 6. **Document the policy.** A short comment per shared field ('guarded by `stateLock`') turns tribal knowledge into an enforceable contract. ### The mental model A monitor is a **key to a specific safe**. To hand data from a writer to a reader, both must use the **same** safe: the writer locks it, puts the data in, unlocks (flush); the reader unlocks the *same* safe and takes it out (acquire). Two different safes with two different keys protect nothing in common — each thread is diligently locking, but they never meet. ### Terms defined - **Monitor / intrinsic lock:** the per-object lock `synchronized` uses; identity is the object reference. - **Happens-before:** the JMM ordering/visibility relation; here created only by same-monitor unlock→lock. - **Torn / partially-published read:** observing a half-updated value or an object whose fields aren't yet visible. - **@GuardedBy:** an annotation declaring which lock must be held to access a field, checkable by static analysis.
- What annotation and tooling make the 'one lock per field' policy machine-checkable, and how do they help?@GuardedBy("lockName") declares the guarding lock; Error Prone's GuardedBy checker and SpotBugs' inconsistent-synchronization detectors flag accesses that don't hold the named lock or that synchronize inconsistently, turning a documentation convention into an enforced invariant at build time.
- Why prefer a private final Object lock over synchronizing on this?A private final lock can't be reassigned (final) and isn't visible to external code (private), so no other code can lock on it and accidentally interfere or deadlock. Synchronizing on this lets any client that holds your object's reference participate in (or stall) your locking, an encapsulation and liveness hazard.
saying these in an interview costs you the question
- Believing any synchronized access is safe regardless of which lock
- Splitting reads and writes of one field across separate locks
- Locking on public/this or interned String/Integer objects
- Relying on tests to catch visibility bugs instead of reasoning from the JMM