Threads, Processes, and the Java Memory Model

You are about to leave the comfortable single-threaded world where reasoning about code is straightforward. From here on, every line of Java you write can be read out of order, cached in an invisible register, or observed differently by two threads on two cores. This file is the physics floor. Nothing in the rest of Phase 05 makes sense without it.

We’ll start with the mechanics — what a thread is — and then move to the Java Memory Model (JMM), which is the specification for what threads are and aren’t allowed to see about each other’s writes.


1. Threads vs Processes (Fast Refresher)

A process is an OS-level container: its own virtual address space, its own file descriptors, its own PID. A thread is a schedulable unit inside a process — it shares the process’s heap, code, and open files with every other thread in that process.

Process

Thread

Address space

Isolated

Shared with siblings

Creation cost

Slow (fork, exec)

Fast (a few KB stack)

Communication

IPC, pipes, sockets

Shared memory (dangerous)

Crash blast radius

One process

Whole JVM

Context switch

Expensive (TLB flush)

Cheaper (same address space)

In Java, one java command = one JVM process. Inside that process, every new Thread() creates an OS-level thread (a “platform thread”). Virtual threads change this later — set them aside for now.

Numbers you should know:

  • A platform thread’s default stack is ~1 MB on 64-bit HotSpot. 10,000 platform threads = ~10 GB of stack reservation.

  • Context switch cost between threads: ~1-10 μs on modern Linux.

  • Virtual threads: default stack starts at ~1 KB, grows as needed.


2. The Java Memory Model in Five Sentences

  1. Each thread has its own working memory (registers, CPU caches). Main memory is shared.

  2. Without synchronization, the JVM and hardware are allowed to reorder reads and writes as long as the single-threaded result is preserved.

  3. A write by thread A is not guaranteed to be visible to thread B unless there is a happens-before relationship between them.

  4. synchronized, volatile, final, Thread.start(), Thread.join(), and java.util.concurrent primitives establish happens-before edges.

  5. Without a happens-before edge, “it worked on my machine” means nothing — a JIT recompilation, a different CPU, or a different load can silently break your code.

If you internalize only one thing from this file: absence of a happens-before edge is a bug, even if the code appears to work.


3. Happens-Before, Concretely

Happens-before is a partial ordering over memory actions. If action X happens-before action Y, then:

  • X’s effects are visible to Y.

  • The JVM cannot reorder X after Y (with respect to what Y observes).

The rules you actually use:

Edge

Establishes happens-before

Program order

Within a single thread, statement N happens-before statement N+1

Monitor lock

Unlock of monitor M happens-before every subsequent lock of M

volatile

Write of volatile V happens-before every subsequent read of V

Thread.start()

The start() call happens-before the first action of the started thread

Thread.join()

The last action of the joined thread happens-before join() returning

final fields

Constructor’s write of a final field happens-before any thread reading the reference after construction completes

Transitivity

If X → Y and Y → Z, then X → Z

The transitivity rule is what makes locking useful — it’s why publishing an object into a ConcurrentHashMap inside a locked section is safe for readers who use the same lock.


4. volatile — What It Actually Guarantees

volatile is the most misunderstood keyword in Java. Get this right and you’re ahead of most people who’ve been writing Java for a decade.

What volatile guarantees:

  1. Visibility. A write to a volatile field is immediately visible to any thread that subsequently reads it. No cache staleness.

  2. Ordering. Reads and writes to a volatile field are not reordered relative to each other or to non-volatile reads/writes surrounding them (roughly — the rules are stronger than that but this is enough for daily use).

  3. Atomicity of single reads/writes of 32-bit and reference values. Also of 64-bit long/double (which are NOT atomic without volatile on 32-bit JVMs).

What volatile does NOT guarantee:

  1. Atomicity of read-modify-write operations. counter++ is not atomic even if counter is volatile. It’s three operations: read, add, write.

  2. Compound invariants. If you have two volatile fields that should be updated together, volatile does nothing for you.

private volatile int counter;

public void increment() {
    counter++;  // BROKEN: three ops, two threads can lose an update
}

Use volatile when:

  • You have a flag toggled by one thread and read by others (volatile boolean stopped)

  • You are implementing double-checked locking (see below)

  • The field is written once by one thread and observed by others (publication)

Use AtomicInteger/AtomicReference when:

  • You need atomic read-modify-write (increment, compare-and-set)


5. Atomics — Compare-and-Set as a Primitive

The java.util.concurrent.atomic package gives you lock-free primitives backed by CPU-level compare-and-swap (CAS) instructions (LOCK CMPXCHG on x86).

Class

Use for

AtomicInteger / AtomicLong

Counters, sequence numbers

AtomicReference<T>

Publishing objects atomically

AtomicBoolean

Flags with atomic set-if-condition

AtomicIntegerArray

Elementwise atomic counters

LongAdder / LongAccumulator

High-contention counters (much faster than AtomicLong under contention)

private final AtomicInteger counter = new AtomicInteger();

public int incrementAndGet() {
    return counter.incrementAndGet();  // atomic RMW
}

// Classic CAS pattern:
public void updateMaxIfGreater(int candidate) {
    int current;
    do {
        current = counter.get();
        if (candidate <= current) return;
    } while (!counter.compareAndSet(current, candidate));
}

⚠️ When contention is high, prefer LongAdder over AtomicLong. AtomicLong uses a single CAS location — every thread spins on it. LongAdder shards internally and sums on read. Under 32-thread contention on a typical Xeon, LongAdder beats AtomicLong by 5-10×. Trade-off: LongAdder.sum() is a snapshot, not a strict atomic read.


6. Double-Checked Locking (and Why volatile Is Required)

This is a classic study problem. You want a lazy singleton. Naive version:

private static Singleton instance;

public static Singleton getInstance() {
    if (instance == null) {                // (1) not synchronized
        synchronized (Singleton.class) {
            if (instance == null) {        // (2) inside lock
                instance = new Singleton();
            }
        }
    }
    return instance;
}

This is broken pre-Java 5 and broken today if you omit volatile. Here’s why:

instance = new Singleton() is not one operation. It’s:

  1. Allocate memory.

  2. Write the reference to instance.

  3. Run the constructor.

Steps 2 and 3 can be reordered by the JIT. Thread A can observe a non-null instance at line (1) whose constructor has not run. Thread A dereferences it. Boom.

The fix: declare instance volatile. The volatile write of the reference establishes a happens-before edge with any subsequent volatile read, so the constructor’s writes are visible before the reference is.

private static volatile Singleton instance;

Better still: don’t write this at all. Use the initialization-on-demand holder idiom (which relies on JLS class-init guarantees, not JMM edges):

public class Singleton {
    private Singleton() {}

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

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

The JVM guarantees class initialization is thread-safe and lazy. No volatile, no locks, correct.


7. False Sharing — When Two Independent Variables Kill Each Other

Modern CPUs move data between memory and cache in cache line granularity — typically 64 bytes. If thread A writes to variable x and thread B writes to variable y, and x and y happen to sit in the same cache line, every write by A invalidates B’s cached copy, and vice versa. Your independent-looking counters fight each other over the memory bus.

Symptom: two threads each spinning on their own AtomicLong, expected 2× throughput, observed less than 1×.

Detection:

  • Async Profiler with -e cache-misses (covered in Phase 06)

  • Suspiciously flat scaling on multi-core

Mitigations:

  • Java 8+: @sun.misc.Contended annotation (needs -XX:-RestrictContended before Java 17; use jdk.internal.vm.annotation.Contended in modern JDK with --add-exports). Pads the field to its own cache line.

  • Manual padding: pad with 7 longs between the field and the next one.

  • LongAdder already handles this internally.

// Under the hood, LongAdder's Cell is @Contended.
// You almost never need to hand-pad in application code — but you should
// recognize the symptom when you profile.

8. The Rules You’ll Actually Use Day-to-Day

  • Read/write one flag from multiple threads: volatile.

  • Read-modify-write a counter: AtomicInteger/LongAdder.

  • Publish an immutable object safely: final fields + safe publication (via constructor, volatile write, or concurrent collection).

  • Multi-field invariants: use a lock (synchronized or ReentrantLock).

  • Immutable data: always thread-safe. Prefer this whenever you can.

Immutability is the cheat code. Records make it easier than ever. Every class you can make immutable is a class you don’t have to reason about concurrently.


⚠️ What Most People Get Wrong

They believe volatile makes counter++ thread-safe. It does not. They believe synchronized on this protects static state. It does not — static state needs synchronization on the Class object or a static lock. They believe “my tests pass, so my code is correct.” Concurrency bugs are non-deterministic; passing tests prove nothing. The correct mindset: “I have proven, from JMM rules, that this cannot go wrong.” If you cannot state the happens-before chain, you have a bug — you just haven’t hit it yet.

Also: they memorize the JMM rules without ever running jcstress (Java Concurrency Stress). You’ll do that in file 06.


Return to README.md · Next: 02_synchronization_and_locks.md