DEV Community

Cover image for Java Memory Model: How Multithreading Changes the Rules
AnkitDevCode
AnkitDevCode

Posted on

Java Memory Model: How Multithreading Changes the Rules

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++;
Enter fullscreen mode Exit fullscreen mode

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 the volatile keyword 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-volatile long and double reads/writes are not guaranteed to be atomic on all platforms.

How to achieve atomicity

  • Use synchronized or ReentrantLock to 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 setting flag = 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 synchronized or 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++;
Enter fullscreen mode Exit fullscreen mode

Atomicity vs. visibility (and ordering)

Different concurrency mechanisms solve different problems.

volatile

volatile primarily provides:

  • Visibility — a read sees the most recent volatile write (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:

  • AtomicInteger
  • AtomicLong
  • AtomicReference

provide atomic operations (for the operations they support), along with the necessary visibility and ordering guarantees.

Example:

AtomicInteger count = new AtomicInteger();

count.incrementAndGet(); // atomic
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

  1. 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)
    
  2. Volatile variable rule
    A write to a volatile field 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 ready happens-before reading ready. By transitivity, the earlier plain write (data = 42) is also guaranteed to be visible.

  3. 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;
        }
    }
    
  4. Thread start rule
    A call to thread.start() happens-before any action inside the started thread’s run().

    int data = 100;
    
    Thread t = new Thread(() -> {
        // Guaranteed to see data = 100 because start() happens-before run()
        System.out.println(data);
    });
    
    t.start();
    
  5. Thread join rule
    All actions inside a thread happen-before another thread successfully returns from join() 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
Enter fullscreen mode Exit fullscreen mode

5. Do I need visibility?

Consider:

volatile
synchronized
Lock
Enter fullscreen mode Exit fullscreen mode

6. Do multiple operations need to happen together?

Consider:

synchronized
Lock
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Understanding the JMM makes these APIs much easier to reason about.

Instead of memorizing:

"Use volatile here."

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)