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 ByteBuffer.allocateDirect(), bounded by -XX:MaxDirectMemorySize.
Java
// 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)    |   |
// |  +-----------+  +----------+  +-----------------+   |
// +-----------------------------------------------------+
Shell
# 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
Setting -Xms equal to -Xmx avoids heap resizing at runtime, which can cause GC pauses. This is a common best practice for production deployments.

How 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.
Java
// 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)
    }
}
Calling System.gc() is only a hint to the JVM: it does not guarantee a garbage collection will occur. In production, never rely on System.gc() for memory management. Use -XX:+DisableExplicitGC to ignore explicit GC calls entirely.

Generational 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.
Java
// 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)
Shell
# 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
If your application has frequent Full GCs, it usually indicates that too many objects are being promoted to Old Gen, the heap is too small, or there is a memory leak. Use GC logs to investigate.

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.
Shell
# 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%
Avoid explicitly setting young generation size (-Xmn or -XX:NewRatio) with G1. G1 dynamically adjusts the number of young regions to meet the pause-time target, fixing the young gen size defeats this adaptive behavior.
Java
// 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
Shell
# 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
ZGC pause times are typically under 1 millisecond regardless of heap size: a heap of 128 MB or 16 TB will have similar pause times. This makes ZGC ideal for applications where consistent low latency is critical.

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
AspectShenandoahZGC
Concurrent compactionBrooks forwarding pointersColored pointers + load barriers
Barrier typeRead + write barriersLoad barriers only
Object header overheadExtra forwarding word per objectNo per-object overhead (uses pointer bits)
Generational supportExperimental (generational mode)Generational since Java 21
AvailabilityOpenJDK (not in Oracle JDK)All major JDK distributions
Typical pause times< 1 ms< 1 ms
Shell
# 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
Shenandoah is not included in Oracle JDK builds. If you need Shenandoah, use an OpenJDK-based distribution such as Amazon Corretto, Eclipse Temurin, or Red Hat OpenJDK.

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
FlagDescriptionExample
-XmsInitial heap size-Xms4g
-XmxMaximum heap size-Xmx8g
-XmnYoung generation size (avoid with G1)-Xmn2g
-XssThread stack size-Xss512k
-XX:MaxMetaspaceSizeMaximum metaspace size-XX:MaxMetaspaceSize=512m
-XX:MaxDirectMemorySizeMaximum direct (off-heap) memory-XX:MaxDirectMemorySize=1g
Choosing a Collector
FlagCollectorBest For
-XX:+UseG1GCG1 (default since Java 9)General-purpose, balanced throughput and latency
-XX:+UseZGCZGCUltra-low latency, large heaps
-XX:+UseShenandoahGCShenandoahLow latency (OpenJDK only)
-XX:+UseParallelGCParallel GCMaximum throughput (batch processing)
-XX:+UseSerialGCSerial GCSingle-core machines, small heaps (<100MB)
GC Logging
Shell
# 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
FlagDescription
-XX:+HeapDumpOnOutOfMemoryErrorAutomatically dump heap on OOM
-XX:HeapDumpPath=/path/dump.hprofPath for heap dump file
-XX:+DisableExplicitGCIgnore System.gc() calls
-XX:MaxGCPauseMillis=200Target max GC pause (G1/ZGC)
-XX:ParallelGCThreads=8Number of parallel GC threads (STW phases)
-XX:ConcGCThreads=4Number of concurrent GC threads
-XX:MaxTenuringThreshold=15Number of minor GCs before promotion
-XX:+PrintTenuringDistributionShow age distribution at each minor GC
Shell
# 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
Always enable -XX:+HeapDumpOnOutOfMemoryError in production. When an OOM occurs, you need the heap dump to diagnose the root cause. Without it, you are diagnosing blind.

Detecting 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, Map, or Set without ever removing them.
  • 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 ThreadLocal value is set but never removed, it persists for the lifetime of the pooled thread.
  • 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.
Java
// 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
    }
};
Java
// 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
ToolPurposeUsage
jmapCapture heap dumpjmap -dump:live,format=b,file=heap.hprof <pid>
jstatMonitor GC statisticsjstat -gcutil <pid> 1000
jcmdDiagnostic commandsjcmd <pid> GC.heap_info
VisualVMVisual heap analysis and profilingGUI tool: connect to running JVM
Eclipse MATAnalyze heap dumps for leak suspectsOpen .hprof file for dominator tree analysis
async-profilerLow-overhead allocation profilingAttach to JVM for allocation flame graphs
Shell
# 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 jstat -gcutil. If Old Gen usage trends upward after each Full GC, there is likely a leak.
  • 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.
Enable -XX:NativeMemoryTracking=summary to track JVM native memory usage. Use jcmd <pid> VM.native_memory summary to see a breakdown of memory used by the JVM itself (heap, thread stacks, code cache, GC overhead, etc.).