Learn Java Concurrency Correctness Part 006 Java Memory Model Foundation
title: Learn Java Concurrency & Correctness - Part 006 description: Java Memory Model foundation: visibility, ordering, happens-before, synchronization actions, correctly synchronized programs, and practical design rules. series: learn-java-concurrency-correctness seriesTitle: Learn Java Concurrency & Correctness order: 6 partTitle: Java Memory Model Foundation tags:
- java
- concurrency
- correctness
- java-memory-model
- happens-before
- visibility
- ordering date: 2026-06-28
Part 006 — Java Memory Model Foundation
The Java Memory Model, usually abbreviated as JMM, is the specification that defines which writes a read is allowed to observe in a multithreaded Java program.
That sounds abstract, but it answers concrete production questions:
- If one thread sets a stop flag, will another thread see it?
- If one thread initializes an object and publishes it, can another thread see partially initialized fields?
- If a field is written before another field, can a reader observe them in the opposite order?
- What does
synchronizedactually guarantee beyond mutual exclusion? - Why does
volatilefix some bugs but not others? - Why can a program look correct in tests but fail under load?
This part builds the memory model required for the next parts: volatile, final fields, safe publication, locks, atomics, executors, virtual threads, and reactive boundaries.
1. Kaufman Skill Deconstruction
To become effective with the JMM, do not try to memorize every formal rule first. Deconstruct the skill into operational subskills:
- Recognize shared mutable variables.
- Distinguish atomicity, visibility, and ordering.
- Identify synchronization actions.
- Draw happens-before relationships.
- Check whether every conflicting access is ordered.
- Reason about safe publication.
- Avoid relying on timing, sleep, logging, or CPU behavior.
- Design classes so their invariants do not require heroic memory-model reasoning.
The goal is not to become a language-lawyer. The goal is to become reliable under production concurrency.
2. Why Java Needs a Memory Model
A single-threaded program can mostly be understood as if statements execute in program order.
A multithreaded program cannot be understood that way unless the program is correctly synchronized.
Modern execution includes:
- Compiler transformations.
- JIT optimization.
- CPU cache hierarchy.
- Store buffers.
- Instruction reordering.
- Register allocation.
- Speculative execution.
- Multiple cores observing memory at different times.
The JMM defines the legal outcomes for Java programs despite those optimizations.
Without a memory model, every JVM/CPU combination could expose different behavior. With a memory model, Java can allow aggressive optimization while defining correctness rules for concurrent programs.
3. The Three Questions
When analyzing shared memory, ask three separate questions.
3.1 Atomicity
Is the operation indivisible?
count++;
No. It is read-modify-write.
3.2 Visibility
If one thread writes, is another thread guaranteed to see it?
stopped = true;
Not necessarily, unless there is a happens-before relationship.
3.3 Ordering
If one thread performs operation A before operation B, must another thread observe A before B?
payload = loadPayload();
ready = true;
Not necessarily, unless ready is volatile, the accesses are lock-protected, or another synchronization relationship exists.
These are distinct. volatile can help visibility and ordering for a variable access, but it does not make compound actions atomic.
4. Sequential Consistency: The Model Engineers Wish They Had
Sequential consistency means the result of execution is as if:
- Operations of all threads were interleaved in one global order.
- Each thread's operations appeared in its own program order.
That is the intuitive model most engineers assume.
But Java only gives this simple model for programs that are correctly synchronized.
A key JMM principle:
If a program has no data races, its executions appear sequentially consistent.
This is why avoiding data races is not academic. Data-race-free programs are much easier to reason about.
5. Program Order Is Not Enough
Inside one thread, the code has program order.
x = 1;
y = 2;
Thread A executes x = 1 before y = 2 in program order.
But program order is not automatically a visibility guarantee to Thread B.
final class Pair {
int x;
int y;
}
Pair pair = new Pair();
// Thread A
pair.x = 1;
pair.y = 2;
// Thread B
System.out.println(pair.y);
System.out.println(pair.x);
Without synchronization, Thread B is not guaranteed to observe a coherent story that matches Thread A's program order.
The JMM is about the relationship between actions across threads, not just order within one thread.
6. Happens-Before
The most practical JMM concept is happens-before.
If action A happens-before action B, then effects of A are visible to B, and A is ordered before B for memory-model purposes.
Think of happens-before as a visibility/ordering bridge.
No bridge, no reliable visibility.
7. Core Happens-Before Edges
You do not need every formal edge on day one, but you must internalize the common ones.
| Edge | Meaning |
|---|---|
| Program order | Within one thread, earlier actions happen-before later actions |
| Monitor unlock → subsequent monitor lock | Exiting a synchronized block happens-before another thread enters synchronized block on same monitor |
| Volatile write → subsequent volatile read | A write to a volatile field happens-before a later read of that same field |
| Thread start | Actions before Thread.start() happen-before actions in the started thread |
| Thread termination → join return | Actions in a thread happen-before another thread successfully returns from join() |
| Default initialization | Default writes to fields happen-before other actions |
| Transitivity | If A happens-before B and B happens-before C, then A happens-before C |
The engineering use of this table:
For every reader that must observe a writer, identify the happens-before path.
If you cannot identify the path, the design is relying on luck.
8. Example: Broken Stop Flag
final class Worker implements Runnable {
private boolean stopped;
void stop() {
stopped = true;
}
@Override
public void run() {
while (!stopped) {
doWork();
}
}
private void doWork() {
// work
}
}
One thread calls stop(). Another thread runs the loop.
There is no happens-before relationship between the write and the read.
Possible consequences:
- The worker sees the update late.
- The worker never sees the update.
- The JIT optimizes based on the assumption that
stoppedis not changed by the current thread.
Correct with volatile:
final class Worker implements Runnable {
private volatile boolean stopped;
void stop() {
stopped = true;
}
@Override
public void run() {
while (!stopped) {
doWork();
}
}
private void doWork() {
// work
}
}
Now the volatile write in stop() happens-before a subsequent volatile read in run() that observes it.
But volatile is appropriate here because the invariant is a single flag. It would not be enough for a complex multi-field transition.
9. Example: Ready Flag and Payload
Broken:
final class Holder {
private Payload payload;
private boolean ready;
void publish() {
payload = loadPayload();
ready = true;
}
Payload consumeIfReady() {
if (ready) {
return payload;
}
return null;
}
}
A reader might observe ready == true but not reliably observe the initialized payload.
Correct with volatile flag:
final class Holder {
private Payload payload;
private volatile boolean ready;
void publish() {
payload = loadPayload();
ready = true;
}
Payload consumeIfReady() {
if (ready) {
return payload;
}
return null;
}
}
Why this works:
- In Thread A,
payload = loadPayload()happens-beforeready = trueby program order. - The volatile write to
readyhappens-before Thread B's subsequent volatile read ofreadythat seestrue. - By transitivity, the payload write happens-before the payload read.
Diagram:
This is the classic release/acquire pattern using a volatile flag.
10. Locks Give Visibility, Not Just Mutual Exclusion
Many engineers understand locks as “only one thread at a time”. That is only half the story.
synchronized also creates happens-before edges.
final class LockedHolder {
private Payload payload;
private boolean ready;
synchronized void publish() {
payload = loadPayload();
ready = true;
}
synchronized Payload consumeIfReady() {
return ready ? payload : null;
}
}
When Thread A exits publish(), it unlocks the monitor. When Thread B enters consumeIfReady(), it locks the same monitor. The unlock happens-before the subsequent lock.
That means Thread B sees effects made visible by Thread A inside the synchronized region.
Important:
The same lock must protect both read and write paths.
This is broken:
final class HalfLockedHolder {
private Payload payload;
private boolean ready;
synchronized void publish() {
payload = loadPayload();
ready = true;
}
Payload consumeIfReady() {
return ready ? payload : null; // not synchronized
}
}
The writer uses a lock, but the reader does not participate in the same happens-before protocol.
11. Volatile Is Not a Lightweight Lock
volatile gives visibility and ordering around reads/writes of a variable. It does not provide mutual exclusion for compound actions.
Broken:
final class VolatileCounter {
private volatile int count;
void increment() {
count++;
}
int value() {
return count;
}
}
The read and write of count are individually volatile, but count++ is still:
volatile read
add
volatile write
Two threads can still lose updates.
Use volatile when:
- A single variable represents state.
- Writes do not depend on the current value, or there is only one writer.
- You need publication/visibility signaling.
- You do not need compound atomicity.
Use a lock/atomic/CAS/state machine when mutation depends on current state.
12. Correctly Synchronized Programs
A program is correctly synchronized when all conflicting accesses to shared variables are ordered by happens-before.
A conflicting access means:
- Two accesses to the same variable.
- At least one is a write.
- They are from different threads.
If all such conflicts are ordered, the program is data-race-free.
A practical review process:
For every mutable shared field:
list all writes
list all reads
for each read/write pair across threads:
identify happens-before edge
if none exists:
data race
This is tedious at first, but it becomes a design habit.
13. Data-Race-Free Does Not Mean Business-Correct
A data-race-free program avoids undefined memory visibility behavior. It can still be logically wrong.
Example:
final class BookingService {
private final AtomicInteger remaining = new AtomicInteger(1);
boolean tryBook() {
if (remaining.get() <= 0) {
return false;
}
remaining.decrementAndGet();
return true;
}
}
The atomic operations are individually race-free, but the business operation is not atomic.
Correct:
final class BookingService {
private final AtomicInteger remaining = new AtomicInteger(1);
boolean tryBook() {
while (true) {
int current = remaining.get();
if (current <= 0) {
return false;
}
if (remaining.compareAndSet(current, current - 1)) {
return true;
}
}
}
}
or with a lock:
final class BookingService {
private int remaining = 1;
synchronized boolean tryBook() {
if (remaining <= 0) {
return false;
}
remaining--;
return true;
}
}
The JMM tells you which writes are visible. It does not automatically validate your domain invariant.
14. Reordering: The Bug You Cannot See Locally
Consider:
int x = 0;
boolean ready = false;
// Thread A
x = 42;
ready = true;
// Thread B
if (ready) {
assert x == 42;
}
Without synchronization, the assertion is not guaranteed.
The issue is not only literal CPU reordering. The JMM allows the runtime to produce results consistent with legal executions where Thread B does not get the visibility/order guarantee you intended.
Correct approaches:
volatile boolean ready;
or:
synchronized (lock) {
x = 42;
ready = true;
}
and corresponding synchronized read.
Do not reason from the source-code order alone. Reason from happens-before.
15. Thread Start and Join
15.1 Start Edge
Actions before starting a thread happen-before actions inside the started thread.
var config = new Config("v1");
var worker = new Thread(() -> use(config));
worker.start();
The writes that prepared config before start() are visible to the new thread, assuming config is not mutated unsafely afterward.
15.2 Join Edge
Actions performed by a thread happen-before another thread returns from join() on that thread.
var result = new ResultBox();
var thread = new Thread(() -> result.value = compute());
thread.start();
thread.join();
System.out.println(result.value);
The write in the worker is visible after successful join().
This is why test code that starts threads and joins them can read final results safely, even if the internal operation itself may have been racy.
16. Default Initialization
Java guarantees default initialization of fields before other actions.
For example, object fields start as 0, false, or null before normal writes occur.
However, default initialization is not a substitute for safe publication. A thread may see default values for fields if an object is improperly published before construction/initialization effects are visible.
This is one reason constructor escape is dangerous.
17. Final Fields and Initialization Safety
final fields have special JMM semantics. Properly constructed objects with final fields provide stronger initialization safety guarantees.
Example:
record Rule(String id, String expression) {}
Records are naturally useful for immutable data carriers because their components are final.
But final-field safety has boundaries:
- Do not let
thisescape during construction. - Final reference does not make the referenced object deeply immutable.
- Mutable objects stored in final fields still need defensive copies or confinement.
Example:
final class RuleSet {
private final List<Rule> rules;
RuleSet(List<Rule> rules) {
this.rules = List.copyOf(rules);
}
List<Rule> rules() {
return rules;
}
}
This combines final-field initialization safety with immutable collection publication.
Final fields are important enough that the next part will treat volatile, final, and safe publication in more detail.
18. Happens-Before Transitivity
Transitivity is how simple synchronization edges compose into real systems.
Example:
payload = loadPayload(); // A
ready = true; // B, volatile write
if (ready) { // C, volatile read
consume(payload); // D
}
Relationships:
A happens-before B // program order
B happens-before C // volatile write/read
C happens-before D // program order
therefore A happens-before D
Mermaid view:
This is the core reasoning move of the JMM.
19. Publication Patterns
Publication means making an object reachable by another thread.
Common publication channels:
- Assigning to a shared field.
- Putting into a concurrent collection.
- Returning from a synchronized method.
- Starting a thread with captured state.
- Completing a
Future/CompletableFuture. - Sending through a blocking queue.
- Emitting through a reactive publisher.
The JMM question:
Does the publication channel create a happens-before relationship from the writer's initialization to the reader's consumption?
Examples:
19.1 Unsafe Publication
class Registry {
static RuleSet current;
static void reload() {
current = new RuleSet(loadRules());
}
}
The reference assignment is not enough unless access is otherwise safely published.
19.2 Volatile Publication
class Registry {
static volatile RuleSet current;
static void reload() {
current = new RuleSet(loadRules());
}
}
A volatile write publishes the new immutable snapshot to volatile readers.
19.3 Lock Publication
class Registry {
private final Object lock = new Object();
private RuleSet current;
void reload() {
synchronized (lock) {
current = new RuleSet(loadRules());
}
}
RuleSet current() {
synchronized (lock) {
return current;
}
}
}
Unlock/lock on the same monitor establishes visibility.
20. The JMM and Virtual Threads
Virtual threads change the cost model of concurrency. They do not change the Java Memory Model.
A virtual thread is still a Thread from the perspective of Java code. Shared mutable state accessed by virtual threads must obey the same synchronization rules.
This means:
volatilemeans the same thing.synchronizedmeans the same thing.- Data races are still data races.
- Safe publication is still required.
- Thread confinement is less reliable if state is handed across many virtual-thread tasks.
Virtual threads make it easier to write thread-per-task code, but they do not make unsynchronized shared state safe.
21. The JMM and Async/Reactive Code
Async code often hides threads behind callbacks, futures, schedulers, event loops, or publishers.
The memory-model question remains:
What guarantees that the producer's write is visible to the consumer's callback?
Libraries such as CompletableFuture, blocking queues, executors, and reactive implementations provide their own synchronization internally. But your own shared mutable objects passed through them still need correct ownership.
Bad:
var request = new MutableRequest();
future.thenRun(() -> service.handle(request));
request.setTenantId("changed-after-registration");
The issue is ownership, not just memory visibility. Once a mutable object is handed to an async boundary, continuing to mutate it is usually a design smell.
Prefer immutable messages:
record RequestMessage(String requestId, String tenantId, Payload payload) {}
22. Practical Happens-Before Audit
Use this audit when reviewing code.
22.1 Field Inventory
Class: RuleEngine
field mutable? shared? protection
config yes yes volatile immutable snapshot
metrics yes yes LongAdder
cache yes yes ConcurrentHashMap + immutable values
lastError yes yes synchronized accessor
22.2 Access Inventory
field: config
writes:
reload()
reads:
decide()
happens-before:
volatile write in reload -> volatile read in decide
invariant:
config object must be immutable after publication
22.3 Red Flags
mutable field + no volatile/lock/atomic/confinement
synchronized writer + unsynchronized reader
volatile reference to mutable object
atomic fields protecting multi-field invariant
this escape during construction
shared collection with mutable values
ThreadLocal value not removed in pooled threads
async callback closes over mutable object
23. Common Misconceptions
23.1 “The CPU Is Strong Enough, So It Is Fine”
Java code must be correct under the Java Memory Model, not only under one CPU architecture observed today.
23.2 “Sleep Fixes Visibility”
Thread.sleep(100);
Sleep does not create a happens-before edge for your shared state.
23.3 “Logging Fixes It”
Logging may accidentally add synchronization or alter timing. That does not make your program correct.
23.4 “It Is Safe Because the Reference Is Final”
A final reference prevents reassignment of the reference. It does not make the object graph immutable.
private final List<String> ids = new ArrayList<>();
The reference is final. The list is mutable.
23.5 “Only Writes Need Synchronization”
Readers must participate in the same visibility protocol. A synchronized write with an unsynchronized read is not enough.
24. Design Rules You Can Apply Immediately
- Prefer immutable objects for cross-thread communication.
- Avoid publishing mutable objects before construction is complete.
- Do not expose internal mutable collections.
- Use one synchronization strategy per invariant.
- Protect both reads and writes.
- Use
volatilefor simple state publication, not compound state transitions. - Use locks for multi-field invariants unless you intentionally encode the whole state in one atomic reference.
- Treat async boundaries as ownership boundaries.
- Do not rely on
sleep, logging, tests, or observed timing. - Draw happens-before paths for non-trivial concurrent code.
25. Practice Drills
Drill 1 — Draw the Happens-Before Graph
Given:
class Holder {
int x;
volatile boolean ready;
void write() {
x = 10;
ready = true;
}
int read() {
return ready ? x : -1;
}
}
Draw the happens-before chain from x = 10 to return x.
Drill 2 — Find the Missing Edge
Given:
class Holder {
int x;
boolean ready;
synchronized void write() {
x = 10;
ready = true;
}
int read() {
return ready ? x : -1;
}
}
Explain why this is still broken.
Drill 3 — Safe Publication Review
Take one cached object in your codebase. Answer:
- Where is it created?
- Where is it published?
- Is the reference published through volatile, lock, concurrent collection, future completion, queue, or another mechanism?
- Is the object immutable after publication?
- Can any caller mutate its internals?
Drill 4 — Replace Sleep
Find any code that uses Thread.sleep() to wait for a state change. Replace it with one of:
join().CountDownLatch.Future.get().CompletableFuturecompletion.- Lock/condition.
- Polling with explicit volatile deadline if appropriate.
Explain the happens-before edge created by the replacement.
26. Key Takeaways
- The Java Memory Model defines which writes a read may observe.
- Program order inside one thread is not enough for cross-thread visibility.
- Happens-before is the main practical reasoning tool.
- Locks provide both mutual exclusion and visibility.
- Volatile provides visibility/ordering for a variable, not compound atomicity.
- Data-race-free programs are much easier to reason about.
- Business correctness still requires invariant-level thinking beyond the JMM.
- Virtual threads and async programming do not remove memory-model obligations.
- Safe publication requires both a publication mechanism and an immutable/constrained object graph.
- If you cannot draw the happens-before path, the design is suspect.
References
- Java Language Specification, Chapter 17: Threads and Locks — https://docs.oracle.com/javase/specs/jls/se8/html/jls-17.html
- Java SE 25
java.lang.ThreadAPI — https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/lang/Thread.html - Java SE 25
java.util.concurrentpackage — https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/concurrent/package-summary.html - JEP 444: Virtual Threads — https://openjdk.org/jeps/444
You just completed lesson 06 in start here. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.
Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.