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¶
Each thread has its own working memory (registers, CPU caches). Main memory is shared.
Without synchronization, the JVM and hardware are allowed to reorder reads and writes as long as the single-threaded result is preserved.
A write by thread A is not guaranteed to be visible to thread B unless there is a happens-before relationship between them.
synchronized,volatile,final,Thread.start(),Thread.join(), andjava.util.concurrentprimitives establish happens-before edges.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 |
|
Write of volatile V happens-before every subsequent read of V |
|
The |
|
The last action of the joined thread happens-before |
|
Constructor’s write of a |
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:
Visibility. A write to a
volatilefield is immediately visible to any thread that subsequently reads it. No cache staleness.Ordering. Reads and writes to a
volatilefield 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).Atomicity of single reads/writes of 32-bit and reference values. Also of 64-bit
long/double(which are NOT atomic withoutvolatileon 32-bit JVMs).
What volatile does NOT guarantee:
Atomicity of read-modify-write operations.
counter++is not atomic even ifcounteris volatile. It’s three operations: read, add, write.Compound invariants. If you have two
volatilefields that should be updated together,volatiledoes 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 |
|---|---|
|
Counters, sequence numbers |
|
Publishing objects atomically |
|
Flags with atomic set-if-condition |
|
Elementwise atomic counters |
|
High-contention counters (much faster than |
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:
Allocate memory.
Write the reference to
instance.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.Contendedannotation (needs-XX:-RestrictContendedbefore Java 17; usejdk.internal.vm.annotation.Contendedin 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.LongAdderalready 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:
finalfields + safe publication (via constructor, volatile write, or concurrent collection).Multi-field invariants: use a lock (
synchronizedorReentrantLock).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