Why garbage collection is an interview staple
GC problems — long pauses, OOM errors, throughput degradation — are among the hardest issues to diagnose in production Java systems, and they often surface as latency spikes or cascading failures under load. Interviewers ask about GC to probe whether you understand how automatic memory management works, not just that it exists. This article gives you a thorough mental model of the full GC landscape.
The core idea: reachability, not reference counting
Java does not use reference counting (the way CPython does). Instead, an object is considered live if and only if it is reachable from a GC root — a locally determined set of objects guaranteed to be in use:
- Local variables in active stack frames
- Static fields of loaded classes
- JNI references held by native code
- Active thread objects
The GC traces the object graph from every root. Any object not found during that traversal is garbage and its memory can be reclaimed. This approach handles cyclic references automatically (two objects pointing at each other but unreachable from any root are both collected), which reference counting famously cannot do without extra machinery.
Mark-and-sweep — the foundational algorithm
All production JVM collectors are variations on mark-and-sweep:
- Mark — traverse the live object graph from GC roots; mark each live object.
- Sweep — scan the heap; reclaim any unmarked object's memory.
- Compact (in most modern collectors) — slide live objects together to eliminate fragmentation and reset the allocation pointer.
Before GC: [A live][B dead][C live][D dead][E live]
After mark: [A ✓] [B ] [C ✓] [D ] [E ✓]
After sweep:[A ] [free ] [C ] [free ] [E ]
After compact: [A][C][E][ free ]
Compaction is what makes bump-pointer allocation (just increment a pointer for each
new) possible after collection — it's why Java allocation is so fast.
Generational collection — the big performance win
Empirically, most objects die young (the weak generational hypothesis). A typical production allocation profile looks like:
- ~90 % of objects become garbage before their first GC
- ~9 % survive a few GC cycles
- ~1 % live for the lifetime of the application (singletons, caches)
Generational GC exploits this by splitting the heap:
┌─────────────────────────────────────────────────────┐
│ Java Heap │
│ ┌────────────────────────────┐ ┌─────────────────┐ │
│ │ Young Generation │ │ Old Generation │ │
│ │ ┌──────────┬────┬──────┐ │ │ (Tenured) │ │
│ │ │ Eden │ S0 │ S1 │ │ │ │ │
│ │ └──────────┴────┴──────┘ │ │ │ │
│ └────────────────────────────┘ └─────────────────┘ │
└─────────────────────────────────────────────────────┘
Minor GC (Young Gen only): objects that survive are copied between Survivor spaces
and age-counted. After reaching -XX:MaxTenuringThreshold (default 15) they are
promoted to Old Gen.
Full GC (entire heap): triggered when Old Gen fills up. It is expensive — sometimes many seconds — because the collector processes far more data.
The insight: by collecting the small Young Gen frequently, most garbage is reclaimed cheaply before it ever pollutes the expensive-to-collect Old Gen.
The GC collector lineup
Serial GC (-XX:+UseSerialGC)
Single-threaded, stop-the-world for all phases. Fastest per-collection on a single core; slowest absolute pause when the heap is large. Right for tiny heaps (< 256 MB), CLIs, or single-core containers.
Parallel GC (-XX:+UseParallelGC)
Multi-threaded stop-the-world. Maximises throughput — the fraction of CPU time your application runs rather than GC. All threads pause while GC threads work in parallel. The default before Java 9; still ideal for batch jobs that care only about total processing speed.
java -XX:+UseParallelGC -XX:GCTimeRatio=19 BatchJob # target 95 % app / 5 % GC
G1 GC (-XX:+UseG1GC) — default since Java 9
G1 breaks the heap into equal-sized regions (1–32 MB, configured with
-XX:G1HeapRegionSize). Each region is labelled Eden, Survivor, or Old dynamically.
G1 collects the regions with the most garbage first (hence "Garbage First"), meeting a
configurable pause target (-XX:MaxGCPauseMillis=200 by default).
java -XX:+UseG1GC -Xms4g -Xmx4g -XX:MaxGCPauseMillis=100 ApiServer
G1 has short STW phases (initial mark, remark) and long concurrent phases, keeping pauses in the tens of milliseconds range for heaps up to several GBs. It is the right default for most Java applications.
Humongous objects (> 50 % of region size) bypass Young Gen and land in dedicated
contiguous regions. Frequent large short-lived allocations can pressure G1 significantly
— increase -XX:G1HeapRegionSize to raise the humongous threshold.
CMS — deprecated and removed
Concurrent Mark-Sweep was the first low-latency collector. It had two problems: no compaction (leading to fragmentation and eventual "concurrent mode failure" full GCs) and high CPU overhead. Deprecated in Java 9, removed in Java 14. Use G1 instead.
ZGC (-XX:+UseZGC) — production since Java 15
ZGC targets sub-millisecond pauses regardless of heap size (tested at terabytes). It achieves this with:
- Colored pointers — GC metadata encoded in the unused upper bits of 64-bit references, avoiding per-object GC state.
- Load barriers — every pointer dereference checks and fixes up the reference; relocation happens while the app runs.
- Concurrent relocation — objects are moved to new locations concurrently, with the load barrier transparently forwarding stale references.
java -XX:+UseZGC -Xmx16g LowLatencyService
ZGC trades some throughput (load barriers add overhead per dereference) for extremely consistent pause times. Use it when tail-latency SLAs are measured in single-digit milliseconds.
Shenandoah
Red Hat's low-pause alternative to ZGC. Also relocates concurrently, using Brooks pointers (an extra forwarding word in the object header). Pause targets of < 10 ms. More compatible with 32-bit and compressed-oops configurations than ZGC.
| Collector | Pause target | Best for |
|---|---|---|
| Serial | Lowest memory overhead | Tiny heaps / single-core |
| Parallel | Maximum throughput | Batch processing |
| G1 | Balanced (~10–200 ms) | General-purpose servers |
| ZGC | < 1 ms | Ultra-low latency APIs |
| Shenandoah | < 10 ms | Low-latency, more portable |
Stop-the-world — why it can't be fully eliminated
All JVM GCs require at least brief stop-the-world (STW) phases where every application thread is paused. The reason: safely inspecting and modifying the object graph requires all threads to be at a safepoint — a known, consistent execution state where no thread holds an inconsistent view of the heap.
Even ZGC has two tiny STW phases (initial mark root scan and final mark). The goal of modern collectors is to make STW short and predictable, not to eliminate it.
Long "time-to-safepoint" entries in GC logs (the time between the JVM requesting a
safepoint and threads arriving at one) often point to long counted loops in hot paths.
Java 10+ added -XX:+UseCountedLoopSafepoints to insert safepoint checks inside
counted loops.
Reading GC logs
Enable structured GC logging in production (it has negligible overhead):
java -Xlog:gc*:file=/var/log/gc.log:time,uptime,level,tags:filecount=10,filesize=20m App
A typical G1 Young GC event:
[1.234s][info][gc] GC(5) Pause Young (Normal) (G1 Evacuation Pause) 256M->128M(1024M) 8.432ms
│ │ │ │ │ │
type reason before after cap pause
A Full GC triggered by evacuation failure:
[12.3s][info][gc] GC(42) To-space exhausted
[12.4s][info][gc] GC(43) Pause Full (G1 Compaction Pause) 2048M->1200M(2048M) 9831.456ms
GC logs are the first thing you need when diagnosing a latency spike. Always enable them in production with rolling files — you can't replay what you didn't capture.
The most important tuning flags
| Flag | Effect |
|---|---|
-Xms / -Xmx | Initial / maximum heap size |
-XX:+UseG1GC | Select G1 (default Java 9+) |
-XX:MaxGCPauseMillis=N | G1 pause time goal (soft, default 200 ms) |
-XX:G1HeapRegionSize=N | Region size (1–32 MB, power of 2) |
-XX:MaxTenuringThreshold=N | GC cycles before Old Gen promotion |
-XX:+HeapDumpOnOutOfMemoryError | Auto heap dump on OOM |
-XX:+UseZGC | Select ZGC |
-XX:ConcGCThreads=N | Concurrent GC threads (G1 / ZGC) |
-XX:NativeMemoryTracking=summary | Track off-heap memory |
Start with heap size and the pause target; add other flags only when data from GC logs shows a specific problem.
Common GC anti-patterns
Calling System.gc()
System.gc() is a hint to run a Full GC. The JVM may ignore it (or is suppressed by
-XX:+DisableExplicitGC). When honoured, it triggers the most expensive GC event at
an arbitrary moment. Remove it immediately from any production code.
Static caches without eviction
static Map<String, Object> cache = new HashMap<>(); // static = GC root
// Every value added here lives forever → Old Gen grows without bound
Use a proper cache (Caffeine, Guava Cache) with size and time-based eviction.
Excessive object churn
Creating millions of short-lived objects per second (e.g., boxing primitives in a tight
loop, concatenating strings with + in a loop) drives rapid Minor GC cycles. Use
primitive collections (int[] vs List<Integer>), StringBuilder, or streams for
bulk operations.
Humongous object abuse (G1)
Objects larger than half a G1 region bypass Young Gen entirely. Frequent humongous
allocations fragment Old Gen and can cause evacuation failures. Increase
-XX:G1HeapRegionSize or restructure the allocation.
Finalization — avoid it entirely
Object.finalize() was Java's attempt at destructor semantics. It introduces two GC
cycles of delay, unpredictable timing, and the possibility of object resurrection. It
is deprecated in Java 9 and scheduled for removal.
Always use AutoCloseable and try-with-resources for deterministic resource cleanup:
class Resource implements AutoCloseable {
@Override public void close() { /* release underlying resource */ }
}
try (Resource r = new Resource()) {
r.use();
} // close() called deterministically here, even on exception
For post-GC notification without finalizers, use java.lang.ref.Cleaner (Java 9+),
which runs cleanup actions in a dedicated thread without the resurrection hazard.
Diagnosing GC-related production problems
| Symptom | Likely cause | Investigation |
|---|---|---|
| Long periodic pauses | Full GC / Old Gen collection | GC logs, heap dump |
| Steadily growing Old Gen | Memory leak | Heap dump + Eclipse MAT |
| Frequent Minor GCs | High allocation rate | GC logs, allocation profiler |
| OOM: Java heap space | Heap too small or leak | jmap heap dump |
| OOM: Metaspace | Class loader leak | -XX:NativeMemoryTracking |
High process RSS vs -Xmx | Native memory growth | jcmd VM.native_memory |
The workflow is always: measure (GC logs) → hypothesis → verify (heap dump / profiler) → fix → measure again. Never tune flags without first understanding what the data says.
Recap
Java's GC automatically reclaims unreachable objects — anything not reachable from a
GC root. The foundational algorithm is mark-and-sweep-compact. Generational
collection (Eden → Survivor → Old Gen) exploits the observation that most objects die
young, keeping Minor GC pauses short. G1 is the right default for most servers,
offering balanced throughput and ~10–200 ms pauses. ZGC and Shenandoah push
pauses below 1–10 ms for latency-critical workloads. Always enable GC logging in
production, avoid System.gc() and finalize(), and diagnose with heap dumps and
GC-log analysis before reaching for tuning flags.