Module 10

Garbage collection & the JVM's options

Java frees you from calling free() — the JVM finds unreachable objects and reclaims them automatically. The catch: reclamation costs CPU, and some of that cost arrives as stop-the-world pauses where every application thread freezes. For a pricing service, a pause is missed requests; for a trading system, a 500ms pause during a market spike is risk. GC management is the art of choosing where those costs land.

The generational hypothesis— the empirical fact all modern collectors exploit — says most objects die young (a request's temporaries) while the few survivors live long (a cached PairSeries). So the heap splits into a small young generation(eden + survivor spaces) collected often and cheaply by copying the few live objects out, and a large old generationreceiving the survivors (“promotion”), collected rarely and expensively. A minor GC sweeps eden in milliseconds; a full GC over the old generation is the pause you architect to avoid.

The collector menu — the JVM ships several, selected with one flag:

  • Serial (-XX:+UseSerialGC) — one thread, smallest footprint. The default the JVM silently picks in small containers (<2 CPUs or <~1.8 GB) — which means many “G1” cloud services are actually running Serial.
  • Parallel (-XX:+UseParallelGC) — all cores, maximum throughput, doesn't care about pause length. Right for batch jobs where total runtime is all that matters.
  • G1 (-XX:+UseG1GC, the general default since JDK 9) — region-based, pause-target-driven (-XX:MaxGCPauseMillis=200): collects incrementally in “mixed” cycles to keep pauses near the target. The balanced choice for most services.
  • ZGC (-XX:+UseZGC, generational since JDK 21) — (sub-)millisecond pauses independent of heap size; almost all work runs concurrently with the application. The latency collector for trading and market-data systems — paid for with some throughput and memory overhead. (Shenandoah is the same goal from Red Hat.)
  • Epsilon (-XX:+UseEpsilonGC) — the no-op collector: allocates and never frees; the process dies when the heap fills. For benchmarking allocation costs, and the logical endpoint of the HFT style where code pools every object and produces zero garbage — if you never collect, the collector can't pause you.

The flags that matter in practice: heap size (-Xmx/-Xms, or in containers -XX:MaxRAMPercentage— the beta service's Cloud Run deployment sets 75% instead of the timid 25% default), the collector flag, the pause target, and — before touching any of them — -Xlog:gc to see what the collector is actually doing. Senior rule: GC tuning without GC logs is astrology.

🎛 GC simulator

-XX:+UseG1GC
60 MB/s
10%

Heap (512 MB) — eden 128 MB · old gen 384 MB, state at end of a 60s run

eden (churns)
old gen used
free

GC pauses over 60s (bar height = pause length)

minor (eden) · mixed cycle

max pause

120 ms

total paused

0.54 s

throughput

99.1%

collections

28 + 1

minor + major

G1: worst pause 120ms, throughput 99.1%. Acceptable for most services; drop the long-lived fraction and pauses shrink further — allocation profile beats collector choice.

Deterministic, illustrative simulation — real pause times vary by heap size, hardware and JDK version; the shapes (minor vs major, throughput-vs-latency trades, Epsilon's cliff) are what generalize. Educational tool — not investment advice.

Choosing a collector — the decision in one table

WorkloadPickBecause
Overnight batch / ETLParallelGCOnly total runtime matters; pauses are free
Typical web service / APIG1 (default)Balanced; tune with MaxGCPauseMillis
Latency-critical, big heap (trading, market data)ZGC<1ms pauses at any heap size; buy latency with throughput
Tiny container (1 vCPU, 1 GB)SerialGCWhat you're getting anyway — and it's fine at that scale
Allocation benchmarking / zero-garbage designEpsilonGCRemoves the collector as a variable entirely

In the beta service: the best GC strategy is allocating less

The engine's codebase contains zero GC code — no System.gc(), no reference types, no collector flags — and that's the lesson. Its design starves the collector of work: the primitive arrays of Module 2 turn a 40-year ticker into two objects instead of twenty thousand boxed ones; the big structures are built once at startup, promote to the old generation, and never churn; the bounded LRUcaps memory by design; and a warm query allocates almost nothing (the JIT's escape analysis can often keep the per-day WindowMomentsrecord off the heap entirely). Deployed on a 1-vCPU Cloud Run container, it runs whatever collector the JVM picks — most likely Serial — and it simply doesn't matter, because after warmup there's nothing to collect. Tune your allocation profile first; the collector choice is what's left over.

Things to try

  • • Run Serial at a high allocation rate — watch old gen fill and the full-GC pause bar dwarf everything. That's the pause a risk engine can't afford at 9:30:01.
  • • Same workload on ZGC — pauses flatten to slivers while throughput dips slightly. That's the trade.
  • • Set long-lived fraction near zero — minor GCs become nearly free on every collector. That's the generational hypothesis paying out, and the beta service's allocation profile.
  • • Pick Epsilon and watch the heap march to OOM — then imagine the object-pooling discipline needed to run a trading day without it.