Java Internals 14 min read

Java Memory Architecture & Garbage Collection — A Complete Visual Guide

Every Java object is born in Eden, might survive to Old Gen, and is eventually collected. But how does the JVM decide what lives and what dies? We'll walk through every memory region, every GC generation, and every major algorithm — with live simulations you can interact with.

A
Jun 13, 2026 · 14 min read

The JVM Memory Landscape

When your Java program runs, the JVM carves up memory into distinct regions — each with a specific purpose, lifetime policy, and GC strategy. Getting these wrong causes OutOfMemoryError, excessive GC pauses, or silent memory leaks.

At the highest level there are two worlds: the Stack (thread-local, fast, automatic) and the Heap (shared, GC-managed, where almost everything lives). But within the heap, the story gets much richer.

Interactive JVM Memory Map

Click any region to see what lives there, its size flags, and what error it throws when full.

Click a memory region above to explore it.

Memory Regions — Deep Dive

📚
Stack Memory
Per-thread · LIFO · Auto-managed
-Xss

Each thread gets its own private stack. It stores stack frames — one per method call. A frame holds: local variables, operand stack, and a reference back to the constant pool.

When a method returns, its frame is instantly popped — zero GC involvement. Stack memory is blazing fast but limited. Default thread stack size is typically 512KB–1MB depending on platform.

Stack (main thread)
main() → args, localVars
processOrder() → order, total
calculateTax() → rate, amount
← top of stack (current frame)
StackOverflowError — thrown when stack depth exceeds limit. Classic cause: infinite recursion. Fix: increase -Xss2m or fix the recursion.
Stack frame lifecycle
public void processOrder(Order order) {
    // ↓ frame pushed — 'total' lives on THIS thread's stack
    double total = order.getAmount();
    double tax   = calculateTax(total); // ← new frame pushed
    // calculateTax() frame popped on return ↑
    order.setTotal(total + tax);
    // ↑ processOrder frame popped when this method returns
    // All locals (total, tax) instantly gone — no GC needed
}
🏗️
Heap Memory
Shared · GC-managed · All objects live here
-Xms / -Xmx

The heap is where new MyObject() allocates memory. It's shared across all threads and managed entirely by the garbage collector. The heap is divided into generations based on the generational hypothesis: most objects die young.

Young Generation (~1/3 of heap)
Eden ~80%
S0 ~10%
S1 ~10%
Minor GC — fast, frequent (ms)
Old Generation (~2/3 of heap)
Tenured / Old Gen
Major GC — slower, infrequent (ms–s)
OutOfMemoryError: Java heap space — heap is full and GC can't reclaim enough. Fix: increase -Xmx4g, fix memory leaks, or review object retention.
🧠
Metaspace
Native memory · Class metadata · Replaced PermGen (Java 8+)
-XX:MaxMetaspaceSize

Metaspace stores class metadata: class structures, method bytecode, constant pools, field/method descriptors. Unlike the old PermGen, Metaspace uses native memory — it can grow beyond JVM heap limits.

By default, Metaspace is unbounded (grows until OS runs out). Always set -XX:MaxMetaspaceSize=256m in production to prevent native memory exhaustion.

OutOfMemoryError: Metaspace — too many classes loaded. Common causes: frameworks generating proxy classes (Spring, Hibernate, ByteBuddy), class loader leaks in hot-deploy scenarios.
Metaspace tuning flags
# Production baseline
-XX:MetaspaceSize=128m        # initial commit size
-XX:MaxMetaspaceSize=256m     # hard cap — always set this!
-XX:MinMetaspaceFreeRatio=20  # GC trigger threshold
-XX:MaxMetaspaceFreeRatio=80  # shrink threshold after GC
Code Cache
Native memory · JIT compiled code
-XX:ReservedCodeCacheSize

The JIT compiler (C1/C2) translates hot bytecode into native machine code and stores it in the Code Cache. When the cache fills, JIT compilation stops and performance degrades — the JVM falls back to interpreted mode.

Warning: CodeCache is full — not an OOM, but a performance cliff. The JVM stops JIT-compiling new methods. Fix: -XX:ReservedCodeCacheSize=256m. Visible in logs as CodeCache is full. Compiler has been disabled.

Live Object Lifecycle Simulator

Create objects and run GC cycles. Watch objects move from Eden → Survivor → Old Gen → get collected. Age counters show how many Minor GCs an object has survived.

GC cycles before Old Gen
Young Generation Minor GCs: 0
Eden
0/8
Survivor S0
0
Survivor S1
0
Old Generation (Tenured) 0/12
0
Alive
0
Collected
0
Promoted
0
OOM Events
GC Log

GC Roots & Reachability

The GC starts from GC roots — a fixed set of known live references — and traces the entire object graph. Any object reachable from a root is alive. Anything unreachable is garbage.

📌
Local variables
Variables on active stack frames of any thread. As long as the method is running, its local object references are GC roots.
🌐
Static fields
Any static field holding an object reference. Statics live for the entire class lifetime — a common source of memory leaks.
🔗
JNI references
Objects referenced by native (C/C++) code via JNI. The GC cannot collect these even if unreachable from Java code.
⚙️
Active threads
The Thread objects themselves and everything they reference (via their stack) are roots for as long as the thread is alive.
Object reachability chain
GC Root
(stack local)
OrderService
@0x1001
List<Order>
@0x2002
Order #7
@0x3003
TempHelper
@0x4004 ← GC
TempHelper has no path from any GC root → it's garbage, eligible for collection on next GC cycle.
Classic static field memory leak
// ⚠ Common leak: static cache holding strong references forever
public class ReportCache {
    // Static field → GC root → Report objects NEVER collected
    private static final Map<String, Report> cache = new HashMap<>();

    public static void cache(String key, Report r) {
        cache.put(key, r); // grows forever — classic heap leak
    }
}

// ✓ Fix: use WeakReference — GC can collect when no other strong refs exist
private static final Map<String, WeakReference<Report>> cache = new WeakHashMap<>();

// ✓ Or: use Guava/Caffeine with max-size and TTL eviction
Cache<String, Report> cache = Caffeine.newBuilder()
    .maximumSize(1000)
    .expireAfterWrite(10, TimeUnit.MINUTES)
    .build();

GC Algorithms — How They Work

Click an algorithm to see how it works, its pause characteristics, and when to use it.

Stop-the-world pause comparison (relative, same heap load)
Serial GC
~500ms+
Parallel GC
~200ms
G1GC
~50ms (tunable)
ZGC
<1ms
Shenandoah
<1ms

Production JVM Flags Cheat Sheet

Flag Purpose Example
-Xms / -Xmx Initial / max heap size -Xms512m -Xmx4g
-Xss Thread stack size -Xss512k
-XX:MaxMetaspaceSize Cap Metaspace (always set!) -XX:MaxMetaspaceSize=256m
-XX:+UseG1GC Enable G1 garbage collector -XX:+UseG1GC
-XX:MaxGCPauseMillis G1: target max pause time (soft goal) -XX:MaxGCPauseMillis=200
-XX:+UseZGC Enable ZGC (Java 15+ production-ready) -XX:+UseZGC
-XX:+PrintGCDetails Verbose GC logging (legacy) -XX:+PrintGCDetails
-Xlog:gc* Unified GC logging (Java 9+) -Xlog:gc*:file=gc.log:time
-XX:+HeapDumpOnOutOfMemoryError Auto heap dump on OOM -XX:HeapDumpPath=/dumps/
-XX:NewRatio Ratio of Old to Young gen size -XX:NewRatio=3
-XX:SurvivorRatio Ratio of Eden to one Survivor space -XX:SurvivorRatio=8

Which GC for Which Use Case?

⚙️
Batch / offline processing
Parallel GC (-XX:+UseParallelGC)
Maximises throughput. Pauses are acceptable when processing isn't user-facing.
🌐
Web API / latency-sensitive
G1GC (-XX:+UseG1GC)
Predictable pause targets. Default since Java 9. Set -XX:MaxGCPauseMillis=100.
Real-time / <1ms pause
ZGC (-XX:+UseZGC)
Concurrent, sub-millisecond pauses. Java 15+ production-ready. Slightly higher CPU overhead.
🔧
Small JVM / CLI tools
Serial GC (-XX:+UseSerialGC)
Single-threaded, minimal overhead. Fine when heap is small and throughput doesn't matter.
Java JVM GC Performance Memory