← All guides
INTERNAL ENGINEERING REFERENCE · KNOWLEDGE CAFE

JVM Garbage Collection — Field Reference

A lookup handbook for diagnosing GC-related production incidents — SATB queue overflows, ZGC memory bloat, humongous allocation stalls, Metaspace and ClassLoader leaks, and the reference-processing bottleneck that quietly kills high-throughput caches. Written for engineers who already know what a heap is.

Compiled from 10 internal Q&A deep-divesCoverage: Feb – Jul 2026Scope: G1 · Shenandoah · ZGC · EpsilonPrint this page for a PDF copy
01

Foundations

The generational hypothesis, GC roots, and the mark/sweep/compact cycle every collector below builds on.

An object is unreachable when no chain of strong references connects it to a GC root: local variables and operand-stack entries in live frames, static fields of loaded classes, JNI references, and references held directly by the JVM. The collector traverses the object graph from these roots; anything not visited is garbage.

Almost every production collector — Serial, Parallel, G1, Shenandoah, ZGC — leans on the generational hypothesis: most objects die young, a few live forever. Splitting the heap into a small, frequently-collected young generation and a larger, rarely-collected old generation lets the collector spend most of its effort where the payoff is highest.

Fig. 1 — Generational heap layout
flowchart LR
  subgraph HEAP["Java Heap"]
    direction LR
    subgraph YOUNG["Young Generation"]
      direction TB
      E["Eden\n(new allocations)"]
      S0["Survivor S0"]
      S1["Survivor S1"]
    end
    OLD["Old Generation\n(Tenured)"]
  end
  META["Metaspace\n(off-heap: class metadata)"]
  E -- "minor GC, survives" --> S0
  S0 -- "ages out" --> S1
  S1 -- "promoted" --> OLD
  HEAP -.-> META

Every tracing collector runs some variant of three phases:

  • Mark — traverse from GC roots, flag every reachable object (typically a bit in the object header).
  • Sweep — return the memory of unmarked objects to a free list.
  • Compact / Copy — relocate surviving objects into contiguous memory to fight fragmentation and keep allocation a cheap pointer-bump.
TL;DR

Everything downstream — SATB, colored pointers, humongous objects, remembered sets — is a different answer to the same question: how do you keep marking correct while the application keeps mutating the graph underneath you?

02

Collector landscape

Five collectors, one evolutionary line: throughput-first, then pause-aware, then almost entirely concurrent.

Fig. 2 — Collector evolution
flowchart LR
  A["Serial\nsingle-threaded STW"] --> B["Parallel\nmulti-threaded STW"]
  B --> C["CMS\nconcurrent, no compaction\n(deprecated Java 9, removed 14)"]
  C --> D["G1\ndefault since Java 9\nregion-based, mostly-concurrent"]
  D --> E["Shenandoah / ZGC\nJava 11+ / 15+\nfully concurrent, sub-ms pauses"]
  E --> F["Epsilon\nno-op — allocates, never collects"]
  classDef legacy fill:#00000000,stroke-dasharray: 4 3;
  class A,B,C legacy;
  • Serial — single-threaded, stop-the-world. Fine for small heaps and client apps; unacceptable for any latency-sensitive service.
  • Parallel — same STW model, multiple threads doing the work. Throughput-optimized, still not pause-aware.
  • CMS — the first attempt at concurrency. Never compacted, suffered fragmentation and "concurrent mode failure" fallbacks to a full STW collection. Deprecated in Java 9, removed in Java 14 — do not target it in new deployments.
  • G1 — the default since Java 9. Region-based, mostly concurrent, offers a soft real-time pause-time target rather than a guarantee. See §03.
  • Shenandoah / ZGC — sub-millisecond pauses regardless of heap size, at the cost of some throughput and (for ZGC) memory footprint. See §04–§05.
  • Epsilon — allocates and never reclaims. A deliberate no-op collector, not a broken one — see §06 for when that's actually the right call.
03

G1 & the humongous object trap

Symptom: periodic multi-hundred-ms pauses with no full GCs in the logs, correlated with allocations just above the region size.

G1 divides the heap into equal-sized regions (1–32MB, chosen so the heap holds roughly 2,048 of them) and allocates normal objects into per-thread TLABs — a lock-free pointer bump inside an Eden region. Any object larger than 50% of a region is classified Humongous and bypasses this entirely.

Region sizeThe default formula

Heap sizeTarget regionsResulting region size
24 GB2,04824GB ÷ 2048 = 12MB → rounds to 8MB

A humongous object skips Eden and Survivor and is placed directly in the Old Generation as a run of contiguous regions — breaking G1's core generational assumption that objects die young.

Why the pause happens

  1. The allocating thread cannot use its TLAB and must acquire the global Heap_lock, stalling every other thread doing slow-path allocation.
  2. G1 must find contiguous free regions large enough for the object.
  3. If none are free, G1 triggers an emergency, synchronous young collection to coalesce space — this is the pause you see in the logs.
Fig. 3 — Humongous allocation gridlock
flowchart TD
  A["Sustained humongous allocation rate\n(e.g. 600MB/s)"] --> B{"IHOP threshold tripped?"}
  B -- yes --> C["Concurrent Mark starts"]
  C --> D{"Marking keeps pace?"}
  D -- "too slow" --> E["Marking interrupted"]
  B -- "space exhausted\nbefore marking finishes" --> F["Emergency Young GC (STW)"]
  E --> F
  F --> G["Coalesce contiguous regions\n→ multi-hundred-ms pause"]
The 4MB tuning trap

Doubling -XX:G1HeapRegionSize to 4MB does not fix a 3MB object problem — the humongous threshold simply becomes 2MB, so the object is still humongous, now wasting 25% of every region it occupies, while halving the total region count coarsens every other pause-time decision G1 makes. Likewise, raising -XX:G1HeapWastePercent only delays mixed-GC reclamation; it doesn't touch the Heap_lock contention or the TLAB bypass that actually caused the pause.

Fix the allocation pattern, not the collector

Pool and reuse the oversized buffers instead of allocating them per request. A bounded, lock-free pool eliminates the humongous allocation rate directly:

BufferPool.java — condensed
public final class BufferPool {
    private static final int BUFFER_SIZE = 3 * 1024 * 1024;   // 3MB
    private static final int MAX_POOL_SIZE = 512;              // caps footprint at 1.5GB
    private final ConcurrentLinkedQueue<byte[]> pool = new ConcurrentLinkedQueue<>();

    public byte[] acquire() {
        byte[] buf = pool.poll();
        return buf != null ? buf : new byte[BUFFER_SIZE];   // fallback allocation
    }

    public void release(byte[] buf) {
        if (buf != null && buf.length == BUFFER_SIZE) pool.offer(buf);
        // full pool: let GC reclaim naturally
    }
}
Virtual thread trap

Do not back this pool with a ThreadLocal if your platform runs Virtual Threads — thread-locals pin memory per virtual thread and can themselves cause an OOM at scale. Use a bounded concurrent structure instead.

Migrate or fix?

Fix the allocation pattern and stay on G1 — it has lower CPU overhead than a concurrent collector once humongous allocations are gone. Only migrate to Generational ZGC if the allocation code is genuinely off-limits; expect roughly a 10% CPU tax and account for the loss of Compressed Oops (below).

04

Shenandoah & SATB

Symptom: steady heap growth during a concurrent cycle, then a multi-second "Degenerated GC" stop-the-world pause.

Shenandoah is fully concurrent: it evacuates live objects while the application keeps mutating the graph. To keep marking correct under that mutation, it uses the tri-color abstraction plus a Snapshot-At-The-Beginning (SATB) write barrier.

ColorMeaning
WhiteUnvisited — a garbage candidate.
GreyVisited, but its own fields haven't been scanned yet.
BlackVisited and fully scanned — guaranteed live.

Corruption risk: a mutator writes a reference to a White object into a Black object while destroying the only path from Grey. SATB's rule closes this: no object reachable at the start of the cycle may be lost. Every reference overwrite first logs the old value into a thread-local SATB queue — "greying" it — rather than requiring a re-scan of the whole graph.

Fig. 4 — SATB pre-write barrier
flowchart LR
  A["Mutator writes new_value\ninto field"] --> B{"GC cycle active?"}
  B -- no --> F["Write proceeds\n(no barrier cost)"]
  B -- yes --> C{"old_value != null\nand unmarked?"}
  C -- no --> F
  C -- yes --> D["Enqueue old_value\ninto thread-local SATB buffer"]
  D --> E["old_value is now Grey"]
  E --> F

Floating garbage & the 40GB → 62GB climb

Because SATB keeps every object reachable at cycle-start alive for the whole cycle, anything that dies mid-cycle becomes floating garbage — uncollectable until the cycle ends. Under a burst of high-frequency mutation (order-book updates, in the source scenario), a 10-second concurrent cycle can pin tens of gigabytes of dead objects that would otherwise have been reclaimed immediately.

Shenandoah vs. G1's incremental update

G1 also uses SATB for marking, but leans on Remembered Sets for cross-region pointers and its evacuation phase is itself stop-the-world — so an overflowing SATB queue degrades throughput without necessarily forcing an emergency pause. Shenandoah's evacuation is concurrent; if its SATB queues overflow, marking cannot complete, evacuation can't be scheduled, and the JVM falls back to a synchronous Degenerated GC to finish the work.

FlagDefaultEffect
-XX:ShenandoahSATBBufferSize1024Thread-local buffer capacity. Larger = fewer global-queue flushes, less lock contention, more footprint at safepoints.
-XX:ShenandoahSATBBufferFlushInterval~10msForced periodic flush even if the buffer isn't full. Larger = less CPU, slower recognition of newly-greyed objects.

These knobs trade memory and safepoint time for pause resilience — they buy headroom, they don't fix a sustained high-mutation-rate workload.

volatile is SATB-unfriendly

A volatile reference write forces a full memory barrier (e.g. lock addl) in addition to the SATB pre-barrier, stalling the CPU pipeline and flushing store buffers. AtomicReferenceFieldUpdater.lazySet() uses Release semantics instead — it still triggers SATB on a successful CAS, but skips the heavy StoreLoad barrier, and a failed CAS enqueues nothing at all.

The real fix: eliminate reference churn

Primitives are invisible to SATB — mutating a long or double triggers zero write barriers. On hot paths, replace mutated object graphs (linked orders, mutable node chains) with flat primitive arrays (AtomicLongArray, off-heap buffers, ring buffers). This is an architectural fix, not a tuning one — flag changes only delay the same failure under sustained load.

05

ZGC & RSS bloat

Symptom: pause times are excellent, but RSS runs far above the heap size and Kubernetes OOMKills the pod.

ZGC avoids external per-object GC metadata by embedding state directly into the pointer itself — a colored pointer.

64-bit reference layout (non-generational ZGC)
+-------------------+-+----+-----------------------------------------------+
| 18-bit unused      |G|Colr| 42-bit object address                          |
+-------------------+-+----+-----------------------------------------------+

The color bits encode Marked0, Marked1, or Remapped. Dereferencing a colored pointer directly would crash the CPU, so ZGC maps the same physical memory into three separate virtual address ranges (multi-mapping) — the MMU resolves whichever colored address the pointer carries to the one physical page, with no per-access bitmask instruction required.

Fig. 5 — One physical page, three virtual views
flowchart LR
  P["Physical page\n(1x actual RAM)"]
  M0["Virtual: Marked0"] --> P
  M1["Virtual: Marked1"] --> P
  R["Virtual: Remapped"] --> P

Why RSS looks 80% over heap size

LayerWhat it measures
Committed (virtual)Address space the JVM has claimed; may not be backed by RAM yet.
RSS (physical)Physical RAM actually resident right now.
Page tablesKernel structures mapping virtual → physical. Tripled by multi-mapping — on a 1TB heap this alone can cost 16–24GB.

Beyond the page-table tax, ZGC's ZPageCache deliberately retains freed physical pages rather than returning them, because returning memory is expensive:

The cost of giving memory back
madvise(addr, length, MADV_DONTNEED);
// synchronous — walks kernel page tables, triggers a TLB shootdown
// across every core. If the memory is needed again soon, the next
// touch is a real page fault: zero, map, and stall the thread.

Set -XX:ZUncommitDelay too low on a bursty workload and you get thrashing: free → uncommit → burst traffic → page fault → re-allocate → repeat — burning the exact throughput ZGC was meant to protect.

Diagnosing bloat: page cache vs. real leak

Native Memory Tracking
# boot with -XX:NativeMemoryTracking=detail
jcmd <pid> VM.native_memory baseline
# ...wait for RSS to climb...
jcmd <pid> VM.native_memory detail.diff

If growth sits under the GC category (and roughly tracks active heap usage), it's ZPageCache retention — expected. If Internal, Symbol, or Compiler categories climb continuously instead, that's a genuine native leak, not ZGC.

Tuning a 1TB heap on a 1.2TB K8s limit
-XX:+UseZGC -XX:+ZGenerational
-Xms850g -Xmx850g                 # leave ~370GB for off-heap + page tables + OS
-XX:+ZUncommit -XX:ZUncommitDelay=120
-XX:ZCollectionInterval=60        # force periodic reclaim during quiet windows
-XX:ZFragmentationLimit=20
-XX:NativeMemoryTracking=summary

# node level — never leave THP at "always" under ZGC
echo madvise > /sys/kernel/mm/transparent_hugepage/enabled
echo madvise > /sys/kernel/mm/transparent_hugepage/defrag
No Compressed Oops

ZGC cannot use -XX:+UseCompressedOops — every reference is a full 64-bit pointer. Moving from G1 to ZGC can inflate your object-graph footprint by 20–30% below 32GB, and materially more at scale. Budget for it before you set -Xmx.

06

Comparison matrix

Pick by constraint, not by reputation.

CollectorTypical pauseThroughput vs. G1Compressed OopsBest forAvoid when
G110s–100s msbaselineyesGeneral-purpose, balanced latency/throughputSustained humongous allocation just above region size
Shenandoah< 10ms−10–15%yesLow-latency with a live-graph mutation rate you can controlVery high reference-mutation-rate hot paths (SATB overflow risk)
ZGC (Generational)< 1ms−10–15%noMulti-terabyte heaps, sub-ms SLAsMemory-constrained containers without a real RSS budget
Epsilon0 (no GC)0 overheadn/aShort-lived processes, bounded/known allocation, benchmarkingAny long-running or memory-unpredictable service — guaranteed OOM
Fig. 6 — Which collector?
flowchart TD
  Q1{"Process is short-lived\nwith bounded, known allocation?"}
  Q1 -- yes --> EPS["Epsilon"]
  Q1 -- no --> Q2{"Pause SLA?"}
  Q2 -- "no strict SLA" --> G1["G1"]
  Q2 -- "< 10ms" --> Q3{"Heap fits in\n32GB with room\nto lose Compressed Oops?"}
  Q3 -- "small / mid heap" --> SH["Shenandoah"]
  Q3 -- "huge heap, sub-ms" --> ZG["Generational ZGC"]
07

Reference types & the ReferenceHandler bottleneck

Symptom: 80% CPU in ReferenceQueue.remove()/enqueue(), a multi-million backlog, throughput collapses under load.

SoftReference is not an application cache-eviction strategy. It is a JVM-managed emergency release valve, and treating it as a cache primitive for tens of millions of entries reliably collapses under load.

Why there's exactly one ReferenceHandler thread

When the GC determines an object is only softly/weakly/phantomly reachable, it appends it to a single, global, JVM-internal pending list — not directly to your ReferenceQueue. One high-priority daemon thread, the ReferenceHandler, drains that list and hands entries off to their Java-level queues. The single-thread design simplified JVM internals early on; at 50M references and 50K req/s it becomes the application's choke point.

Fig. 7 — The JVM → Java reference handoff
sequenceDiagram
  participant GC as Garbage Collector
  participant PL as Reference.pending (global list)
  participant RH as ReferenceHandler (1 thread)
  participant RQ as Your ReferenceQueue
  participant APP as 64 application threads

  GC->>PL: discovers cleared soft/weak/phantom refs
  loop single-threaded drain
    RH->>PL: poll (lock-free CAS)
    RH->>RQ: enqueue()
  end
  APP->>RQ: remove() — blocks on empty / contends under backlog

The enqueue path is lock-free, but "lock-free" is not "free": atomic CAS on the pending-list head under sustained load triggers the MESI cache-coherency protocol to bounce that cache line across NUMA nodes on every write — hundreds of stalled cycles per operation, showing up as CPU time spent on hardware synchronization instead of business logic.

Why SoftReference eviction is unpredictable under G1

The SoftReference LRU heuristic
eligibleIdleTime = (MaxMemory - UsedMemory) * SoftRefLRUPolicyMSPerMB

G1 collects incrementally by region and has no global, real-time view of every soft reference's last-access time. Soft references in a hot young-gen region can be cleared prematurely while stale ones in an untouched old-gen region linger — and when memory pressure finally forces a clear, it clears everything past the threshold at once.

Thundering herd

Clearing millions of soft references simultaneously means millions of concurrent cache misses. Every one of those 64 application threads goes to the database at once — the cache stampede is usually worse than the memory pressure that triggered it.

Cleaner (Java 9+) does not fix this. It still registers objects as PhantomReferences under the hood — they must still pass through the same single-threaded ReferenceHandler handoff before Cleaner's own thread pool ever sees them.

The fix: stop using JVM reference tracking for caching

Caffeine (W-TinyLFU) replacement
Cache<CacheKey, CacheValue> cache = Caffeine.newBuilder()
        .maximumSize(maxEntries)                    // bounded — no OOM
        .expireAfterAccess(Duration.ofMinutes(10))   // predictable, not heap-pressure-driven
        .recordStats()
        .build();
jstat -gc metricSoftReference cacheCaffeine (W-TinyLFU)
YGC countvery highlow / stable
YGC time~150ms/pause<10ms/pause
Full GC countfrequent~0
Total GC time80%+ of CPU<2% of CPU
Team standard

On-heap caching up to 10–20GB → Caffeine. Above ~32GB or tens of millions of entries → an off-heap store (Chronicle Map, OHC) that bypasses the collector entirely. Reject any PR that builds custom caching on java.lang.ref classes.

08

Metaspace & ClassLoader leaks

Symptom: OutOfMemoryError: Metaspace after repeated hot-reload / plugin-reload cycles, even though "unloaded" classes should be gone.

A class is identified by its binary name plus its defining ClassLoader — so a class can only be unloaded once its ClassLoader is completely unreachable. Two distinct events are often conflated:

  • Class unloading — a logical marking during a GC cycle once the loader is confirmed unreachable.
  • Metadata deallocation — the physical reclaim of native memory, deferred until every class in a shared Metachunk is unloaded. One pinned class can hold an entire chunk's memory hostage.

Why G1 can delay this by minutes on an under-utilized heap

G1 unloads classes concurrently during its Remark phase, but that phase is triggered by old-gen occupancy crossing -XX:InitiatingHeapOccupancyPercent (default 45%). On a 32GB heap with a 2GB working set, that threshold may never trip — Metaspace grows completely decoupled from heap-driven GC triggers until it hits MaxMetaspaceSize on its own.

The two things quietly pinning your ClassLoader

Proxy cache reference chain

AppClassLoader (parent, immortal)
  └──> java.lang.reflect.Proxy$ProxyCache
        └──> CacheKey (interfaces from AppClassLoader)
              └──> generated Proxy class (defined by RestartClassLoader)
                    └──> RestartClassLoader  [LEAKED]

JDK dynamic proxies cache generated classes keyed by defining loader + interfaces. Because the parent loader is immortal, that cache entry is never evicted — pinning every class the child loader ever defined.

SoftReference caches in Spring / CGLIB

Using the same LRU formula from §07: on a 32GB heap with 30GB free, SoftRefLRUPolicyMSPerMB=1000 (the default) computes an eligible idle time of roughly 347 days. With no real heap pressure, the GC simply refuses to clear these caches — and they're anchoring your restart-scoped loader the entire time.

Fig. 8 — Heap dump triage for a ClassLoader leak
flowchart TD
  A["GC.class_histogram shows\nrising ClassLoader instance count"] --> B["jcmd <pid> GC.heap_dump"]
  B --> C["Open in Eclipse MAT"]
  C --> D["Histogram → find your\ncustom ClassLoader class"]
  D --> E["Right-click → Path to GC Roots\n(exclude weak/soft refs)"]
  E --> F{"Root is..."}
  F --> G["a Thread's threadLocals\n→ ThreadLocal leak"]
  F --> H["an unterminated Thread\n→ still running / never joined"]
  F --> I["a static field\n→ global cache/singleton"]
  F --> J["JMX MBeanServer\n→ registered, never unregistered"]
Prove it with unified logging
-Xlog:class+unload=info,gc+metaspace=debug,gc+class*=debug:file=/var/log/app/gc_metaspace.log:time,uptime,level,tags

Seeing [debug][gc,class,load] entries with zero corresponding class,unload entries across a Full GC is a confirmed hard leak, not a timing artifact.

Fixing it without disabling hot reload
  • -XX:SoftRefLRUPolicyMSPerMB=0 — clear soft references aggressively in dev/staging. Trade-off: more CPU rebuilding metadata caches, but classloader pinning stops.
  • Tighten Metaspace resize coefficients (-XX:MetaspaceSize, -XX:MinMetaspaceFreeRatio, -XX:MaxMetaspaceFreeRatio) so growth triggers a concurrent cycle sooner.
  • On context shutdown, explicitly clear framework caches: Introspector.flushCaches(), ReflectionUtils.clearCache(), then a staging-only System.gc() hint.
Raising MaxMetaspaceSize only buys time

If each restart leaks a few MB linearly, doubling the ceiling to 2GB just moves the crash from 4 hours to 16 — and lengthens the eventual Full GC pause once the JVM finally has to scan a much larger, more fragmented Metaspace.

09

Diagnosing memory leaks in production

Symptom: old-gen collections get progressively more expensive, and the post-Full-GC baseline never returns to where it started.

A JVM leak is not lost memory in the C sense — it's unused but still-reachable memory. Something is holding a chain of strong references from a GC root (a static field, an active thread stack, a stale ThreadLocal entry) to objects the application logically no longer needs.

Leak vs. allocation churn — the distinguishing test

High allocation rate (churn)Real memory leak
SymptomFrequent minor GCs, high CPU in GCOld-gen baseline climbs over time
After a Full GCHeap returns to baselineBaseline itself keeps rising
Root causeObjects die young, as expectedObjects are promoted and survive Full GC

Triage order — cheapest signal first

  1. GC logs — does post-Full-GC heap occupancy trend upward across days? That alone confirms a leak.
  2. jcmd <pid> GC.class_histogram — snapshot, wait an hour, diff. Look for classes with continuously growing instance counts.
  3. JFR with jdk.OldObjectSample — near-zero overhead, samples objects that survive into old-gen and reconstructs their allocation stack trace and GC-root reference chain.
  4. Isolated heap dump — only if JFR is inconclusive, and only after draining traffic (below).
Start a production-safe JFR recording
jcmd <pid> JFR.start name=LeakDetection duration=15m filename=leak.jfr settings=profile
The cascading-outage trap

jcmd <pid> GC.heap_dump is a Stop-The-World operation — on a 32GB heap, writing the .hprof file can pause the JVM for 10–30 seconds. A live node that stops responding to health checks gets marked dead by the load balancer, and its traffic reroutes onto the remaining nodes — which can then fail too. Always drain the node from the load balancer before taking a heap dump.

Common leak sources

  • Unbounded caches — a raw ConcurrentHashMap used as a cache with no eviction policy.
  • ThreadLocal leaks — pooled threads that never call .remove(); see the fix below.
  • Metaspace / ClassLoader leaks — dynamic-proxy frameworks that fail to unload (§08).
  • Off-heap / native leaks — direct ByteBuffers or Netty buffers that bypass the heap entirely and are invisible to heap-only tooling; confirm with -XX:NativeMemoryTracking=detail.
ThreadLocal (leak-prone) → ScopedValue (Java 21, leak-proof)
// Leak-prone: relies on every code path reaching finally { remove() }
private static final ThreadLocal<UserSession> SESSION = new ThreadLocal<>();
void process(UserSession s) {
    SESSION.set(s);
    try { doWork(); } finally { SESSION.remove(); }   // miss this once, and it leaks
}

// Leak-proof: binding is scoped to run() and cleared automatically on exit
private static final ScopedValue<UserSession> SESSION = ScopedValue.newInstance();
void process(UserSession s) {
    ScopedValue.where(SESSION, s).run(this::doWork);   // no remove() to forget
}
Code review policy

Ban raw ThreadLocal for new contextual-data code; require ScopedValue (Java 21+). Every static cache needs an explicit max size or TTL — no exceptions.

10

Flags cheat sheet

Every flag referenced above, in one table.

FlagCollectorEffect
-XX:+UseZGC -XX:+ZGenerationalZGCEnable Generational ZGC (Java 21+; preferred over legacy single-gen ZGC).
-XX:ZUncommitDelay=<s>ZGCHow long a page sits idle in ZPageCache before uncommitting. Too low → thrashing on bursty load.
-XX:ZCollectionInterval=<s>ZGCForce a cycle at a fixed interval so idle pages become eligible to uncommit.
-XX:ZFragmentationLimit=<%>ZGCHow aggressively ZGC compacts; lower reclaims more but costs more CPU.
-XX:NativeMemoryTracking=detailanyEnables jcmd VM.native_memory — the only reliable way to separate GC page retention from a real native leak.
-XX:ShenandoahSATBBufferSize=<n>ShenandoahThread-local SATB buffer capacity before a global-queue flush.
-XX:ShenandoahSATBBufferFlushInterval=<ms>ShenandoahForced periodic SATB buffer flush.
-XX:G1HeapRegionSize=<n>G1Manual region size override. Changes the humongous threshold (50% of region) — verify it actually clears your object size.
-XX:G1HeapWastePercentG1Tolerated old-gen waste before a mixed GC. Raising it delays reclamation — a band-aid, not a fix.
-XX:InitiatingHeapOccupancyPercentG1Old-gen occupancy that triggers concurrent marking (and thus class unloading). Default 45% — can stay untripped on under-utilized heaps.
-XX:SoftRefLRUPolicyMSPerMB=<ms>anyms of survival per MB of free heap for a SoftReference. Set to 0 to clear aggressively (e.g. hot-reload staging).
-XX:MaxMetaspaceSize / MetaspaceSizeanyMetaspace ceiling / initial size. Raising the ceiling alone only postpones a linear leak.
-XX:+ParallelRefProcEnabledanyParallelizes reference processing during STW phases — without it, millions of references serialize through one thread mid-pause.
-Xlog:gc+humongous=debugG1Surfaces humongous allocation events that generic "Young GC" reporting hides.
-Xlog:class+unload=info,gc+metaspace=debuganyProves whether classes are actually being unloaded vs. merely marked.
11

Pitfalls checklist

Every "gotcha" from the source material, grouped by failure mode. Skim before a design or code review.

Allocation-path design

  • Hot-path mutable object graphs (linked nodes, mutated references) fight every concurrent collector, not just Shenandoah — prefer flat primitive layouts.
  • Objects allocated just above a region's humongous threshold cause disproportionate pauses; check gc+humongous logs, not just generic pause metrics.
  • Pool oversized/short-lived buffers rather than allocating per request — but never back a pool with ThreadLocal under Virtual Threads.

Memory visibility & RSS

  • ZGC's multi-mapped page tables have a real, invisible-to-heap-monitoring memory cost — budget for it explicitly on constrained containers.
  • Leave THP at madvise, never always, under ZGC or Shenandoah — khugepaged defragmentation causes latency spikes and fragmentation-driven bloat.
  • A flat heap-usage graph with climbing OS process memory means a native leak — heap dumps and JFR heap events won't show it; use NMT.

Reference processing

  • Never build an application cache on SoftReference/WeakReference — use Caffeine (on-heap) or an off-heap store at scale.
  • Cleaner does not bypass the single-threaded ReferenceHandler — it still queues through the same bottleneck.
  • An unpolled ReferenceQueue is a silent, permanent leak of the Reference wrapper objects themselves.

Metaspace & ClassLoaders

  • Under-utilized heaps can suppress concurrent marking (and thus class unloading) indefinitely — Metaspace pressure alone won't trigger it under G1's default IHOP.
  • Framework SoftReference caches (CGLIB, Spring) can pin a ClassLoader for months on a large, low-pressure heap — do the LRU-formula math before assuming they'll clear.
  • System.gc() is a hint, not a guarantee — relying on it to clear a leaked ClassLoader is futile.

Diagnostics process

  • Never take a heap dump on a live production node without draining it from the load balancer first — the STW pause can cascade into a cluster-wide outage.
  • A larger heap reduces Full GC frequency, which hides a leaking Metaspace/ClassLoader for longer, not less.
  • Prefer JFR (<1–2% overhead, always-on-safe) over heap dumps (STW, seconds of pause) as your default diagnostic tool.
12

Sources

Synthesized from these internal Q&A deep-dives. Each contains the full scenario, code walkthroughs, and team-recommendation writeups this reference condenses — pull the original for complete context.

  • Shenandoah GC SATB Queue Overflow and Degenerated GC Mitigation2026-07-19
  • ZGC Multi-Mapping and RSS Bloat: Tuning 1TB Heaps in Kubernetes2026-07-19
  • The SoftReference Cache Trap: ReferenceHandler Bottlenecks and High-Throughput Eviction Redesign2026-07-19
  • Metaspace Leak Diagnosis: Classloader Leaks, G1GC Dynamics, and SoftReference Retention in Spring DevTools2026-07-19
  • G1GC Humongous Allocation Pathology: Diagnostics, Internals, and Architectural Remedies2026-07-19
  • A Java microservice memory leak diagnosis (allocation churn vs. leak, JFR, ScopedValue)2026-07-07
  • ClassLoader Leak Diagnosis and GC Eligibility2026-04-07
  • ZGC for Low Latency and Epsilon GC's Niche2026-02-24
  • Detecting Memory Leaks Caused by Java Threads2026-03-19
  • Java's Memory Management for Unreachable Objects: A Deep Dive into Garbage Collection2026-03-22