Back to all articles
ARTICLE

Java Memory Model (JMM): From Fundamentals to JVM Internals

Visibility, happens-before, safe publication and JVM internals, explained with practical Java examples and interactive simulations.

30 min read
-
Java Memory Model illustration showing Thread A, shared main memory and Thread B, with visibility, ordering, atomicity and happens-before.

From fundamentals to JVM internals

Advanced Java • Multithreading • CPU Architecture • Concurrency • Production Engineering

The Java Memory Model is one of the most important concepts to master as a Senior Java Engineer, particularly when working with concurrent applications, Spring Boot services, thread pools, shared caches, and lock-free algorithms.

By the end of this guide, you should understand not only what volatile, synchronized, and AtomicInteger do, but why they are necessary, what guarantees they provide, and how the JVM enforces those guarantees.

1. What exactly is the Java Memory Model?

The Java Memory Model (JMM) is a formal specification that defines which values threads are allowed to observe when reading and writing shared memory.

It defines rules for:

  • Visibility: When one thread updates a variable, what is another thread allowed to see?
  • Ordering: Can operations appear to execute in a different order from the source code?
  • Atomicity: Which operations are indivisible, and which can interleave?
  • Happens-before: What relationships establish visibility and ordering guarantees?
  • Synchronization: How do Java concurrency constructs establish these guarantees?

The specification is defined primarily in Java Language Specification, Chapter 17.

Important distinction: JMM vs JVM memory areas

These are two different subjects that developers often confuse.

JVM Runtime Memory

Heap, metaspace and three separate thread stacks describe where runtime data lives.
JVM runtime memory

Where runtime data lives and how memory is organized.

Java Memory Model

Thread A writes and Thread B reads under visibility and ordering rules.
Java Memory Model

Which cross-thread observations and operation orders are legal.

The heap/stack model tells you where objects and frames conceptually exist. The JMM tells you whether another thread can safely observe values written to shared fields.

2. Why do we need a memory model?

Consider a simple Java program:

Java
class SharedState {
    int value = 0;
    boolean ready = false;
}

Two threads access the same object.

Thread A - Writer

Java
state.value = 42;
state.ready = true;

Thread B - Reader

Java
if (state.ready) {
    System.out.println(state.value);
}

You might expect that whenever Thread B observes ready == true, it must also observe value == 42.

That expectation is not guaranteed.

Without synchronization, the program has data races. The reader may observe ready == true while still observing value == 0.

Why?

Because the Java source code passes through multiple layers of execution:

  1. Java source code

    Program-order semantics

  2. javac → Bytecode

    Class files and bytecode instructions

  3. JVM interpreter / JIT compiler

    Optimization, instruction scheduling, memory barriers

  4. CPU execution

    Registers, store buffers, caches, memory ordering

  5. Observable result

    Constrained by JMM, not simply source-line order

Both compilers and processors may optimize execution. The JMM defines the legal observable outcomes of those optimizations.

A critical principle: The JMM is a contract, not a literal diagram of CPU caches or JVM implementation. A JMM-permitted outcome does not prove that a specific CPU cache or compiler optimization caused it.

3. Interactive animation: How a visibility bug happens

Let's simulate the writer and reader using a simplified conceptual model. Click through the steps to see how Thread B can observe inconsistent state.

INTERACTIVE / 01

Memory visibility simulator

Synchronized
THREAD A / WRITER
value = 0
ready = false
THREAD B / READER
observed value = 0
observed ready = false
Volatile release → acquire

Initial state

The ready field is declared volatile. Both threads start from the initial state.

volatile ready is the synchronization variable.
Step 1 / 5

The diagram illustrates legal observations, not literal per-thread copies of Java heap objects or a deterministic hardware execution trace.

4. JVM internals: What happens underneath?

A modern JVM executes Java threads on top of a system involving compiler optimizations, processor memory ordering, and cache coherence.

Simplified hardware architecture

Two CPU cores, each with registers, a store buffer and L1/L2 caches, connect to shared cache and main memory.
Simplified hardware architecture

4.1 CPU registers

A processor can hold values in registers, which are much faster to access than RAM.

From a programmer's perspective, consider:

Java
while (!stopped) {
    performWork();
}

If stopped is an ordinary shared field and this code is not correctly synchronized, the JIT may be permitted to avoid reloading it on every iteration. The program may continue looping after another thread writes stopped = true.

This is fundamentally about the absence of an ordering/visibility guarantee, not proof that a particular value physically remained in a CPU register.

4.2 CPU caches and cache coherence

Modern CPUs have several cache levels. Threads running on different cores can access the same memory location through different cache hierarchies.

Hardware cache-coherence protocols such as MESI coordinate cache-line ownership and visibility.

Typical MESI states:

StateMeaning
M - ModifiedCache line has been changed and owns its latest data
E - ExclusiveCache line is clean and held exclusively
S - SharedMultiple caches may hold this clean line
I - InvalidCache line must not be used

Cache coherence does not automatically make an incorrectly synchronized Java program correct.

Coherence and memory consistency solve different problems. Coherence concerns agreement on individual cache-line values; memory ordering also concerns relationships across operations and locations.

4.3 Store buffers

A CPU may queue writes in a store buffer before they become globally observable.

A core may then execute later loads while earlier stores are still buffered, subject to its architecture's ordering rules.

This helps explain an important Java concurrency phenomenon called Store Buffering.

Consider:

Java
// Initially x = 0, y = 0

// Thread 1
x = 1;
r1 = y;

// Thread 2
y = 1;
r2 = x;

Is this outcome possible?

Structure
r1 = 0
r2 = 0

Yes. For ordinary non-volatile fields, the JMM allows that result.

Each thread can read the other variable's initial value, even though both threads have written their own variable.

We'll explore this with an execution simulator shortly.

4.4 Compiler barriers and CPU barriers

When Java code uses synchronization, the JVM must preserve the corresponding JMM guarantees.

This may require:

  • Compiler-level constraints that prevent certain optimizations and reorderings.
  • CPU-level memory-ordering instructions or fences.
  • Atomic read-modify-write instructions for operations such as CAS.
  • Lock operations for mutual exclusion.

The actual machine instructions depend on the JVM, CPU architecture, and the context of the operation.

For example, x86-64 and ARM64 have different memory-ordering characteristics. A JMM guarantee may require different native instruction sequences on each architecture.

Importantly, volatile does not mean flush all CPU caches to RAM. That is a popular but misleading description.

5. Happens-before: The central rule of JMM

If you deeply understand happens-before, you understand the foundation of Java concurrency.

Happens-before is a formal relation between actions.

When action A happens-before action B, Java guarantees ordering and visibility properties that constrain what B can observe.

We write this as .

There are several important ways to establish this relationship.

5.1 Program order

Within one thread, earlier actions happen-before later actions in program order.

Java
int x = 10;    // A
int y = 20;    // B
int z = x + y; // C

In program order, .

This does not forbid every physical instruction reordering. An optimization is allowed if it preserves the program's required observable behavior.

Also, a program-order relationship within Thread A does not, by itself, establish cross-thread visibility to Thread B.

5.2 Monitor unlock → lock

Java
final Object lock = new Object();
int balance = 0;

// Thread A
synchronized (lock) {
    balance = 100;
}

// Thread B
synchronized (lock) {
    System.out.println(balance);
}

An unlock of a monitor happens-before a subsequent lock of that same monitor in synchronization order.

If Thread B acquires the monitor after Thread A releases it, and there are no other interfering writes, it sees 100.

5.3 Volatile write → read

Java
class State {
    int data;
    volatile boolean ready;
}
Java
// Thread A
state.data = 42;
state.ready = true;

// Thread B
if (state.ready) {
    System.out.println(state.data);
}
Thread Adata = 42ready = trueVolatile release
Thread BRead ready == trueVolatile acquireRead data == 42
Volatile write synchronizes-with the subsequent volatile read

Assumes the reader observes the published true value and there are no concurrent modifications to data.

By transitivity:

Therefore, .

This is why a volatile readiness flag can safely publish data written before it.

Nuance: A volatile write happens-before subsequent volatile reads of that same variable in synchronization order. This does not mean an arbitrary reader necessarily observes the writer's latest intended state regardless of timing or intervening writes.

5.4 Thread start and join

Java
class Example {
    public static void main(String[] args) throws InterruptedException {
        int[] data = {0};
        data[0] = 42;

        Thread worker = new Thread(() -> {
            System.out.println(data[0]); // 42
            data[0] = 100;
        });

        worker.start();
        worker.join();

        System.out.println(data[0]); // 100
    }
}

Why is this safe?

Thread.start() establishes a happens-before edge from prior actions of the starting thread to actions in the started thread.

Successful Thread.join() establishes a happens-before relationship from the worker's actions to the thread returning from join().

The array is shared, but this example has the necessary lifecycle-based synchronization.

These rules and additional relationships involving interruption are specified in JLS §17.4.

6. The three fundamental concurrency problems

Visibility

Does another thread observe a write?

Example: a worker does not notice that a shutdown flag changed.

Atomicity

Can another thread interfere between the steps of an operation?

Example: two threads increment the same counter and one update is lost.

Ordering

Which operations are permitted to appear in which order to other threads?

Example: a thread observes a readiness flag but not the associated data.

These properties are related, but they are not interchangeable.

7. Volatile internally: Visibility and ordering, not compound atomicity

First, a correct use case.

Example A: Gracefully stopping a background worker

Java
public final class BackgroundWorker {
    private volatile boolean running = true;

    public void start() {
        Thread.ofPlatform().start(() -> {
            while (running) {
                doWork();
            }

            System.out.println("Worker stopped");
        });
    }

    public void stop() {
        running = false;
    }

    private void doWork() {
        Thread.onSpinWait(); // Demo only
    }
}

Here volatile is appropriate because the variable represents a simple state flag and does not require a multi-step atomic update.

The reader performs volatile reads, and the writer performs a volatile write.

For a real blocking worker, use interruption and an appropriate cancellation protocol instead of a CPU-intensive spin loop.

Example B: Why volatile is not enough for a counter

Java
class Counter {
    volatile int count = 0;

    void increment() {
        count++;
    }
}

Many Java developers assume this is thread-safe because count is volatile.

It is not.

count++ is a read-modify-write sequence:

Java
int previous = count;
int updated = previous + 1;
count = updated;

Imagine that the initial count is 5.

INTERACTIVE / 02

Lost update simulator

Read → modify → write
SHARED COUNT5Expected final value after 2 increments: 7
THREAD ALocal calculated value-
THREAD BLocal calculated value-

Both threads are ready to increment a shared volatile int.

Step 1 / 5

Correct implementation:

Java
import java.util.concurrent.atomic.AtomicInteger;

class Counter {
    private final AtomicInteger count = new AtomicInteger();

    public int increment() {
        return count.incrementAndGet();
    }

    public int get() {
        return count.get();
    }
}

AtomicInteger.incrementAndGet() performs an atomic read-modify-write update, unlike a compound operation on a volatile field.

Internally: Compare-And-Set (CAS)

CAS is a primitive operation conceptually defined as:

Structure
CAS(address, expected, newValue)

if current_value == expected:
    current_value = newValue
    return success
else:
    return failure

A simplified CAS loop:

Java
int previous;
int next;

do {
    previous = counter.get();
    next = previous + 1;
} while (!counter.compareAndSet(previous, next));

If two threads both read 5, only one successful CAS can change 5 → 6. The other thread must retry using the updated value.

The JVM and platform cooperate to implement atomic updates, frequently using atomic CPU instructions rather than heavyweight mutual-exclusion locks.

8. Synchronized internally: Mutual exclusion + happens-before

Consider a bank account.

Java
public final class BankAccount {
    private long balance = 1_000;

    public synchronized boolean withdraw(long amount) {
        if (amount < 0) {
            throw new IllegalArgumentException();
        }

        if (balance < amount) {
            return false;
        }

        balance -= amount;
        return true;
    }

    public synchronized long getBalance() {
        return balance;
    }
}

The important guarantee isn't just that balance is visible.

The balance check and withdrawal occur in the same critical section.

Other threads cannot concurrently execute a synchronized method guarded by the same object's monitor.

How the JVM represents synchronized

A synchronized block:

Java
synchronized (lock) {
    balance -= amount;
}

Conceptually compiles to bytecode with monitor operations:

Structure
aload          // Load lock reference
monitorenter   // Enter monitor
...
monitorexit    // Exit monitor

The compiler also generates exception-handling logic so that the monitor is released if the block exits abnormally.

For a synchronized instance method, the method has the ACC_SYNCHRONIZED flag, rather than requiring an explicit monitorenter opcode in its body.

The JVM uses monitor synchronization semantics, but the implementation may optimize locking substantially. An uncontended lock need not always involve an operating-system mutex or kernel transition.

What synchronized guarantees

Propertysynchronized
VisibilityYes, through monitor synchronization
OrderingYes
Mutual exclusionYes, for the same monitor
Atomic compound operationsYes, if the full invariant is protected
Lock-free progressNo

A useful rule for production code: all accesses to a mutable state invariant should follow one consistent synchronization policy.

Synchronizing the writer while allowing unsynchronized readers is often still incorrect.

9. Reordering: Can Java execute lines out of order?

Yes, provided the execution remains consistent with the JMM.

Here's a classic example of two ordinary variables.

Java
static int x = 0;
static int y = 0;

static int r1;
static int r2;

Thread A:

Java
x = 1;
r1 = y;

Thread B:

Java
y = 1;
r2 = x;

With non-volatile variables and no synchronization, all these result pairs are possible under the JMM:

r1r2Allowed?
00Yes
01Yes
10Yes
11Yes

How can both reads return zero?

The JMM does not require independent, data-racy writes and reads in these threads to form a single sequentially consistent execution. Reordering effects and buffering can permit observations like (0, 0).

What changes with volatile?

Java
static volatile int x = 0;
static volatile int y = 0;

When all four accesses are volatile, (0, 0) is forbidden.

The volatile actions must be consistent with a global synchronization order that respects each thread's program order.

This example demonstrates the difference between ordinary fields and synchronization actions at the formal memory-model level.

10. Practical experiment: Reproducing a memory-ordering problem

You can run the following Java program to try to observe the store-buffering outcome.

Java
public class ReorderingExperiment {
    static int x, y;
    static int r1, r2;

    public static void main(String[] args) throws InterruptedException {
        for (int i = 1; i <= 100_000; i++) {
            x = y = r1 = r2 = 0;

            Thread t1 = new Thread(() -> {
                x = 1;
                r1 = y;
            });

            Thread t2 = new Thread(() -> {
                y = 1;
                r2 = x;
            });

            t1.start();
            t2.start();

            t1.join();
            t2.join();

            if (r1 == 0 && r2 == 0) {
                System.out.println("Observed (0, 0) at iteration " + i);
                break;
            }
        }
    }
}

Run it:

Terminal
javac ReorderingExperiment.java
java ReorderingExperiment

You might observe (0, 0), or you might not observe it at all.

This experiment is nondeterministic. Creating threads every iteration is expensive and affects scheduling, making the result harder to reproduce reliably.

The join() calls make it safe for the main thread to read the result variables after both workers finish. They do not establish the missing happens-before relationship between the workers' ordinary accesses.

For professional concurrency testing, use a specialized framework instead.

JCStress: The OpenJDK concurrency testing tool

JCStress is designed to explore concurrency outcomes through controlled stress testing.

Here is a litmus test for the same example:

Java
import org.openjdk.jcstress.annotations.*;
import org.openjdk.jcstress.infra.results.II_Result;

@JCStressTest
@Outcome(id = {"0, 1", "1, 0", "1, 1"}, expect = Expect.ACCEPTABLE,
         desc = "Ordinary interleavings")
@Outcome(id = "0, 0", expect = Expect.ACCEPTABLE_INTERESTING,
         desc = "Store-buffering outcome")
@State
public class StoreBufferingTest {
    int x;
    int y;

    @Actor
    public void actor1(II_Result result) {
        x = 1;
        result.r1 = y;
    }

    @Actor
    public void actor2(II_Result result) {
        y = 1;
        result.r2 = x;
    }
}

JCStress repeatedly exercises the actors and classifies observed outcomes.

Note that stress tests cannot prove an outcome impossible merely because it never appeared. Their value is in exploring permitted and forbidden concurrency behavior.

11. The final keyword and safe publication

The final keyword is more important to concurrency than it initially appears.

Consider:

Java
public final class UserProfile {
    private final String username;
    private final int age;

    public UserProfile(String username, int age) {
        this.username = username;
        this.age = age;
    }

    public String username() {
        return username;
    }

    public int age() {
        return age;
    }
}

The JMM gives special initialization-safety guarantees to final fields, provided the object is constructed correctly.

Specifically, the constructor must not allow the object reference to escape before construction is complete.

The JVM/JMM defines a freeze action at the end of construction that is relevant to final-field semantics.

This provides stronger guarantees than merely assigning values to ordinary mutable fields.

What does this escape mean?

A dangerous example:

Java
public class UnsafeService {
    static UnsafeService instance;

    private final int timeout;

    public UnsafeService() {
        instance = this; // Escapes too early
        timeout = 30;
    }
}

Another thread might read instance while construction is incomplete.

The special final-field construction guarantees do not apply normally to an object that improperly escapes during construction.

A safer pattern:

Java
public final class SafeService {
    private final int timeout;

    private SafeService(int timeout) {
        this.timeout = timeout;
    }

    public static SafeService create() {
        return new SafeService(30);
    }

    public int timeout() {
        return timeout;
    }
}

This avoids exposing this from the constructor. The resulting reference must still be shared using an appropriate publication strategy when other mutable state or ordering requirements are involved.

Another important distinction: final does not imply deep immutability.

Java
final List<String> names = new ArrayList<>();
names.add("Alice");

The reference cannot be reassigned, but the object it references can still be modified. A final reference to a mutable collection does not make concurrent collection operations safe.

12. Safe publication: How to transfer an object between threads

Suppose a thread creates a configuration object:

Java
record Configuration(String host, int port, boolean tls) {}

How should another thread receive it?

One practical approach is volatile publication.

Java
public final class ConfigManager {
    private volatile Configuration current = new Configuration("localhost", 8080, false);

    public void reload(Configuration next) {
        current = next;
    }

    public Configuration snapshot() {
        return current;
    }
}

This implementation has several strong properties:

  1. The configuration object is immutable.
  2. The volatile reference provides safe publication.
  3. Readers observe a complete configuration snapshot rather than separately reading mutable fields.
  4. Replacing the configuration does not require mutating the previous object.

A reader can capture a consistent snapshot:

Java
Configuration config = manager.snapshot();

String host = config.host();
int port = config.port();
boolean tls = config.tls();

This pattern is particularly useful for feature flags, routing configuration, pricing rules and in-memory configuration refresh.

It is safer than independently updating:

Java
volatile String host;
volatile int port;
volatile boolean tls;

Why? Because individually visible fields do not create an atomic multi-field snapshot.

13. Double-Checked Locking: Why volatile is essential

A classic interview question:

Java
public final class Singleton {
    private static volatile Singleton instance;

    private Singleton() {}

    public static Singleton getInstance() {
        Singleton local = instance;

        if (local == null) {
            synchronized (Singleton.class) {
                local = instance;

                if (local == null) {
                    local = new Singleton();
                    instance = local;
                }
            }
        }

        return local;
    }
}

This pattern is called Double-Checked Locking (DCL).

Why is volatile required?

Without it, a reference to an object might be published without establishing the required ordering and visibility guarantees for its initialized ordinary fields.

A conceptual view of the danger:

Structure
Thread A:
    Allocate object memory
    Initialize object
    Publish reference

Thread B:
    Observe reference != null
    Read object fields

The important question is whether publication happens-before the other thread's object reads.

Without correct synchronization, another thread may observe a non-null reference but not the constructor-initialized state in the way the programmer expects.

With a volatile publication field, the required ordering is provided.

A simpler solution

For a conventional lazy singleton, the initialization-on-demand holder idiom is usually preferable:

Java
public final class Singleton {
    private Singleton() {}

    private static class Holder {
        static final Singleton INSTANCE = new Singleton();
    }

    public static Singleton getInstance() {
        return Holder.INSTANCE;
    }
}

Class initialization is synchronized by the JVM according to its initialization rules.

This implementation avoids explicit volatile fields and double-checked locking logic.

14. VarHandle: Low-level memory access modes

For advanced concurrent algorithms, Java provides VarHandle, which offers more explicit control over memory access semantics.

Think of it as a tool for expressing different levels of memory ordering.

Access modeGeneral property
Plain get / setOrdinary field access
OpaqueAtomic access with limited same-variable coherence/ordering guarantees
Acquire readOrders subsequent accesses after the acquire
Release writeOrders earlier accesses before the release
VolatileStronger, globally ordered volatile semantics
CASAtomic conditional update with specified memory effects

These modes are documented in the Java VarHandle API and the OpenJDK implementation. See also the source article’s Java SE 16 VarHandle reference.

Practical example: Release/acquire publication

Java
import java.lang.invoke.MethodHandles;
import java.lang.invoke.VarHandle;

public final class ReleaseAcquireExample {
    private int data;
    private boolean ready;

    private static final VarHandle READY;

    static {
        try {
            READY = MethodHandles.lookup().findVarHandle(
                    ReleaseAcquireExample.class, "ready", boolean.class);
        } catch (ReflectiveOperationException e) {
            throw new ExceptionInInitializerError(e);
        }
    }

    public void publish() {
        data = 42;

        READY.setRelease(this, true);
    }

    public int consume() {
        if ((boolean) READY.getAcquire(this)) {
            return data;
        }

        return -1;
    }
}

When the consumer's acquire read observes the value from the corresponding release write, prior writes in the producer are ordered before subsequent reads in the consumer.

This is related to volatile publication but allows more specifically chosen memory-ordering semantics.

You generally should not use low-level VarHandles for ordinary business logic. They are better suited to libraries, synchronization primitives, queues, and carefully verified lock-free structures.

Internals: Memory fences

VarHandle also exposes methods such as:

Java
VarHandle.acquireFence();
VarHandle.releaseFence();
VarHandle.fullFence();

These constrain certain reorderings, but a fence by itself is not automatically a complete synchronization protocol.

A correct lock-free algorithm must establish how values are published, how readers observe them, and why the entire execution satisfies the required memory-ordering relationships.

15. Production example: A thread-safe balance transfer

Imagine a Spring Boot application that processes transfers between accounts.

A common mistake is to assume that using volatile balances guarantees correctness.

Java
class Account {
    volatile long balance;
}

This code is not sufficient for a transfer:

Java
if (source.balance >= amount) {
    source.balance -= amount;
    destination.balance += amount;
}

Several threads can pass the balance check simultaneously, and the read-modify-write operations are not atomic.

We need mutual exclusion around the entire invariant.

Correct in-process locking example

Java
public final class Account {
    private final long id;
    private long balance;

    public Account(long id, long initialBalance) {
        this.id = id;
        this.balance = initialBalance;
    }

    public synchronized long getBalance() {
        return balance;
    }

    public static void transfer(Account from, Account to, long amount) {
        if (amount <= 0) {
            throw new IllegalArgumentException("Amount must be positive");
        }

        if (from == to) {
            return;
        }

        Account first = from.id < to.id ? from : to;
        Account second = from.id < to.id ? to : from;

        if (first.id == second.id) {
            throw new IllegalArgumentException("Duplicate account IDs");
        }

        synchronized (first) {
            synchronized (second) {
                if (from.balance < amount) {
                    throw new IllegalStateException("Insufficient funds");
                }

                from.balance = Math.subtractExact(from.balance, amount);
                to.balance = Math.addExact(to.balance, amount);
            }
        }
    }
}

What makes this implementation interesting?

The locks protect the complete transfer, not just individual reads or writes. Both accounts are locked in a consistent ID order to prevent lock-order inversion and deadlock.

The implementation assumes account IDs are unique and that balances are modified only through the same locking discipline.

This approach works for shared objects inside one JVM process. It does not provide distributed consistency across multiple Spring Boot instances. For persisted money transfers, the database transaction and locking/isolation strategy must enforce the invariant.

16. Common misconceptions about the JMM

MisconceptionCorrect understanding
volatile makes every operation atomicCompound updates still require atomic operations or locks
volatile immediately flushes data to RAMJMM specifies ordering and visibility, not literal RAM flushes
synchronized only prevents simultaneous executionIt also creates happens-before guarantees
CPU cache coherence makes shared Java fields safeCoherence alone is not a Java synchronization protocol
final makes an object immutableFinal fields may reference mutable objects
A concurrent collection makes every multi-step workflow atomicIndividual thread-safe operations do not automatically form one atomic transaction
Thread.sleep() guarantees another thread's writes become visibleSleeping is not a synchronization relationship
Virtual threads eliminate data racesVirtual and platform threads follow the same JMM rules

The last point is especially important with modern Java: virtual threads change the cost and scalability characteristics of concurrency, not the fundamental shared-memory correctness rules.

17. Choosing the right concurrency primitive

RequirementRecommended starting point
Shared shutdown flagvolatile or interruption
Shared numeric counterAtomicInteger, AtomicLong
High-contention statistics counterLongAdder
Protect several related fieldssynchronized or ReentrantLock
Publish immutable configurationvolatile reference or AtomicReference
Shared key/value storageConcurrentHashMap
Producer/consumer handoffBlockingQueue
Independent asynchronous operationsCompletableFuture / executors
Explicit low-level orderingVarHandle
Cross-instance database consistencyDatabase transactions, locks, constraints

These are starting points, not absolute rules. The right primitive depends on which invariant must hold and which consistency guarantees are required.

18. How to inspect concurrency behavior yourself

You can use JVM tooling to investigate how Java code is compiled.

For a synchronized method:

Java
public class LockExample {
    private int count;

    public synchronized void increment() {
        count++;
    }
}

Compile and inspect the bytecode:

Terminal
javac LockExample.java
javap -c -v -p LockExample

Look for the ACC_SYNCHRONIZED method flag.

For a synchronized block:

Java
public class BlockExample {
    private final Object lock = new Object();
    private int count;

    public void increment() {
        synchronized (lock) {
            count++;
        }
    }
}

Inspect it:

Terminal
javac BlockExample.java
javap -c -p BlockExample

You'll find monitorenter and monitorexit instructions.

One subtle but important detail: javap shows bytecode, not the final CPU instructions generated by the JIT. To inspect native code, you need additional JVM facilities such as assembly output with a compatible diagnostic/disassembler setup.

19. Senior-level mental model

When reviewing any multithreaded Java code, ask these five questions:

PUT IT INTO PRACTICE

Concurrency code-review checklist

0/5 checked

The formal principle behind this checklist is:

If a program is correctly synchronized and free of data races, its executions have sequentially consistent behavior. That does not automatically mean every multi-operation business transaction is atomic; those are separate requirements.

Further technical resources

Final summary

A strong understanding of the Java Memory Model comes down to four layers:

Java code - Fields, objects, locks, atomic operations, thread lifecycle.

JMM contract - Legal read values, happens-before, synchronization and ordering.

JVM and CPU - JIT optimizations, caches, store buffers, barriers and atomic instructions.

Correct concurrent behavior - Predictable visibility, safe publication and protected invariants.

The most valuable next advanced topic is Java Memory Model Internals: Store Buffers, CPU Cache Coherence, Memory Barriers, x86 vs ARM64, and the actual JIT-generated assembly for volatile/CAS operations. That connects the formal JMM directly to what happens inside HotSpot and the processor.