Skip to content
TobussSystems in Practice

Java Memory Model & Concurrency Cheat Sheet

7 min read

A practical reference covering JVM memory internals and Java concurrency — heap structure, the Java Memory Model, thread lifecycle, synchronization primitives, executors, CompletableFuture, and the concurrency bugs that bite hardest.


Memory

JVM Heap Generations

The JVM heap is divided into generations. Young Gen holds newly allocated objects and is collected frequently. Surviving objects are promoted to Old Gen (Tenured). Metaspace (Java 8+) replaced PermGen and holds class metadata — it grows dynamically and is not part of the heap.

┌─────────────────────────────────────────────────────────┐
│                        JVM HEAP                         │
│  ┌──────────────────────────────┐  ┌──────────────────┐ │
│  │         Young Gen            │  │    Old Gen       │ │
│  │  ┌───────┐ ┌─────┐ ┌─────┐  │  │   (Tenured)      │ │
│  │  │ Eden  │ │ S0  │ │ S1  │  │──▶│                  │ │
│  │  │       │ │     │ │     │  │  │  Long-lived       │ │
│  │  └───────┘ └─────┘ └─────┘  │  │  objects         │ │
│  └──────────────────────────────┘  └──────────────────┘ │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│              Metaspace (off-heap)                        │
│         Class metadata, static fields                    │
└─────────────────────────────────────────────────────────┘

Stack vs Heap

Each thread has its own stack holding stack frames (local variables, method calls). Objects always live on the heap. Stack overflow = infinite recursion or a call stack that’s too deep.

// Stack: holds primitives and references (not objects)
public void calculate() {
    int x = 5;           // x lives on THIS thread's stack
    String s = "hello";  // reference 's' on stack, String object on heap
    Object obj = new Object(); // obj ref on stack, Object on heap
}

// Stack overflow example
public int infinite(int n) {
    return infinite(n + 1); // StackOverflowError — no base case
}

Java Memory Model (JMM) — Happens-Before Rules

The JMM defines happens-before (HB) rules that guarantee memory visibility between threads:

  • Monitor unlock HB lock — releasing a lock flushes writes; acquiring reads fresh values
  • Volatile write HB read — a volatile write makes all prior writes visible to any subsequent reader
  • Thread.start() HB first action — parent thread’s state is visible to the new thread
  • Thread.join() HB caller — child thread’s writes are visible after join()
// Rule 1: Monitor unlock HB lock
int x = 0;
synchronized (lock) { x = 42; }   // UNLOCK
// --- another thread ---
synchronized (lock) {              // LOCK
    System.out.println(x);         // guaranteed to see 42
}

// Rule 2: Volatile write HB read
volatile boolean ready = false;
int data = 0;
// Thread A
data = 100;
ready = true;   // volatile WRITE
// Thread B
if (ready) use(data);   // sees data = 100

// Rule 3: Thread start HB first action
config = 99;
Thread t = new Thread(() -> use(config)); // sees 99
t.start();

Off-Heap Memory

Off-heap memory lives outside the JVM heap — never touched by GC. Used by Kafka clients, Netty, and high-throughput banking apps. Must be explicitly freed. Two APIs: DirectByteBuffer (standard) and sun.misc.Unsafe (raw pointer arithmetic).

// DirectByteBuffer — standard API
ByteBuffer buf = ByteBuffer.allocateDirect(1024 * 1024); // 1 MB off-heap
buf.putInt(0, 42);
int val = buf.getInt(0);
// Released when ByteBuffer wrapper is GC'd (via Cleaner) — not immediate!

// Force immediate free (pre-Java 9)
((sun.nio.ch.DirectBuffer) buf).cleaner().clean();

// Unsafe — raw pointer arithmetic, no bounds checking
Unsafe unsafe = getUnsafe();
long address = unsafe.allocateMemory(1024);
unsafe.putLong(address, 0xDEADBEEFL);
long result = unsafe.getLong(address);
try {
    riskyOperation();
} finally {
    unsafe.freeMemory(address);  // MUST free — GC will never do this
}

// Common leak: exhausting direct memory
for (int i = 0; i < 100_000; i++) {
    ByteBuffer.allocateDirect(1024 * 1024); // OOM — GC too slow
}

Common Memory Leak Patterns

// 1. Static collections holding references
static List<byte[]> cache = new ArrayList<>();
cache.add(new byte[1024 * 1024]); // never removed → lives forever

// 2. Unclosed streams/connections
InputStream in = new FileInputStream("data.txt");
// forgot in.close() → file descriptor + buffer leaked

// 3. ThreadLocal not removed in thread pools
static ThreadLocal<UserContext> ctx = new ThreadLocal<>();
ctx.set(new UserContext()); // thread is reused from pool
// ctx.remove() never called → old context survives next request

// 4. Listeners not deregistered
eventBus.register(this);  // adds reference to eventBus's list
// object can't be GC'd even after you're "done" with it
// Fix: always call eventBus.unregister(this) in cleanup

// 5. Non-static inner class holding outer reference
class Outer {
    byte[] hugeData = new byte[10_000_000];
    class Inner implements Runnable {  // holds implicit ref to Outer
        public void run() { /* ... */ }
    }
}
executor.submit(new Outer().new Inner());
// Outer + hugeData pinned until task completes

Concurrency

Thread Lifecycle

NEW → RUNNABLE → BLOCKED/WAITING/TIMED_WAITING → TERMINATED

  • BLOCKED: waiting to acquire a monitor lock
  • WAITING: Object.wait(), Thread.join() with no timeout — needs explicit notify
  • TIMED_WAITING: sleep(), wait(timeout), join(timeout) — auto-wakes
        ┌─────┐
        │ NEW │
        └──┬──┘
           │ start()
           ▼
      ┌─────────┐   lock contention   ┌─────────┐
      │RUNNABLE │ ──────────────────▶ │ BLOCKED │
      │         │ ◀────────────────── │         │
      └────┬────┘   lock acquired     └─────────┘
           │
           │ wait()/join()            ┌─────────┐
           └────────────────────────▶ │ WAITING │
           ◀──────────── notify()──── └─────────┘
           │
           │ sleep(n)/wait(n)         ┌──────────────┐
           └────────────────────────▶ │TIMED_WAITING │
           ◀──────────── timeout ──── └──────────────┘
           │
           ▼
      ┌────────────┐
      │ TERMINATED │
      └────────────┘

volatile vs synchronized

volatile guarantees visibility and happens-before, but not atomicity. synchronized guarantees both visibility and atomicity.

int++ is not atomic even on volatile — it’s three operations: read, increment, write. Two threads can interleave.

// volatile — visibility only
volatile int counter = 0;
counter++;  // NOT ATOMIC! Read-Modify-Write can interleave

// Thread 1: read(0) → increment → write(1)
// Thread 2: read(0) → increment → write(1)  ← lost update!
// Result: 1 instead of 2

// Correct: use AtomicInteger
AtomicInteger counter = new AtomicInteger(0);
counter.incrementAndGet(); // CAS — truly atomic

// synchronized — atomicity + visibility
private int count = 0;
synchronized void increment() {
    count++;  // safe: only one thread at a time
}

// volatile IS sufficient for a single write/read (flag pattern)
volatile boolean shutdown = false;
// Thread A: shutdown = true;   (single write — atomic for boolean)
// Thread B: while (!shutdown)  (reads fresh value — visible)

Key Synchronizers

// ReentrantLock — explicit lock with tryLock, timed lock
ReentrantLock lock = new ReentrantLock();
lock.lock();
try { /* critical section */ }
finally { lock.unlock(); }

// ReadWriteLock — many readers OR one writer
ReadWriteLock rwLock = new ReentrantReadWriteLock();
rwLock.readLock().lock();  // multiple threads can hold simultaneously
rwLock.readLock().unlock();

// CountDownLatch — wait for N events (one-shot)
CountDownLatch latch = new CountDownLatch(3);
// 3 worker threads each call latch.countDown()
latch.await(); // main thread waits until count reaches 0
// Cannot be reset

// CyclicBarrier — N threads meet at a point, then continue together
CyclicBarrier barrier = new CyclicBarrier(3, () -> System.out.println("All ready"));
// each thread calls barrier.await() — last one triggers the action
// Can be reset and reused

// Semaphore — limit concurrent access
Semaphore dbPool = new Semaphore(10); // max 10 concurrent DB connections
dbPool.acquire();
try { /* use connection */ }
finally { dbPool.release(); }

Thread Pools — ThreadPoolExecutor & ForkJoinPool

ThreadPoolExecutor internals: task submission checks corePoolSize → queue → maximumPoolSize → RejectedExecutionHandler. The queue type drives when new threads are created. ForkJoinPool uses work-stealing — idle threads steal tasks from busy threads’ deques.

Task submitted
       │
       ▼
  core threads < corePoolSize?
       │ YES → create new thread
       │ NO
       ▼
  Queue full?
       │ NO → enqueue task
       │ YES
       ▼
  threads < maxPoolSize?
       │ YES → create new thread
       │ NO
       ▼
  RejectedExecutionHandler
// ThreadPoolExecutor — explicit control
ExecutorService pool = new ThreadPoolExecutor(
    4,                              // corePoolSize
    8,                              // maximumPoolSize
    60, TimeUnit.SECONDS,           // keepAlive for extra threads
    new ArrayBlockingQueue<>(100),  // bounded queue
    new ThreadPoolExecutor.CallerRunsPolicy() // rejection: caller runs it
);

// ForkJoinPool — divide and conquer
ForkJoinPool fjp = new ForkJoinPool(4); // parallelism = 4
fjp.invoke(new RecursiveTask<Integer>() {
    protected Integer compute() {
        if (problem is small) return solve();
        // split into two sub-tasks
        var left  = new SubTask(leftHalf).fork();
        var right = new SubTask(rightHalf).fork();
        return left.join() + right.join();
    }
});

// Common factory methods (use carefully)
Executors.newFixedThreadPool(4);      // bounded threads, unbounded queue (!)
Executors.newCachedThreadPool();      // unbounded threads — danger at scale
Executors.newWorkStealingPool();      // wraps ForkJoinPool

CompletableFuture

CompletableFuture enables non-blocking async pipelines. Key distinction: thenApply runs on the completing thread (sync), thenApplyAsync runs on a pool. thenCompose flattens nested futures.

// Basic async pipeline
CompletableFuture<String> future = CompletableFuture
    .supplyAsync(() -> fetchUser(id))          // runs on ForkJoinPool
    .thenApply(user -> user.getName())         // sync: same thread
    .thenApplyAsync(name -> enrich(name))      // async: pool thread
    .thenCompose(name -> fetchOrders(name));   // flatMap — avoids CF<CF<T>>

// Combining futures
CompletableFuture<User>   userFuture   = fetchUserAsync(id);
CompletableFuture<Account> accountFuture = fetchAccountAsync(id);

CompletableFuture.allOf(userFuture, accountFuture)
    .thenRun(() -> {
        User user       = userFuture.join();
        Account account = accountFuture.join();
        combine(user, account);
    });

// Error handling
CompletableFuture<String> safe = future
    .exceptionally(ex -> "fallback-value")    // recover from exception
    .handle((result, ex) -> {                 // always runs
        if (ex != null) return "error";
        return result.toUpperCase();
    });

// Timeout (Java 9+)
future.orTimeout(5, TimeUnit.SECONDS)
      .exceptionally(ex -> "timed out");

The Most Dangerous Concurrency Bugs

// DEADLOCK — always acquire locks in the same order
// Thread 1: lock(A) then lock(B)
// Thread 2: lock(B) then lock(A)  → circular wait
// Fix: enforce global lock ordering (e.g. by ID)
if (a.id < b.id) { lock(a); lock(b); }
else             { lock(b); lock(a); }

// FALSE SHARING — threads write different fields on same cache line
class Counter {
    volatile long a;  // Thread 1 writes
    volatile long b;  // Thread 2 writes — same 64-byte cache line!
    // Each write invalidates the other thread's cache
}
// Fix: @Contended (JVM flag: -XX:-RestrictContended)
@jdk.internal.vm.annotation.Contended volatile long a;

// THREADLOCAL LEAK in thread pools
static ThreadLocal<Connection> conn = new ThreadLocal<>();
// Thread from pool: conn.set(c);
// Task ends but thread lives on → Connection never closed
// Fix: always conn.remove() in finally block

// LIVELOCK — threads keep responding to each other, no progress
// Thread A: see conflict → back off → retry → see conflict again
// Thread B: same pattern — neither makes progress
// Fix: randomized backoff