Threads enable multiple tasks to make progress concurrently. However, concurrency raises a fundamental question:
When multiple threads access the same data, how do we know what each thread will see?
Consider this simple code:
int count = 0;
Thread 1 → count++;
Thread 2 → count++;
It looks straightforward, but once multiple threads are involved, things are no longer as simple as they appear.
To understand why, we need to understand the Java Memory Model (JMM).
What is the Java Memory Model?
The Java Memory Model is the set of rules that governs how threads read and write shared memory in a multi-threaded application.
It provides definitive answers to core concurrency questions:
- Visibility: When does a change made by one thread become visible to another?
- Ordering: Can the compiler or CPU reorder operations?
-
volatile: What guarantees does thevolatilekeyword actually provide? -
synchronized: Why does locking a section of code flush memory updates? - Happens-before: What is the underlying contract that guarantees thread safety?
- Silent failures: Why can code that looks correct in single-threaded tests break under real concurrency?
Key insight
The JMM is not a description of physical RAM. It is a formal specification that defines the rules of visibility, ordering, and atomicity across JVM threads, CPU caches, and hardware.
Without the JMM, modern CPU optimizations—such as caching, store buffers, and instruction reordering—would make multi-threaded program behavior effectively unpredictable.
Three core concepts
When discussing the Java Memory Model, three concepts are particularly important:
- Atomicity
- Visibility
- Ordering
Understanding these explains a large part of Java concurrency.
1) Atomicity
An operation is atomic if it executes as a single, indivisible unit of work. It either happens completely, or it does not happen at all. No other thread can observe the operation in a partially completed state.
The problem
In Java, operations that look simple on the surface are often broken down into multiple CPU instructions. For example, count++ requires three steps:
- read
- modify
- write
If two threads execute this sequence simultaneously, their steps can interleave, causing lost updates.
Java guarantees
- Reads and writes for reference variables and most primitive variables are naturally atomic.
- A notable exception: non-
volatilelonganddoublereads/writes are not guaranteed to be atomic on all platforms.
How to achieve atomicity
- Use
synchronizedorReentrantLockto prevent concurrent entry into a critical section. - Use atomic classes in
java.util.concurrent.atomic(e.g.,AtomicInteger,AtomicLong), which rely on hardware-level compare-and-swap (CAS).
2) Visibility
Visibility determines when a write made by one thread becomes visible to reads made by other threads.
The problem
Modern CPUs use high-speed caches (L1/L2/L3) and store buffers to maximize execution speed. If Thread A updates a variable, that update may remain in one core’s local cache and not be immediately flushed to main memory. If Thread B running on another core reads the same variable, it may see stale data.
Common symptom
- Threads running forever in
while (!flag) {}loops because they never observe another thread settingflag = true.
How to achieve visibility
- Mark a variable as
volatile: forces volatile writes to be made visible and volatile reads to observe the latest write according to the JMM. - Use
synchronizedor locks: entering/exiting a critical section creates the required memory synchronization effects.
This is where happens-before enters the picture. A simple way to interpret it is:
If action A happens-before action B, then B is guaranteed to observe the effects of A, and A is ordered before B according to the Java Memory Model.
Important: volatile is not atomic
volatile provides visibility and ordering guarantees, but it does not make compound operations atomic.
For example, this is still not an atomic increment:
volatile int count;
count++;
Atomicity vs. visibility (and ordering)
Different concurrency mechanisms solve different problems.
volatile
volatile primarily provides:
-
Visibility — a read sees the most recent
volatilewrite (as defined by the JMM). - Ordering — it establishes required ordering constraints around volatile accesses.
However, volatile does not make compound operations atomic (such as count++).
Atomic classes
Classes such as:
AtomicIntegerAtomicLongAtomicReference
provide atomic operations (for the operations they support), along with the necessary visibility and ordering guarantees.
Example:
AtomicInteger count = new AtomicInteger();
count.incrementAndGet(); // atomic
Note: atomic classes do not automatically make an arbitrary sequence of multiple operations atomic.
synchronized
synchronized provides:
- Mutual exclusion — only one thread at a time can execute a critical section guarded by the same monitor.
- Visibility — changes made before releasing the monitor become visible to a thread that subsequently acquires the same monitor.
- Ordering — synchronization establishes happens-before relationships defined by the JMM.
Example:
class Counter {
private int count;
synchronized void increment() {
count++;
}
synchronized int getCount() {
return count;
}
}
Here, count does not need to be volatile because both reads and writes are protected by the same monitor.
3) Ordering
Modern CPUs and compilers aggressively optimize code. The instructions we write are not always executed in the intuitive, line-by-line order. Ordering guarantees ensure that execution steps occur in a predictable, non-corrupting sequence across threads.
The problem
To maximize pipeline efficiency, the JIT compiler and CPU may reorder instructions. Reordering preserves the intended results of single-threaded code (the “as-if-serial” rule), but it can break multi-threaded logic when other threads can observe the reordering.
For example:
a = 1;
b = 2;
The compiler or hardware may reorder these operations when it is safe from a single-thread perspective, but with multiple threads the reordering can become observable. This is one reason the JMM defines rules around ordering between actions performed by different threads.
Happens-before
Happens-before defines when the result of one action is guaranteed to be visible to another action.
It does not necessarily mean “this physically happened earlier in time.” Instead, it means there is a defined ordering relationship.
Example:
Thread 1:
write data
release lock
Thread 2:
acquire same lock
read data
The JMM establishes a happens-before relationship between the unlock and a subsequent lock on the same monitor. Therefore, Thread 2 can reliably observe the relevant writes made before the unlock.
Common happens-before rules
-
Program order rule
Within a single thread, each action happens-before every action that comes later in the program.
// Executed by a single thread: int x = 10; // Action A int y = 20; // Action B (A happens-before B) -
Volatile variable rule
A write to avolatilefield happens-before every subsequent read of that same field.
class VolatileExample { private int data = 0; private volatile boolean ready = false; // Thread 1 public void write() { data = 42; // 1. Plain write ready = true; // 2. Volatile write (release) } // Thread 2 public void read() { if (ready) { // 3. Volatile read (acquire) System.out.println(data); // 4. Guaranteed to print 42 } } }Why it works: writing to
readyhappens-before readingready. By transitivity, the earlier plain write (data = 42) is also guaranteed to be visible. -
Monitor lock rule (
synchronized)
An unlock on a monitor (exiting a synchronized block/method) happens-before every subsequent lock on the same monitor.
class SynchronizedExample { private int count = 0; // Thread 1 public synchronized void increment() { count++; } // Thread 2 public synchronized int getCount() { return count; } } -
Thread start rule
A call tothread.start()happens-before any action inside the started thread’srun().
int data = 100; Thread t = new Thread(() -> { // Guaranteed to see data = 100 because start() happens-before run() System.out.println(data); }); t.start(); -
Thread join rule
All actions inside a thread happen-before another thread successfully returns fromjoin()on that thread.
Thread t = new Thread(() -> { result = 500; }); t.start(); t.join(); // wait for completion // Guaranteed to see result = 500 because completion happens-before join returns System.out.println(result);
A Practical Rule of Thumb
When writing concurrent Java code, ask yourself:
1. Is this data shared?
If not, concurrency is usually much easier.
2. Is the shared data mutable?
Immutable data is much easier to share safely.
3. Can multiple threads modify it?
If yes, you probably need a concurrency strategy.
4. Do I need atomicity?
Consider:
AtomicInteger
AtomicLong
AtomicReference
5. Do I need visibility?
Consider:
volatile
synchronized
Lock
6. Do multiple operations need to happen together?
Consider:
synchronized
Lock
7. Can I avoid shared mutable state altogether?
Often, that’s the best solution.
The Bigger Picture
The Java Memory Model is the foundation behind many of Java’s concurrency tools.
Java Memory Model
│
┌─────────────┼─────────────┐
↓ ↓ ↓
Visibility Ordering Atomicity
│ │ │
└─────────────┼─────────────┘
↓
Concurrency APIs
│
┌─────────────┼─────────────┐
↓ ↓ ↓
synchronized volatile Atomics
│
↓
Locks
│
↓
Executors
│
↓
CompletableFuture
│
↓
Virtual Threads
Understanding the JMM makes these APIs much easier to reason about.
Instead of memorizing:
"Use
volatilehere."
you can ask:
What guarantee do I actually need?
Final Takeaway
Threads give Java the ability to execute multiple tasks concurrently.
But once multiple threads share memory, a new problem appears:
How do we make sure threads see and update shared data correctly?
That’s the problem the Java Memory Model helps define.
Top comments (0)