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
Heap (512 MB) — eden 128 MB · old gen 384 MB, state at end of a 60s run
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
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
| Workload | Pick | Because |
|---|---|---|
| Overnight batch / ETL | ParallelGC | Only total runtime matters; pauses are free |
| Typical web service / API | G1 (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) | SerialGC | What you're getting anyway — and it's fine at that scale |
| Allocation benchmarking / zero-garbage design | EpsilonGC | Removes 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.