02 — Memory & Garbage Collection

Garbage collection is the single most-asked JVM study topic and the single most-misunderstood in production. Almost every “the service is slow” ticket in a Java shop eventually touches GC: pauses too long, allocation rate too high, wrong collector for the workload, heap too small (or too large, which is also a thing). Your job in this file is to build a model precise enough to predict what the collector will do next, not just describe it after the fact.

The most important idea in the whole chapter is one line: most objects die young. Every practical Java collector is built around exploiting that fact. Everything else — regions, phases, pauses — is engineering to make the exploitation cheap.

1. Heap Layout (G1, the Default)

Since Java 9, G1 (Garbage-First) has been the default collector. G1 divides the heap into ~2048 equal-sized regions (typically 1–32 MB each), and each region is dynamically labeled as Eden, Survivor, Old, or Humongous. This is different from the older Parallel/CMS “contiguous Eden and Old” layout you may have read about.

 [ E ][ E ][ S ][ O ][ O ][ H ][ - ][ E ][ O ][ S ] ...
  Eden    Survivor  Old     Humongous  Free   ...

Region role

What lives there

Eden

Fresh allocations. Most objects die here.

Survivor (S0/S1)

Objects that survived one Young GC. Copied between S0 and S1 each cycle.

Old (Tenured)

Objects that survived ~15 Young GCs (-XX:MaxTenuringThreshold).

Humongous

Any single object > 50 % of a region. Allocated directly in Old regions.

Humongous allocations are the sneaky one — a single 20 MB byte[] can be the reason your app allocates in Old and triggers Mixed GCs earlier than expected. -Xlog:gc+humongous will show them.

2. The Generational Hypothesis

Studies from the 1980s onward keep confirming it: the vast majority of objects (>90 %, often >98 %) become unreachable within a few milliseconds of allocation. This is why collectors are generational: they collect Young frequently and cheaply, Old rarely and expensively.

  • Young GC (a.k.a. minor): scans only Eden + Survivors. Fast (typically 10–100 ms on G1). Uses copying — live objects copied out, dead objects reclaimed by wiping the whole region.

  • Mixed GC (G1-specific): collects all Young plus some Old regions chosen by the concurrent mark’s liveness data. This is how G1 avoids Full GC while keeping Old size in check.

  • Full GC (a.k.a. major): entire heap, single-threaded fallback in G1 (multi-threaded on Parallel). Long pause. If your prod app is doing Full GCs, something is wrong.

Concurrent Mark runs alongside your application. When Old occupancy crosses InitiatingHeapOccupancyPercent (default 45 %), G1 starts marking Old regions to know which ones to collect in the next Mixed cycle.

3. Collectors: When to Pick What

Modern OpenJDK ships five production collectors. Pick by workload, not by fashion.

Collector

Flag (Java 21)

Pause profile

Best for

Serial

-XX:+UseSerialGC

Long pauses, single-threaded

Small heaps (<100 MB), CLI tools, containers with 1 CPU

Parallel

-XX:+UseParallelGC

Batch-style, long throughput-first pauses

Batch/ETL where throughput > latency, no user-facing SLO

G1 (default)

-XX:+UseG1GC

Predictable, target ~200 ms

Most services. Heaps 4 GB–64 GB. Default for a reason.

ZGC (generational since Java 21)

-XX:+UseZGC (-XX:+ZGenerational before J21)

Sub-millisecond pauses

Large heaps (16 GB–TB), latency-critical, memory to spare

Shenandoah

-XX:+UseShenandoahGC (OpenJDK builds only)

Sub-10 ms pauses

Same niche as ZGC; Red Hat’s answer

Generational ZGC (Java 21+). Before Java 21, ZGC was non-generational — it walked the whole heap every cycle. Since JEP 439, ZGC has a Young generation, closing most of its throughput gap with G1 while keeping its <1 ms pauses. If you’re on Java 21+ with a big heap and pause-sensitive workload, ZGC is genuinely competitive with G1 for the first time.

Rule of thumb:

  • Default to G1. It is boring and works.

  • If p99 pauses are your SLA and you have a big heap, benchmark ZGC.

  • If you’re on Java 8 with -XX:+UseConcMarkSweepGC — stop. CMS was removed in Java 14. Upgrade the JVM and switch to G1.

4. Tuning Flags That Matter

Set heap size explicitly in production. The defaults are conservative and container-unaware on older JVMs.

-Xms4g -Xmx4g              # Equal min/max: no resizing pauses, predictable RSS
-XX:+UseG1GC               # Explicit default (harmless to state)
-XX:MaxGCPauseMillis=200   # G1 target pause. Not a guarantee — a hint.
-XX:MaxMetaspaceSize=512m  # Cap Metaspace to prevent runaway class-loader leaks
-XX:MaxDirectMemorySize=1g # Cap direct ByteBuffers (Netty!)
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/heapdumps/
-XX:+ExitOnOutOfMemoryError    # In containers, prefer crash + restart to zombie process

Containers. Use -XX:MaxRAMPercentage=75.0 (or set -Xmx explicitly). Pre-JDK 10, the JVM saw the host’s RAM, not the cgroup limit; the pod would then be OOM-killed by the kernel with zero JVM signal. Modern JVMs are container-aware but only if you’re on Java 10+ and the flag is set.

Do not blindly copy flags like -XX:NewRatio, -XX:SurvivorRatio, -XX:MaxTenuringThreshold from blog posts. G1 sizes generations dynamically; overriding these often makes things worse.

5. Reading GC Logs

Java 9 unified logging: -Xlog:gc* replaced the two dozen legacy flags. Use this in production:

-Xlog:gc*,gc+age=trace,safepoint:file=/var/log/gc.log:time,uptime,level,tags:filecount=10,filesize=100M

A typical G1 Young GC line:

[2.345s][info][gc] GC(4) Pause Young (Normal) (G1 Evacuation Pause) 512M->128M(2048M) 45.231ms

Read it as: at 2.345s uptime, GC number 4 was a Young pause; the heap went from 512 MB used before to 128 MB used after in a 2048 MB heap; the pause took 45 ms.

What to look for when a service is unhappy:

  1. Frequency. Young GCs every 100 ms = allocation-rate problem, not GC problem. Fix the allocator (Async Profiler allocation mode, file 04).

  2. Duration. Young GCs >200 ms on a 4 GB heap = Survivor sizing wrong, or huge live set, or humongous allocations.

  3. Post-GC heap. Heap after Young GC keeps growing = objects are being promoted to Old that shouldn’t be = tune allocation churn or increase Young size.

  4. Full GCs. Any Pause Full line is a bug in prod. Chase it.

  5. to-space exhausted — G1 ran out of survivor space and had to fall back. Add heap or reduce allocation rate.

Tools: GCViewer (open-source) and GCEasy (free web tool at gceasy.io) both consume the log and produce graphs. Learn one, use it every time.

6. Object Allocation Path (What new Actually Does)

An allocation from your Java code hits several fast paths before it ever pauses:

  1. TLAB (Thread-Local Allocation Buffer). Each thread has a small pre-allocated chunk of Eden. new bumps a pointer inside the TLAB — no locking, no atomic ops. Blazing fast.

  2. TLAB refill. When the current TLAB is full, the thread atomically claims a new chunk of Eden.

  3. Eden exhausted. Triggers a Young GC.

  4. Humongous path. Objects >50 % of a region skip Eden entirely and go straight to a Humongous region in Old.

Why this matters: allocation is often “free” (a pointer bump). The cost shows up later when the object dies and GC has to scan. This is the mental flip that separates junior thinking (“is new slow?”) from senior thinking (“what’s the allocation rate, and where does the garbage go to die?”).

⚠️ What Most People Get Wrong

“Add more heap and the GC problem goes away.” Sometimes. Other times, doubling -Xmx doubles Full GC pause time because there’s twice as much live set to walk. And a very large young generation means more objects get promoted before they die, polluting Old. The right question is “why is my allocation rate 2 GB/s?”, not “can I have 32 GB of heap?”. Measure allocation rate with JFR’s TLABAllocation event or Async Profiler -e alloc (file 04).

“CMS is fine.” CMS was deprecated in Java 9 and removed in Java 14. If you find it in production, treat it as a migration ticket. G1 is the default replacement. Do not tune around a dead collector.

Recap

  • G1 splits heap into ~2048 regions dynamically labeled Eden/Survivor/Old/Humongous.

  • Most objects die young; that’s why we have generational GC.

  • Pick G1 by default; ZGC for latency-critical large heaps on Java 21+.

  • Set -Xms=-Xmx, cap Metaspace and DirectMemory, always enable -XX:+HeapDumpOnOutOfMemoryError in prod.

  • Read GC logs for frequency, duration, post-GC heap size, Full GCs.

  • Allocation is a pointer bump into a TLAB — the cost is deferred to collection.


Return to README.md · Previous: 01_jvm_architecture.md · Next: 03_jit_and_optimization.md