Garbage Collection & JVM Memory Model
The JVM automatically reclaims memory occupied by objects that are no longer reachable: this is garbage collection. Understanding how memory is organized and how different collectors work is essential for writing high-performance Java applications and diagnosing production issues like memory leaks, long GC pauses, and OutOfMemoryErrors.
Contents
JVM Memory Areas
The JVM divides memory into several distinct areas, each serving a specific purpose. Understanding these areas is the foundation for reasoning about garbage collection and memory management.
Heap Memory
The heap is where all object instances and arrays are allocated. It is the primary area managed by the garbage collector and is divided into generations:
- Young Generation: where new objects are allocated. It is further divided into:
- Eden Space: initial allocation area for most new objects.
- Survivor Spaces (S0 and S1): objects that survive a minor GC are copied here. Only one survivor space is active at a time.
- Old Generation (Tenured): objects that have survived multiple minor GC cycles are promoted here. A full GC or major GC is required to clean this space.
Non-Heap Memory
- Metaspace: stores class metadata (class definitions, method data, constant pools). Replaced PermGen in Java 8. Grows dynamically using native memory and is bounded by
.-XX:MaxMetaspaceSize - Thread Stacks: each thread has its own stack holding frames for method calls, local variables, and partial results. Stack size is controlled by
.-Xss - Code Cache: stores JIT-compiled native code. Sized via
.-XX:ReservedCodeCacheSize - Direct Memory: off-heap memory allocated via
, bounded byByteBuffer.allocateDirect() .-XX:MaxDirectMemorySize
// JVM Memory Layout (conceptual diagram)
//
// +-----------------------------------------------------+
// | JVM Process |
// | |
// | +-----------------------------------------------+ |
// | | HEAP (-Xms / -Xmx) | |
// | | | |
// | | +-----------+ +-------------------------+ | |
// | | | Young Gen | | Old Generation | | |
// | | | | | (Tenured) | | |
// | | | +-------+ | | | | |
// | | | | Eden | | | Long-lived objects | | |
// | | | +-------+ | | promoted from Young | | |
// | | | |S0 |S1 | | | | | |
// | | | +---+---+ | +-------------------------+ | |
// | | +-----------+ | |
// | +-----------------------------------------------+ |
// | |
// | +-----------+ +----------+ +-----------------+ |
// | | Metaspace | |Code Cache| | Thread Stacks | |
// | | (native) | | (JIT) | | (per-thread) | |
// | +-----------+ +----------+ +-----------------+ |
// +-----------------------------------------------------+
# Query current memory settings at runtime
java -XX:+PrintFlagsFinal -version 2>&1 | grep -E "MaxHeapSize|MetaspaceSize|ThreadStackSize"
# Typical production heap configuration
java -Xms4g -Xmx4g -XX:MaxMetaspaceSize=512m -Xss1m -jar app.jar
-Xms-XmxHow Garbage Collection Works
Garbage collection determines which objects are still in use and reclaims memory from those that are not. The JVM uses a tracing strategy based on reachability from a set of root references.
GC Roots
The GC starts from a set of root references that are always considered reachable:
- Local variables and parameters on active thread stacks
- Static fields of loaded classes
- Active threads themselves
- JNI references held by native code
- Objects used for synchronization (monitors)
Mark-Sweep-Compact
Most JVM collectors follow a three-phase approach:
- Mark: starting from GC roots, traverse all reachable object references and mark every object found as alive.
- Sweep: scan the heap and reclaim memory occupied by unmarked (unreachable) objects.
- Compact: move surviving objects together to eliminate fragmentation and make allocation faster via simple bump-pointer allocation.
// Reachability example — which objects survive GC?
public class ReachabilityDemo {
private static List<String> staticList = new ArrayList<>(); // GC root (static field)
public static void main(String[] args) {
Object a = new Object(); // reachable via local variable (GC root)
Object b = new Object(); // reachable via local variable
List<Object> list = List.of(a); // 'a' is also reachable through 'list'
b = null; // 'b' is now unreachable — eligible for GC
staticList.add("persistent"); // reachable via static field — survives any GC
// At this point:
// 'a' — reachable (local var + list)
// original 'b' — unreachable (eligible for collection)
// "persistent" — reachable (static field)
}
}
System.gc()System.gc()-XX:+DisableExplicitGCGenerational Collection
Most JVM collectors use a generational strategy based on the weak generational hypothesis: most objects die young. By dividing the heap into generations, the GC can collect short-lived objects frequently (minor GC) and long-lived objects rarely (major/full GC).
Minor GC (Young Generation Collection)
- Triggered when Eden space fills up.
- All live objects in Eden and the active Survivor space are copied to the other Survivor space.
- Objects that have survived enough minor GC cycles (reaching the tenuring threshold) are promoted to Old Generation.
- Minor GCs are fast because most objects in Eden are already dead: typically only 1-5% survive.
Major / Full GC
- Collects the Old Generation (and sometimes the entire heap).
- Much more expensive than minor GC because Old Gen objects tend to be long-lived, so a larger portion of the heap must be scanned.
- Triggered when the Old Generation fills up or when a promotion from Young Gen fails due to insufficient space.
// Object lifecycle through generations
//
// 1. new Object() --> allocated in Eden
// 2. Eden fills up --> Minor GC triggered
// 3. Surviving objects copied to Survivor space (S0 or S1)
// - age counter incremented
// 4. Next Minor GC: survivors copied to the OTHER Survivor space
// - dead objects in Eden + old Survivor are reclaimed
// 5. After N minor GCs (tenuring threshold, default ~15):
// - object promoted to Old Generation
// 6. Old Generation fills up --> Major GC / Full GC
// Check the tenuring threshold
// -XX:MaxTenuringThreshold=15 (default)
// -XX:+PrintTenuringDistribution (shows age distribution at each minor GC)
# Monitor GC activity in real time
jstat -gc <pid> 1000
# Output columns include:
# S0C/S1C — Survivor space 0/1 capacity
# S0U/S1U — Survivor space 0/1 utilization
# EC/EU — Eden capacity/utilization
# OC/OU — Old gen capacity/utilization
# MC/MU — Metaspace capacity/utilization
# YGC/YGCT — Young GC count/time
# FGC/FGCT — Full GC count/time
G1 Garbage Collector
The Garbage-First (G1) collector has been the default GC since Java 9. It divides the heap into equal-sized regions (typically 1-32 MB each) rather than contiguous generations. Any region can serve as Eden, Survivor, Old, or Humongous depending on what the collector needs.
Key Concepts
- Regions: the heap is split into ~2048 regions. G1 tracks the amount of garbage in each region and collects the regions with the most garbage first (hence "Garbage-First").
- Humongous objects: objects larger than 50% of a region are allocated across contiguous "humongous" regions. These are expensive and collected during Full GC.
- Mixed collections: G1 can collect a subset of Old Gen regions alongside Young Gen regions in a single pause, avoiding the need for a full heap scan.
- Pause-time target: G1 tries to meet a configurable pause-time goal by adjusting how many regions it collects per cycle.
G1 Collection Phases
- Young-only phase: collects only Young Gen regions (Eden + Survivor). Triggered when Eden fills.
- Concurrent marking: runs concurrently with the application to identify which Old Gen regions contain the most garbage.
- Mixed collection: collects Young Gen regions plus a selected set of Old Gen regions with high garbage ratios.
- Full GC (fallback): single-threaded stop-the-world collection of the entire heap. Occurs if G1 cannot keep up with allocation.
# Enable G1 with a 200ms pause-time target
java -XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \
-XX:G1HeapRegionSize=16m \
-XX:InitiatingHeapOccupancyPercent=45 \
-Xms8g -Xmx8g \
-jar app.jar
# G1 key flags explained:
# -XX:MaxGCPauseMillis=200 Target max pause time (default 200ms)
# -XX:G1HeapRegionSize=16m Region size (1m to 32m, power of 2)
# -XX:InitiatingHeapOccupancyPercent=45 Start concurrent marking at 45% heap
# -XX:G1MixedGCCountTarget=8 Spread mixed GC over 8 cycles
# -XX:G1HeapWastePercent=5 Stop mixed GC when reclaimable < 5%
-Xmn-XX:NewRatio// Detecting humongous allocations — objects > 50% of G1 region size
// If G1HeapRegionSize=16m, any object > 8MB is humongous
byte[] large = new byte[10 * 1024 * 1024]; // 10 MB — humongous with 16m regions
// Humongous allocations are expensive because:
// 1. They span multiple contiguous regions
// 2. They are only reclaimed during Full GC (before Java 11) or concurrent marking
// 3. They can cause premature Full GCs
// Fix: increase region size or reduce large allocations
// -XX:G1HeapRegionSize=32m (makes humongous threshold 16MB)
ZGC (Java 15+)
ZGC (Z Garbage Collector) is a scalable, low-latency collector designed for applications that require sub-millisecond pause times regardless of heap size. It became production-ready in Java 15 and is generational since Java 21.
Key Design Principles
- Colored pointers: ZGC uses metadata bits in 64-bit object pointers to track GC state (marked, remapped, etc.) without separate mark bitmaps.
- Load barriers: every time the application loads an object reference from the heap, a load barrier checks and potentially fixes the pointer. This allows ZGC to relocate objects concurrently.
- Concurrent everything: marking, relocation, and reference processing all happen concurrently with the application. Only brief "pause" phases for root scanning take sub-millisecond time.
- Region-based: the heap is divided into regions of varying sizes (small: 2MB, medium: 32MB, large: N*2MB) for flexible allocation.
When to Use ZGC
- Applications with strict latency requirements (trading systems, real-time APIs)
- Very large heaps (multi-terabyte) where G1 pauses become too long
- Workloads where tail latency matters more than maximum throughput
# Enable ZGC (Java 15+)
java -XX:+UseZGC -Xms16g -Xmx16g -jar app.jar
# Generational ZGC (Java 21+ — recommended)
java -XX:+UseZGC -XX:+ZGenerational -Xms16g -Xmx16g -jar app.jar
# Note: In Java 23+, ZGenerational is the default behavior of ZGC
# ZGC tuning flags:
# -XX:SoftMaxHeapSize=12g Soft limit — ZGC tries to stay under this
# -XX:ZCollectionInterval=0 Disable proactive GC (default: 0)
# -XX:ZAllocationSpikeTolerance=2 How aggressively to start GC on allocation spikes
# -XX:ConcGCThreads=4 Number of concurrent GC threads
Shenandoah GC
Shenandoah is a low-pause-time garbage collector that achieves concurrent compaction. Like ZGC, its goal is sub-millisecond pauses, but it uses a different approach with Brooks pointers (forwarding pointers).
How Shenandoah Works
- Brooks pointers: every object has an extra indirection pointer at the beginning. Normally it points to itself. During relocation, it points to the new copy. This allows concurrent compaction without stopping the application.
- Concurrent phases: marking, evacuation (copying live objects), and updating references all happen concurrently with the application.
- No generational division: Shenandoah (in its classic mode) treats the entire heap uniformly, without separating young and old objects.
Shenandoah vs ZGC
| Aspect | Shenandoah | ZGC |
|---|---|---|
| Concurrent compaction | Brooks forwarding pointers | Colored pointers + load barriers |
| Barrier type | Read + write barriers | Load barriers only |
| Object header overhead | Extra forwarding word per object | No per-object overhead (uses pointer bits) |
| Generational support | Experimental (generational mode) | Generational since Java 21 |
| Availability | OpenJDK (not in Oracle JDK) | All major JDK distributions |
| Typical pause times | < 1 ms | < 1 ms |
# Enable Shenandoah (OpenJDK only)
java -XX:+UseShenandoahGC -Xms8g -Xmx8g -jar app.jar
# Shenandoah tuning flags:
# -XX:ShenandoahGCHeuristics=adaptive Heuristic mode (adaptive, static, compact, aggressive)
# -XX:ShenandoahMinFreeThreshold=10 Start GC when free heap drops below 10%
# -XX:ShenandoahGuaranteedGCInterval=30000 Force GC every 30s if none occurred
# -XX:ConcGCThreads=4 Number of concurrent GC threads
GC Tuning Flags
The JVM provides many flags for tuning garbage collection. The table below lists the most commonly used flags grouped by category.
Heap Sizing
| Flag | Description | Example |
|---|---|---|
-Xms | Initial heap size | -Xms4g |
-Xmx | Maximum heap size | -Xmx8g |
-Xmn | Young generation size (avoid with G1) | -Xmn2g |
-Xss | Thread stack size | -Xss512k |
-XX:MaxMetaspaceSize | Maximum metaspace size | -XX:MaxMetaspaceSize=512m |
-XX:MaxDirectMemorySize | Maximum direct (off-heap) memory | -XX:MaxDirectMemorySize=1g |
Choosing a Collector
| Flag | Collector | Best For |
|---|---|---|
-XX:+UseG1GC | G1 (default since Java 9) | General-purpose, balanced throughput and latency |
-XX:+UseZGC | ZGC | Ultra-low latency, large heaps |
-XX:+UseShenandoahGC | Shenandoah | Low latency (OpenJDK only) |
-XX:+UseParallelGC | Parallel GC | Maximum throughput (batch processing) |
-XX:+UseSerialGC | Serial GC | Single-core machines, small heaps (<100MB) |
GC Logging
# Unified GC logging (Java 9+)
java -Xlog:gc*:file=gc.log:time,uptime,level,tags:filecount=5,filesize=20m \
-jar app.jar
# What this does:
# gc* — log all GC-related tags
# file=gc.log — write to gc.log
# time,uptime,... — include timestamps and log level
# filecount=5 — keep 5 rotated log files
# filesize=20m — rotate at 20 MB each
# Quick GC summary to stdout
java -Xlog:gc:stdout:time -jar app.jar
# Detailed GC logging with heap info
java -Xlog:gc*,gc+heap=debug:file=gc-detail.log -jar app.jar
Other Useful Flags
| Flag | Description |
|---|---|
-XX:+HeapDumpOnOutOfMemoryError | Automatically dump heap on OOM |
-XX:HeapDumpPath=/path/dump.hprof | Path for heap dump file |
-XX:+DisableExplicitGC | Ignore System.gc() calls |
-XX:MaxGCPauseMillis=200 | Target max GC pause (G1/ZGC) |
-XX:ParallelGCThreads=8 | Number of parallel GC threads (STW phases) |
-XX:ConcGCThreads=4 | Number of concurrent GC threads |
-XX:MaxTenuringThreshold=15 | Number of minor GCs before promotion |
-XX:+PrintTenuringDistribution | Show age distribution at each minor GC |
# Recommended production baseline (G1)
java -Xms4g -Xmx4g \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/var/log/app/heap-dump.hprof \
-Xlog:gc*:file=/var/log/app/gc.log:time,uptime,level,tags:filecount=5,filesize=20m \
-jar app.jar
-XX:+HeapDumpOnOutOfMemoryErrorDetecting Memory Leaks
A memory leak in Java occurs when objects are no longer needed by the application but are still reachable from GC roots, preventing the garbage collector from reclaiming them. Over time, these leaked objects accumulate and eventually cause OutOfMemoryError
Common Leak Patterns
- Static collections that grow indefinitely, adding elements to a static
,List , orMap without ever removing them.Set - Listener / callback registration without removal, registering event listeners but never unregistering them, keeping both the listener and everything it references alive.
- ThreadLocal values not cleaned up: in thread pools, threads are reused. If a
value is set but never removed, it persists for the lifetime of the pooled thread.ThreadLocal - Unclosed resources: streams, connections, and sessions that hold native or heap memory and are not closed properly.
- Inner class references: non-static inner classes hold an implicit reference to their enclosing class instance.
- Custom ClassLoader leaks: in application servers, a redeployed app may leak if the old ClassLoader cannot be GC'd due to lingering references.
// LEAK: static collection grows forever
public class LeakyCache {
// This map is never cleared — entries accumulate over the lifetime of the app
private static final Map<String, Object> cache = new HashMap<>();
public static void store(String key, Object value) {
cache.put(key, value); // entries are never removed!
}
}
// FIX: use a bounded cache or WeakHashMap
private static final Map<String, Object> cache = new LinkedHashMap<>(100, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, Object> eldest) {
return size() > 1000; // evict when cache exceeds 1000 entries
}
};
// LEAK: ThreadLocal not cleaned up in a thread pool
public class RequestContext {
private static final ThreadLocal<Map<String, Object>> context = new ThreadLocal<>();
public static void set(Map<String, Object> data) {
context.set(data);
}
// If this is never called, the data persists in the pooled thread
public static void clear() {
context.remove(); // MUST call this in a finally block!
}
}
// Correct usage with try-finally
try {
RequestContext.set(requestData);
handleRequest();
} finally {
RequestContext.clear(); // always clean up
}
Diagnostic Tools
| Tool | Purpose | Usage |
|---|---|---|
jmap | Capture heap dump | jmap -dump:live,format=b,file=heap.hprof <pid> |
jstat | Monitor GC statistics | jstat -gcutil <pid> 1000 |
jcmd | Diagnostic commands | jcmd <pid> GC.heap_info |
| VisualVM | Visual heap analysis and profiling | GUI tool: connect to running JVM |
| Eclipse MAT | Analyze heap dumps for leak suspects | Open .hprof |
| async-profiler | Low-overhead allocation profiling | Attach to JVM for allocation flame graphs |
# Take a heap dump of a running JVM
jmap -dump:live,format=b,file=heap.hprof $(pgrep -f app.jar)
# Histogram of objects in heap (quick check without full dump)
jmap -histo:live $(pgrep -f app.jar) | head -30
# Monitor GC utilization every second
jstat -gcutil $(pgrep -f app.jar) 1000
# Use jcmd for heap info (preferred over jmap in modern JDKs)
jcmd $(pgrep -f app.jar) GC.heap_info
jcmd $(pgrep -f app.jar) VM.native_memory summary
Diagnosing a Leak Step by Step
- 1. Confirm the leak: monitor Old Gen usage with
. If Old Gen usage trends upward after each Full GC, there is likely a leak.jstat -gcutil - 2. Capture heap dumps: take two dumps separated by several minutes under load. Compare them to see which object types are growing.
- 3. Analyze with Eclipse MAT: open the heap dump, run the Leak Suspects report, and examine the dominator tree to find the largest retained sets.
- 4. Find the GC root path: in MAT, right-click a suspect object and select "Path to GC Roots" to find what is keeping it alive.
- 5. Fix and verify: fix the code, redeploy, and monitor Old Gen usage to confirm the leak is resolved.
-XX:NativeMemoryTracking=summaryjcmd <pid> VM.native_memory summary