Java / Advanced / Lesson 13
The Java Memory Model and shared state
Reason about visibility, atomicity, and ordering using documented happens-before relationships rather than timing, luck, or hardware folklore.
On this page
Lesson goals
- Identify a shared-state race and its invariant.
- Use synchronization or atomic operations appropriately.
- Explain publication, start, and join guarantees.
Separate visibility, atomicity, and ordering
Concurrent code needs more than a correct sequential calculation. Visibility concerns whether one thread can observe another's write; atomicity concerns whether an operation can be interleaved; ordering concerns which actions are constrained relative to others. count++ reads, adds, and writes, so it is not one atomic operation merely because count is an int. Two threads can read the same old value and lose an increment.
The Java Memory Model specifies allowed observations through happens-before relationships. It is not a claim that every source line executes exactly in written order on every processor. Use documented synchronization constructs instead of relying on a debugger run or a sleep that makes a race disappear. First state the invariant and identify every reader and writer of the shared state.
Protect an operation with one lock protocol
synchronized provides mutual exclusion on a monitor and a visibility relationship between an unlock and a subsequent lock of the same monitor. All accesses participating in an invariant must follow the same protocol. Locking a writer while a reader accesses the fields without appropriate coordination is not automatically correct. Multi-field invariants often need one critical section rather than independent protection for each field.
The example performs two thousand increments through synchronized methods and joins both workers before reading. The exact result is 2000. No sleep is required. The counter object is safely available before thread start, and each update uses its monitor. Keep critical sections short and avoid calling unknown or blocking collaborators while holding a lock unless the design explicitly requires it.
public class Main {
static final class Counter {
private int n;
synchronized void increment() { n++; }
synchronized int value() { return n; }
}
public static void main(String[] args) throws Exception {
Counter c=new Counter();
Runnable work=()->{ for(int i=0;i<1000;i++) c.increment(); };
Thread a=new Thread(work),b=new Thread(work);
a.start(); b.start(); a.join(); b.join();
System.out.println(c.value());
}
}
2000
Every increment holds the same monitor, and join waits for both workers. The synchronized accessor follows the same state protocol.
Use a compound atomic operation when it fits
AtomicInteger supplies operations such as incrementAndGet that combine the read-modify-write atomically. The example's workers add 500 and 700 increments, and the final result is 1200. Replacing incrementAndGet with separate get and set would reintroduce a race. An atomic field does not make a collection of several fields one atomic business state.
Compare-and-set can support optimistic state transitions, but retry functions must tolerate being evaluated more than once where the API permits retries. Do not put remote side effects inside an update function that may repeat. Choose synchronization when it states a multi-variable invariant more clearly; lock-free is not a synonym for simpler, faster, or universally correct.
public class Main {
public static void main(String[] args) throws Exception {
var count=new java.util.concurrent.atomic.AtomicInteger();
Thread a=new Thread(()->{for(int i=0;i<500;i++) count.incrementAndGet();});
Thread b=new Thread(()->{for(int i=0;i<700;i++) count.incrementAndGet();});
a.start(); b.start(); a.join(); b.join();
System.out.println(count.get());
}
}
1200
All 1200 increments are retained. The program prints only after both workers have terminated.
Publish completed state through a real relationship
Actions before starting a thread happen-before its actions, and a successful join observes the completed thread's actions under the specified rule. Executor submission and Future result retrieval also provide documented memory-consistency relationships. These contracts are stronger evidence than observing that a worker usually finishes before the next println.
volatile establishes visibility and ordering for its accesses, but volatile count++ remains a compound race. A volatile reference does not make the referenced object's fields or collection operations automatically thread-safe. Immutable state can simplify publication when fully constructed without leaking this. final-field semantics are useful, yet construction discipline and subsequent mutable collaborators still require review.
Use tests as evidence without claiming proof
A concurrency test should control relevant interleavings with latches, barriers, or other defined synchronization, while keeping an overall timeout. Repeating a test many times can expose a race but cannot prove the absence of every allowed bad execution. Do not assign a fabricated fixed output to a deliberately racy example. Explain the invalid interleaving directly.
Review correctness before throughput: identify ownership, publication, invariants, and failure cleanup. Then measure contention and resource cost under representative concurrency. If the simplest safe design confines mutable state to one thread or uses immutable messages, prefer that clarity. Virtual threads do not alter the memory model or repair a shared-state race; the same contracts apply regardless of thread implementation.
Common errors and fixes
Lost increments
- Why it happens
- A read-modify-write sequence was not atomic.
- How to fix it
- Use one lock protocol or a suitable atomic compound operation.
- Prevent it next time
- Review every shared access and test a controlled collision.
volatile did not protect a multi-step update
- Why it happens
- Visibility was mistaken for compound atomicity.
- How to fix it
- Protect the complete invariant rather than only one field access.
- Prevent it next time
- Separate visibility, ordering, and atomicity in design reviews.
A reader sees partially initialized state
- Why it happens
- The object escaped before construction or publication was safely completed.
- How to fix it
- Finish construction and publish through a documented safe boundary.
- Prevent it next time
- Avoid leaking this from constructors.
Check your understanding
1. Two threads read count=0 before each writes count+1. What final value is possible?
Show worked solution
Both may write 1, losing an increment. The logical result should be 2, but the unsynchronized operation is not atomic. Use incrementAndGet or a common lock; adding volatile alone does not combine the operations.
2. A balance and version must change together. Are two independent AtomicIntegers sufficient?
Show worked solution
Not by themselves. Another thread can observe one updated field and one old field. Protect the pair with one synchronization protocol or atomically replace one immutable state object representing both fields.
Key takeaways
- Happens-before is a contract, not a timing guess.
- Atomic fields do not automatically protect multi-field invariants.
- Confinement and immutability can simplify concurrency.