Heap Memory
The heap is the runtime data area from which all Java object instances and arrays are allocated. It is shared across all threads in a JVM process, grows dynamically up to a configured maximum, and is managed entirely by the garbage collector. Understanding heap memory means understanding how objects are allocated, how the generational hypothesis drives GC design, how the major garbage collectors (G1, ZGC, Shenandoah, Parallel) partition and manage the heap, what triggers garbage collection, how to tune heap size and GC behaviour, and how to diagnose heap-related problems including OutOfMemoryError and excessive GC pause times. This entry covers heap structure, generational design, object allocation, garbage collection triggers, common collectors, heap tuning, and memory leak detection.
Heap Structure and the Generational Hypothesis
// ── Object allocation and generational movement ──────────────────────
//
// HEAP STRUCTURE (G1GC region-based, simplified view):
//
// ┌────────────────────────────────────────────────────────────────┐
// │ HEAP │
// │ │
// │ ┌──────┐ ┌──────┐ ┌──────┐ Eden regions │
// │ │ Eden │ │ Eden │ │ Eden │ ← new objects allocated here │
// │ └──────┘ └──────┘ └──────┘ │
// │ ┌──────┐ ┌──────┐ Survivor regions │
// │ │ Surv │ │ Surv │ ← objects that survived 1+ GC │
// │ └──────┘ └──────┘ │
// │ ┌──────┐ ┌──────┐ ┌──────┐ Old regions │
// │ │ Old │ │ Old │ │ Old │ ← long-lived objects promoted here│
// │ └──────┘ └──────┘ └──────┘ │
// │ ┌──────────────────────────┐ Humongous regions │
// │ │ Humongous Object │ ← objects > ~50% region size │
// │ └──────────────────────────┘ │
// └────────────────────────────────────────────────────────────────┘
// ── Allocation lifecycle ──────────────────────────────────────────────
public class AllocationDemo {
// These objects follow different allocation paths:
public void shortLived() {
// Created in Eden, dies before or during next minor GC
String temp = "Hello " + userId; // ephemeral
List<String> batch = new ArrayList<>(100); // ephemeral
batch.add(temp);
processBatch(batch);
// temp and batch become garbage when method returns
}
private static final Cache CACHE = new Cache(1000); // long-lived
// CACHE is allocated in old gen or promoted there quickly
// ── Objects that get promoted to old gen ──────────────────────────
// 1. Objects referenced by static fields
// 2. Objects that survive enough minor GCs (tenuring threshold)
// 3. Objects too large for Eden (humongous objects)
// 4. Objects surviving a major GC collection cycle
}
// ── Allocation rate and GC pressure ──────────────────────────────────
// HIGH allocation rate → frequent minor GCs → CPU overhead
// If live set is large → frequent major GCs → pause time
// Mitigating high allocation:
// 1. Object pooling for expensive-to-create objects
// 2. Reusing StringBuilder instead of creating new
// 3. Using primitive arrays instead of boxed collections
// 4. StringBuilder.setLength(0) to reset without reallocationGarbage Collection — Triggers, Algorithms, and Collectors
// ── GC roots — starting points of object reachability ────────────────
//
// GC Root types:
// 1. Local variables on thread stacks (all active stack frames)
// 2. Static fields of loaded classes
// 3. Active JNI references
// 4. Objects referenced by ClassLoaders
// 5. Interned Strings in the String pool
// 6. Synchronisation monitors
//
// An object is LIVE if it is reachable from ANY root through any chain
// An object is GARBAGE if it is unreachable from all roots
// ── Object reachability demonstration ────────────────────────────────
public void reachabilityDemo() {
Object a = new Object(); // reachable via local var 'a'
Object b = new Object(); // reachable via local var 'b'
a = b; // original Object for 'a' is now unreachable → GARBAGE
b = null; // the Object (now referenced by nothing) is GARBAGE too
// GC may collect both original Objects now
}
// ── Reference types affect GC behaviour ──────────────────────────────
import java.lang.ref.*;
// Strong reference — object kept alive as long as any strong ref exists
Object strong = new Object(); // not collected while 'strong' is reachable
// Soft reference — collected when JVM needs memory (good for caches)
SoftReference<byte[]> soft = new SoftReference<>(new byte[1024 * 1024]);
byte[] data = soft.get(); // null if collected, non-null if still alive
// Weak reference — collected at next GC (good for canonicalising maps)
WeakReference<Object> weak = new WeakReference<>(new Object());
// Phantom reference — collected after finalisation (used for cleanup)
ReferenceQueue<Object> queue = new ReferenceQueue<>();
PhantomReference<Object> phantom = new PhantomReference<>(new Object(), queue);
// ── GC log output interpretation ─────────────────────────────────────
// Enable with: -Xlog:gc (Java 9+) or -verbose:gc (older)
//
// [0.234s][info][gc] GC(3) Pause Young (Normal) (G1 Evacuation Pause)
// 23M->8M(128M) 4.532ms
// ↑ ↑ ↑ ↑ ↑ ↑
// time GC type before after heap pause duration
//
// Interpreting:
// Before: 23MB used, After: 8MB used, Heap: 128MB max, Pause: 4.5ms
// 15MB of garbage collected in 4.5ms — healthy young GC
// ── Common GC collectors and their characteristics ────────────────────
// Serial GC (-XX:+UseSerialGC):
// Single-threaded, stop-the-world. For small heaps (<100MB), embedded.
//
// Parallel GC (-XX:+UseParallelGC):
// Multi-threaded STW. High throughput, accepts longer pauses. Batch jobs.
//
// G1GC (-XX:+UseG1GC, default Java 9+):
// Concurrent + incremental. Balanced throughput/latency. Most apps.
// Target: sub-200ms pauses via -XX:MaxGCPauseMillis=200
//
// ZGC (-XX:+UseZGC, Java 15+ production):
// Mostly concurrent. Sub-millisecond pauses, any heap size.
// For latency-critical apps with large heaps.
//
// Shenandoah (-XX:+UseShenandoahGC, OpenJDK):
// Mostly concurrent. Sub-millisecond pauses. Red Hat distribution.Heap Sizing and Tuning
// ── Essential heap flags ──────────────────────────────────────────────
// java -Xms512m -Xmx2g MyApp
// -Xms512m → initial heap size 512MB
// -Xmx2g → maximum heap size 2GB
// Recommendation: set Xms = Xmx in production for predictability
// java -Xms4g -Xmx4g -XX:+UseG1GC -XX:MaxGCPauseMillis=100 MyApp
// Fixed 4GB heap, G1GC, targeting 100ms max pause
// ── Container-aware heap sizing (Java 10+) ────────────────────────────
// In containers (Docker/Kubernetes), heap is percentage of container memory:
// java -XX:MaxRAMPercentage=75.0 MyApp
// Allocate up to 75% of container memory to heap
// Better than -Xmx in containers where memory limits vary by environment
// ── OutOfMemoryError — types and causes ───────────────────────────────
// 1. "Java heap space"
// Cause: live set exceeds -Xmx, or memory leak
// Fix: increase heap, fix leak, or reduce live set
//
// 2. "GC overhead limit exceeded"
// Cause: JVM spending >98% of time GC-ing and recovering <2% memory
// Signals: heap is too small or there's a near-leak
// Fix: increase heap or find what's holding references
//
// 3. "unable to create new native thread"
// Cause: OS-level thread limit or virtual memory exhaustion
// Fix: reduce -Xss, reduce thread count, increase OS limits
//
// 4. "Metaspace" (separate — see Metaspace entry)
// ── Heap dump for memory leak analysis ────────────────────────────────
// Generate on OutOfMemoryError (essential for production):
// java -XX:+HeapDumpOnOutOfMemoryError
// -XX:HeapDumpPath=/dumps/heap.hprof MyApp
// Generate manually via jcmd:
// jcmd <pid> GC.heap_dump /tmp/heap.hprof
// Generate via jmap:
// jmap -dump:format=b,file=/tmp/heap.hprof <pid>
// Analyse with Eclipse Memory Analyser (MAT) or IntelliJ heap analyser
// ── Memory leak pattern — unintentional retention ─────────────────────
public class CacheWithLeak {
// LEAK: static map holds references indefinitely
// Objects added are NEVER removed — heap grows without bound
private static final Map<Long, byte[]> CACHE = new HashMap<>();
public void cache(Long id, byte[] data) {
CACHE.put(id, data); // grows forever
}
}
// Fixed with WeakHashMap (keys collected when no other refs exist):
private static final Map<Long, byte[]> SAFE_CACHE =
Collections.synchronizedMap(new WeakHashMap<>());
// Or with bounded cache:
private static final Map<Long, byte[]> BOUNDED = new LinkedHashMap<>() {
@Override protected boolean removeEldestEntry(Map.Entry<Long, byte[]> e) {
return size() > 10_000; // evict when size exceeds limit
}
};Object Allocation Internals and TLAB
// ── TLAB allocation — per-thread bump pointer ────────────────────────
//
// Thread 1's TLAB:
// ┌──────────────────────────────────────────────────────┐
// │ [Object A][Object B][Object C][free space .........] │
// │ ↑ ↑ │
// │ used ptr (bump pointer) │
// └──────────────────────────────────────────────────────┘
//
// Allocating new Object D:
// ptr = ptr + sizeof(D) ← atomic bump, no locking needed
// initialise D at old ptr location
//
// Thread 2 has its OWN TLAB — no synchronisation between threads
// ── Object memory layout in HotSpot ──────────────────────────────────
// With compressed oops (-XX:+UseCompressedOops, default):
//
// Object header: 12 bytes
// Mark word: 8 bytes (hash code, lock state, GC age)
// Klass ptr: 4 bytes (compressed pointer to class)
//
// Example object sizes:
// new Object() = 16 bytes (12 header + 4 padding)
// new Integer(42) = 16 bytes (12 header + 4 int field)
// new Long(42L) = 24 bytes (12 header + 8 long field + 4 padding)
// new int[10] = 56 bytes (12 header + 4 length + 40 data)
// new Object[10] = 56 bytes (12 header + 4 length + 40 refs)
// ── Measuring object sizes ────────────────────────────────────────────
// Java Agent approach (org.openjdk.jol):
// Instrumentation instrumentation;
// long size = instrumentation.getObjectSize(object);
// JOL (Java Object Layout) library:
// System.out.println(ClassLayout.parseInstance(new Integer(42)).toPrintable());
// Output: java.lang.Integer object internals:
// OFFSET SIZE TYPE DESCRIPTION
// 0 4 (object header: mark)
// 4 4 (object header: class)
// 8 4 int Integer.value
// 12 4 (loss due to the next object alignment)
// Instance size: 16 bytes
// ── Allocation rate impact on GC ─────────────────────────────────────
// Allocation rate = how fast threads are filling Eden
// Eden size ÷ allocation rate = time between minor GCs
//
// Example:
// Eden = 512MB, allocation rate = 1GB/second
// → minor GC every 0.5 seconds — very frequent!
//
// Reducing allocation rate:
// 1. Object pooling (ByteBuffer.allocateDirect, thread-local pools)
// 2. Avoid boxing in hot paths (IntStream vs Stream<Integer>)
// 3. Reuse StringBuilder, avoid string concatenation in loops
// 4. Off-heap memory for large datasets (ByteBuffer, MemorySegment)