Advanced Reading time: ~16 min

Garbage Collection

GC types, G1, ZGC, memory leaks and tuning

Garbage Collection

Garbage Collection (GC) is the JVM's automatic memory management mechanism that identifies and reclaims heap memory occupied by objects that are no longer reachable from any live thread or GC root.

1. Definition

What is it?

Garbage Collection is the process by which the JVM automatically frees heap memory. The GC periodically traces object graphs starting from GC roots (live thread stacks, static fields, JNI references), marks all reachable objects as live, and reclaims the rest. The programmer does not call free() — the JVM handles deallocation automatically.

Why does it exist?

Manual memory management (C/C++) is error-prone: dangling pointers, double-frees, and buffer overruns are all common bugs. Java's GC eliminates these classes of bugs at the cost of introducing pause times (Stop-the-World events) and some runtime overhead. The trade-off is correctness and developer productivity vs. raw control.

Where does it fit?

GC operates entirely inside the JVM on the heap. The stack (local variables, method frames) is automatically managed via LIFO push/pop with no GC involvement. The heap is where all objects created with new live, and GC is responsible for that memory space.

Key JVM memory areas:

  • Stack — stores method frames and local variables per thread; GC does not manage it.
  • Heap — stores objects and arrays created with new; GC manages this area.
  • Metaspace — stores class metadata and runtime type information.
  • Code Cache — stores JIT-compiled native code.

2. Core Concepts

2.1 GC Algorithms

Algorithm How It Works Characteristic
Mark-and-Sweep Mark live objects, sweep dead ones Can cause fragmentation
Copying Copy live objects to new space, discard old No fragmentation, wastes half the space
Mark-Compact Mark live, then compact survivors to one end No waste, but slower due to compaction

Modern GCs combine these: Young Gen uses Copying (fast, objects die young), Old Gen uses Mark-Compact or concurrent marking.


2.2 Generational Hypothesis

"Most objects die young."

Based on this empirical observation, the heap is divided into generations:

Traditional heap layout in words:

  • Eden — newly allocated objects start here.

  • Survivor S0/S1 — objects that survive a Minor GC move between survivor spaces and age.

  • Old Gen — long-lived objects are promoted here and are collected less often, but more expensively.

  • Minor GC mainly affects the young generation; Major / Full GC targets older data and is usually costlier.

  • Eden: New objects are allocated here (using TLABs for thread-local fast allocation).

  • Survivor S0/S1: Objects that survive a Minor GC are copied between S0 and S1, aging with each cycle.

  • Old Gen (Tenured): Objects that survive enough Minor GCs (default tenuring threshold: 15) are promoted here.


2.3 GC Types

Serial GC (`-XX:+UseSerialGC`)

  • Single-threaded collector for both Young and Old Gen.
  • Stops all application threads (Stop-the-World) during collection.
  • Best for: small heaps (<100 MB), embedded/single-core environments, batch jobs where pauses are acceptable.

Parallel GC / Throughput GC (`-XX:+UseParallelGC`)

  • Multi-threaded Minor and Major GC.
  • Still Stop-the-World, but uses N threads to finish faster.
  • Default before Java 9. Best for: batch processing, throughput-oriented apps where occasional pauses are fine.

CMS – Concurrent Mark-Sweep (`-XX:+UseConcMarkSweepGC`) ⚠ Deprecated

  • Performs most of Old Gen marking concurrently with the application to reduce pause time.
  • Still has short STW phases (initial mark, remark).
  • Suffers from fragmentation (no compaction) and concurrent mode failure under high allocation pressure.
  • Removed in Java 14.

G1 GC – Garbage First (`-XX:+UseG1GC`)

  • Default since Java 9.
  • Region-based: heap is divided into equal-size regions (~1–32 MB) assigned dynamically as Eden, Survivor, Old, or Humongous.
  • Predictable pause goals via -XX:MaxGCPauseMillis (default 200ms).
  • Performs concurrent marking; evacuation (copying live objects) is STW but bounded.
  • Best for: general-purpose server applications, heaps 4 GB – 32 GB.

In G1, the heap is split into many equal-sized regions whose role changes dynamically:

  • E (Eden) — regions for new allocations.
  • S (Survivor) — regions for recently surviving objects.
  • O (Old) — regions for long-lived objects.
  • H (Humongous) — regions reserved for very large objects.

ZGC (`-XX:+UseZGC`)

  • Ultra-low latency GC: pause times < 10 ms regardless of heap size (tested up to terabytes).
  • Pauses do not scale with heap size — they scale only with root set size.
  • Uses load barriers and colored pointers (on 64-bit) to perform relocation concurrently.
  • Available since Java 11 (experimental), production-ready since Java 15, enhanced in Java 21+.
  • Best for: latency-sensitive applications (trading, gaming, real-time systems), very large heaps.

Shenandoah GC (`-XX:+UseShenandoahGC`)

  • Concurrent compaction GC developed by Red Hat, available in OpenJDK.
  • Similar goals to ZGC: sub-10ms pauses with concurrent evacuation.
  • Uses Brooks forwarding pointers (indirection layer) for concurrent relocation.
  • Best for: same use cases as ZGC; available in some distributions where ZGC is not.

Epsilon GC (`-XX:+UseEpsilonGC`) đŸ§Ș

  • A no-op GC: allocates but never collects. The JVM crashes with OOM when heap is exhausted.
  • Used for: performance benchmarking (eliminates GC noise), very short-lived processes, GC overhead testing.

2.4 GC Phases

Phase Trigger Threads Stopped?
Minor GC Eden full Yes (briefly)
Major GC Old Gen needs collection Yes (longer)
Full GC OOM risk, explicit System.gc(), CMS failure Yes (longest)
Concurrent Marking G1/ZGC background phase No
Evacuation/Relocation G1 mixed collection, ZGC Partially or No

2.5 GC Roots

The GC starts tracing from GC roots — references guaranteed to be live:

  • Local variables on active thread stacks
  • Static fields of loaded classes
  • JNI references (native code holding Java objects)
  • Synchronization monitors (objects used in synchronized)
  • Class loader references

Anything not reachable from a GC root is eligible for collection.


2.6 Reference Types

Java provides four reference strengths to give developers control over GC behavior:

Type Class GC Behavior
Strong (default new) Never collected while reachable
Soft SoftReference<T> Collected when JVM needs memory (before OOM)
Weak WeakReference<T> Collected at next GC cycle if no strong refs
Phantom PhantomReference<T> Enqueued after finalization; used for cleanup hooks

3. Practical Usage

Choosing the Right GC

Scenario Recommended GC
Batch processing / max throughput Parallel GC
General server app (Java 9+) G1 GC (default)
Low-latency (<200ms pauses, 4–32 GB heap) G1 GC with tuning
Ultra-low latency (<10ms) or very large heap ZGC
Benchmarking / no-GC short process Epsilon GC

Key JVM Flags

# Heap sizing (ALWAYS set both equally in production)
-Xms4g -Xmx4g

# GC selection
-XX:+UseG1GC          # G1 (default Java 9+)
-XX:+UseZGC           # ZGC
-XX:+UseParallelGC    # Throughput GC
-XX:+UseShenandoahGC  # Shenandoah

# G1 tuning
-XX:MaxGCPauseMillis=200       # Pause goal (soft target)
-XX:G1HeapRegionSize=16m       # Region size (1–32 MB, power of 2)
-XX:InitiatingHeapOccupancyPercent=45  # When to start concurrent marking

# ZGC tuning
-XX:SoftMaxHeapSize=28g        # Soft heap limit (helps ZGC shrink heap)
-XX:ZCollectionInterval=0      # ZGC collection interval (0 = automatic, triggered by allocation rate)

# GC logging (Java 9+ unified logging)
-Xlog:gc*:file=gc.log:time,uptime,level,tags:filecount=5,filesize=20m

# Diagnostic
-XX:+PrintGCDetails            # (Java 8 and earlier)
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/dumps/heap.hprof

When to Tune

  • When GC pause times exceed application SLAs (use GC logs to confirm).
  • When GC overhead exceeds ~5% of total CPU time (visible in logs or JMX).
  • When you observe frequent Full GC events.
  • Never tune blindly — always measure first with GC logs and profiling tools (JFR, VisualVM, GCViewer).

When NOT to Tune

  • Before measuring — premature GC tuning is wasted effort.
  • By increasing heap indefinitely — larger heaps mean longer GC pauses in some collectors.
  • By calling System.gc() — it's a hint the JVM can ignore and typically causes harm.

4. Code Examples

Example 1: WeakReference for Memory-Sensitive Caches

import java.lang.ref.WeakReference;
import java.util.WeakHashMap;

// WeakHashMap: keys are weakly referenced — entries auto-removed when key GC'd
public class ImageCache {
    // Values held softly won't prevent GC but survive minor pressure
    private final WeakHashMap<String, byte[]> cache = new WeakHashMap<>();

    public void put(String key, byte[] data) {
        cache.put(key, data);
    }

    public byte[] get(String key) {
        return cache.get(key); // may return null if GC reclaimed
    }
}

// Explicit WeakReference usage
WeakReference<ExpensiveObject> weakRef = new WeakReference<>(new ExpensiveObject());
ExpensiveObject obj = weakRef.get(); // null if GC has run
if (obj != null) {
    obj.doWork();
}

// SoftReference for caches that should survive as long as possible
import java.lang.ref.SoftReference;
SoftReference<byte[]> softCache = new SoftReference<>(loadLargeData());
byte[] data = softCache.get();
if (data == null) {
    data = loadLargeData(); // reload on eviction
    softCache = new SoftReference<>(data);
}

Example 2: Memory Leak via Static Collection

// ❌ MEMORY LEAK: static map grows without bound
public class LeakyRegistry {
    private static final Map<String, UserSession> sessions = new HashMap<>();

    public static void register(String id, UserSession session) {
        sessions.put(id, session); // sessions never removed!
    }
    // No remove() method — sessions accumulate forever → Old Gen fills → Full GC → OOM
}

// ✅ Fix: use WeakReference values or explicit eviction
public class SafeRegistry {
    private static final Map<String, WeakReference<UserSession>> sessions
        = new ConcurrentHashMap<>();

    public static void register(String id, UserSession session) {
        sessions.put(id, new WeakReference<>(session));
    }

    public static UserSession get(String id) {
        WeakReference<UserSession> ref = sessions.get(id);
        return (ref != null) ? ref.get() : null;
    }
}

Example 3: GC Monitoring with JMX MXBeans

import java.lang.management.GarbageCollectorMXBean;
import java.lang.management.ManagementFactory;
import java.util.List;

public class GCMonitor {
    public static void printGCStats() {
        List<GarbageCollectorMXBean> gcBeans =
            ManagementFactory.getGarbageCollectorMXBeans();

        for (GarbageCollectorMXBean gc : gcBeans) {
            System.out.printf("GC Name: %s%n", gc.getName());
            System.out.printf("  Collections: %d%n", gc.getCollectionCount());
            System.out.printf("  Total time:  %d ms%n", gc.getCollectionTime());
        }
    }

    // Register a GC notification listener (Java 7+)
    public static void registerGCListener() {
        for (GarbageCollectorMXBean gc : ManagementFactory.getGarbageCollectorMXBeans()) {
            if (gc instanceof javax.management.NotificationEmitter emitter) {
                emitter.addNotificationListener((notification, handback) -> {
                    System.out.println("GC event: " + notification.getType());
                }, null, null);
            }
        }
    }
}

Example 4: GC Tuning JVM Flags (Production G1 Setup)

# Production-ready G1 startup flags
java \
  -Xms8g -Xmx8g \                            # Fixed heap — avoid resizing pauses
  -XX:+UseG1GC \                              # Explicit G1 (redundant on Java 9+ but clear)
  -XX:MaxGCPauseMillis=150 \                  # Target max pause
  -XX:G1HeapRegionSize=16m \                  # Larger regions for big heaps
  -XX:InitiatingHeapOccupancyPercent=35 \     # Start concurrent marking earlier
  -XX:G1ReservePercent=15 \                   # Reserve for promotion headroom
  -Xlog:gc*:file=logs/gc.log:time,uptime:filecount=10,filesize=20m \
  -XX:+HeapDumpOnOutOfMemoryError \
  -XX:HeapDumpPath=/dumps/ \
  -jar myapp.jar

# ZGC for latency-sensitive services (Java 15+)
java \
  -Xms16g -Xmx16g \
  -XX:+UseZGC \
  -XX:SoftMaxHeapSize=14g \
  -Xlog:gc*:file=logs/zgc.log:time,uptime \
  -jar myapp.jar

Common Pitfall: Finalizers and GC Delay

// ❌ Finalizers delay GC — objects must survive one extra GC cycle
public class BadResourceWithFinalizer {
    @Override
    protected void finalize() throws Throwable {
        // Don't do this — unpredictable, delays GC, can resurrect objects
        cleanup();
    }

    private void cleanup() { /* release resources */ }
}

// ✅ Use try-with-resources and AutoCloseable instead
public class GoodResource implements AutoCloseable {
    @Override
    public void close() {
        cleanup(); // deterministic, called immediately
    }
}

// Or use Cleaner (Java 9+) for native resource cleanup without finalizer drawbacks
import java.lang.ref.Cleaner;
public class CleanResource {
    private static final Cleaner cleaner = Cleaner.create();
    private final Cleaner.Cleanable cleanable;

    public CleanResource() {
        this.cleanable = cleaner.register(this, new CleanupAction());
    }

    @Override
    public void close() {
        cleanable.clean();
    }

    private static class CleanupAction implements Runnable {
        @Override public void run() { /* release native resources */ }
    }
}

5. Trade-offs

Aspect Details
⚡ Performance GC adds CPU overhead (~1–5%). ZGC/Shenandoah add higher concurrent overhead than Serial/Parallel but eliminate long pauses.
đŸ’Ÿ Memory GC requires headroom: heap at 70–80% occupancy is the sweet spot. Overly large heaps cause long pause cycles; too-small heaps cause frequent GC.
🔧 Maintainability Automatic memory management eliminates dangling pointer bugs but requires developers to understand reference types and avoid unintentional retention.
🔄 Flexibility JVM offers 6 collectors. Switching is a single JVM flag change — no code changes required.
⏱ Latency vs Throughput Serial/Parallel prioritize throughput (fewer but longer pauses). G1 balances both. ZGC/Shenandoah prioritize latency (ultra-short pauses at cost of some throughput).
📈 Scalability ZGC pause times do not grow with heap size — critical for multi-terabyte heaps in data-intensive applications.

6. Common Mistakes

1. ❌ Not Setting `-Xms` Equal to `-Xmx`

# ❌ JVM starts with small heap, grows it under pressure — triggers GC during resize
java -Xmx4g -jar app.jar

# ✅ Fixed heap size eliminates resize-triggered GC and avoids OS memory contention
java -Xms4g -Xmx4g -jar app.jar

Heap resizing causes Full GC events and OS memory allocation latency in production.


2. ❌ Memory Leak via Static Collections

// ❌ Classic leak: listener/handler registry without cleanup
public class EventBus {
    private static final List<EventListener> listeners = new ArrayList<>();
    public static void subscribe(EventListener l) { listeners.add(l); }
    // No unsubscribe() — listeners (and all their object graphs) never collected
}

// ✅ Support deregistration and use weak references for optional listeners
private static final List<WeakReference<EventListener>> listeners = new CopyOnWriteArrayList<>();

3. ❌ Using `System.gc()`

// ❌ Never call this in production code
System.gc(); // Hint only — JVM may ignore it. Usually triggers a Full GC when honored.
             // Causes unpredictable stop-the-world pauses and is an anti-pattern.

// ✅ Let the GC run on its own schedule. Trust the tuning flags.

4. ❌ Finalizers Causing GC Delays

// ❌ Objects with finalizers require TWO GC cycles to be collected:
//    1st cycle: detected as unreachable, placed in finalization queue
//    2nd cycle: finalize() runs, then object is collected
// This doubles the memory pressure and can saturate the finalizer thread.

5. ❌ Tuning Without Measurement

# ❌ Random flag combinations without data
-XX:NewRatio=2 -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=5

# ✅ Enable GC logging first, analyze, then tune one variable at a time
-Xlog:gc*:file=gc.log:time,uptime,level,tags
# Use GCViewer, GCEasy, or JFR to analyze before changing flags

7. Senior-level Insights

GC Tuning Is Measurement-Driven

The most common mistake senior engineers see is tuning GC flags without establishing baselines. Enable GC logging, load-test in a staging environment, then analyze. Tools: Java Flight Recorder (JFR), JDK Mission Control, GCViewer, GCEasy.io.

G1 vs ZGC Selection Criteria

  • Use G1 if your pause SLA is 100–200ms and heap is under 32 GB. G1's ergonomics auto-tune well.
  • Use ZGC when pause SLA is <10ms, heap is >32 GB, or you're running latency-critical services.
  • ZGC in Java 21 (generational ZGC with -XX:+ZGenerational) further improves throughput while maintaining ultra-low latency.

Safepoints

Stop-the-World GC pauses require all application threads to reach a safepoint — a known, safe point in bytecode execution. Reaching safepoints takes time (Time To Safepoint, TTS). High TTS can inflate apparent GC pause times. Monitor with -Xlog:safepoint.

Write Barriers

Concurrent GCs (G1, ZGC, Shenandoah) use write barriers — code injected by the JIT at every reference write — to track object references that change during concurrent marking. This ensures the GC sees a consistent view of the object graph. Write barriers add slight overhead (~1–3% throughput cost).

How ZGC Achieves <10ms Pauses

ZGC uses colored pointers (metadata bits in 64-bit pointers) and load barriers (code run on every reference read) to perform relocation concurrently with the application. Threads fix up stale pointers lazily as they access objects. The only STW phases are short root scanning operations, bounded by thread count, not heap size.

Remembered Sets and Card Tables

G1 maintains a Remembered Set per region — a record of which other regions hold references into this region. This lets G1 collect a subset of regions (a "Collection Set") without scanning the whole heap. Card Tables (used by CMS and G1) divide the heap into 512-byte cards and track dirty cards (modified since last GC) to efficiently find inter-generational references.

TLAB — Thread-Local Allocation Buffers

Each thread has a private chunk of Eden called a TLAB. Object allocation is a simple pointer bump (no locking), making new operations extremely fast. Only when the TLAB is exhausted does the thread need a new one from the shared Eden (with synchronization).

Epsilon GC for Testing

Use -XX:+UseEpsilonGC in benchmark tests to eliminate GC noise and measure raw allocation throughput. It's also useful for demonstrating memory leak behavior in controlled environments.


8. Glossary

Term Definition
Minor GC Collection of Young Gen (Eden + Survivor spaces); fast, frequent
Major GC Collection of Old Gen; slower than Minor GC
Full GC Collection of entire heap (Young + Old + Metaspace); longest pause
Stop-the-World (STW) All application threads are paused while GC runs
Safepoint A point in code where thread state is known-safe for GC to inspect
Write Barrier Code injected at reference writes to support concurrent GC correctness
GC Root Reference guaranteed live: thread stacks, static fields, JNI refs
Remembered Set Per-region set of references pointing into that region (used by G1)
Card Table Heap divided into 512-byte cards; dirty cards tracked for inter-gen refs
TLAB Thread-Local Allocation Buffer — private Eden chunk per thread for fast allocation
Region Fixed-size heap unit used by G1 GC (1–32 MB)
Humongous Object Object larger than 50% of a G1 region; allocated directly in Old Gen
Promotion Moving a surviving object from Young Gen to Old Gen
Tenuring Aging mechanism — objects that survive N Minor GCs are promoted
Concurrent Marking GC phase that traces live objects alongside running application threads
Colored Pointer ZGC technique: metadata stored in unused bits of 64-bit pointers
Load Barrier Code injected at reference reads (ZGC) to handle concurrent relocation

9. Cheatsheet

  • đŸ—‘ïž GC = automatic heap memory reclamation — traces from GC roots, collects unreachable objects
  • 🧬 Generational GC — Eden → Survivor S0/S1 → Old Gen; most objects die in Eden
  • ⚙ Default GC is G1 since Java 9 — region-based, ~200ms pause target
  • ⚡ ZGC = sub-10ms pauses regardless of heap size — use for latency-critical services
  • 📏 Always set -Xms = -Xmx in production to avoid resize-triggered GC pauses
  • 🔍 Measure before tuning — enable -Xlog:gc* and analyze with JFR or GCViewer
  • đŸ§” TLAB makes new nearly free — pointer bump per thread, no lock contention
  • 🔗 WeakReference / SoftReference — let GC reclaim when needed; use for caches
  • đŸš« Never call System.gc() — it's an anti-pattern that triggers unpredictable Full GC
  • đŸ·ïž Safepoints add hidden latency — monitor TTS with -Xlog:safepoint in production

🎼 Games

10 questions