Java Garbage Collection Explained: G1, ZGC and How to Choose
How the JVM finds garbage, why generations matter, what G1 and ZGC actually do, and a practical way to pick and tune a collector for your service.
Garbage collection is why Java developers rarely think about memory β until a service starts pausing, or runs out of heap. This guide covers what you need for production and for interviews: how GC finds garbage, the generational idea, the collectors you'll actually use, and how to choose.
How the JVM decides what's garbage
An object is alive if it's reachable from a GC root: local variables on thread stacks, static fields, JNI references, and a few JVM internals. The collector traces the object graph from those roots; anything not reached is garbage.
That's why a "memory leak" in Java is almost always an unintended reference: an ever-growing static Map, a listener never unregistered, a ThreadLocal never cleared in a thread pool. The objects are still reachable, so they're never collected.
The generational idea
Most objects die young β request objects, temporary strings, iterators. So the heap is split:
- Young generation (Eden plus two Survivor spaces): new objects are allocated in Eden. A minor GC copies the surviving objects to a Survivor space; everything else in Eden is reclaimed at once. Cheap, because few objects survive.
- Old generation: objects that survive several minor GCs are promoted here. Collecting the old generation is more expensive.
Collecting young objects often and old objects rarely makes GC efficient.
The collectors you'll meet
| Collector | Flag | Key idea | Good for |
|---|---|---|---|
| Serial | -XX:+UseSerialGC | Single-threaded, stop-the-world | Tiny heaps, small containers with one CPU |
| Parallel | -XX:+UseParallelGC | Multi-threaded, stop-the-world; maximises throughput | Batch jobs where pauses don't matter |
| G1 (default) | -XX:+UseG1GC | Region-based, mostly concurrent marking, pause-time target | Most services |
| ZGC | -XX:+UseZGC | Concurrent almost everything; sub-millisecond pauses | Large heaps, latency-sensitive services |
| Shenandoah | -XX:+UseShenandoahGC | Concurrent compaction; low pauses | Low latency (OpenJDK builds that include it) |
G1 has been the default collector since Java 9 (for "server-class" machines; the JVM may pick Serial on very small ones). CMS, the older low-pause collector, was deprecated in Java 9 and removed in Java 14.
G1 in one paragraph
G1 divides the heap into equal-sized regions (1β32 MB). Each region is Eden, Survivor, Old or Humongous (for very large objects). It marks live objects concurrently while the application runs, then collects the regions with the most garbage first ("Garbage First"), in short evacuation pauses. You give it a goal, -XX:MaxGCPauseMillis=200 (the default), and it sizes the young generation and picks regions to meet it.
ZGC in one paragraph
ZGC does marking, relocation and reference fixing concurrently, using coloured pointers and load barriers, so pauses stay well under a millisecond regardless of heap size β even with hundreds of gigabytes. Since Java 21 it can be generational (-XX:+UseZGC -XX:+ZGenerational), and from Java 23 generational mode is the default for ZGC. The trade-off is a little CPU overhead and throughput cost compared with G1.
How to choose
- Start with G1. It's the default for good reason: balanced throughput and pauses.
- Latency-critical, with pauses hurting SLOs (trading, real-time APIs, very large heaps)? Try ZGC (generational).
- Pure throughput (offline batch, ETL) where a pause of a second is fine? Parallel GC.
- Measure, don't guess: compare p99 latency, throughput and CPU under a realistic load test.
Sizing and container basics
- Set the heap explicitly. In containers, use percentages:
-XX:MaxRAMPercentage=75, which leaves room for metaspace, thread stacks, direct buffers and the code cache. Setting-Xmxequal to the container limit invites OOMKilled pods. - Set
-Xmsequal to-Xmxfor stable services, to avoid heap resizing. - Turn on GC logging in production; it's cheap:
-Xlog:gc*:file=gc.log:time,uptime:filecount=5,filesize=20m.
Reading the symptoms
| Symptom | Likely cause | First step |
|---|---|---|
| Long pauses | Heap too small, huge allocation rate, humongous objects (G1) | GC logs, then allocation profiling (JFR) |
| Heap keeps growing after each full GC | A memory leak | A heap dump, analysed in Eclipse MAT (dominator tree) |
OutOfMemoryError: Metaspace | Class loader leak (redeploys) or too many generated classes | Check class counts and dynamic proxies |
| Pod OOMKilled but no Java OOM | Native memory exceeds the container limit | Native Memory Tracking, and lower the heap percentage |
Follow-up questions this topic invites β and their answers
Q: Is System.gc() a good idea?
A: Almost never. It only requests a full GC (it can be disabled with -XX:+DisableExplicitGC) and usually hurts performance. Fix the allocation pattern instead.
Q: What are stop-the-world pauses? A: Phases where all application threads are stopped so the collector can work safely. Modern collectors keep them short by doing most of the work concurrently.
Q: What's a humongous object in G1?
A: An object larger than half a region. It's allocated directly in contiguous old regions, which can cause fragmentation and earlier full GCs. Larger -XX:G1HeapRegionSize or avoiding giant arrays helps.
Q: Do finalizers help free memory?
A: No. finalize() is deprecated for removal. It delays reclamation and is unpredictable. Use try-with-resources or Cleaner for native resources.
Go deeper in the JVM internals chapter and the senior JVM & performance interview questions.
Related Posts
Virtual Threads in Java 21: When They Help and When They Don't
Virtual threads make blocking code scale to hundreds of thousands of concurrent tasks β but only for I/O-bound work. How they work, how to use them in Spring Boot, and the pitfalls.
HashMap Internals: How put() and get() Really Work
Buckets, hash spreading, collisions, treeification and resizing β a step-by-step look inside java.util.HashMap, and why equals() and hashCode() must agree.
10 Java Output Questions That Trip Up Experienced Developers
Integer caching, the String pool, finally blocks that override returns β ten short Java snippets where intuition gives the wrong answer, with the exact reason for each.
Java Concurrency: The Interview Questions That Trip People Up
volatile, synchronized, ReentrantLock, happens-before β these concepts trip up even experienced engineers. Here's a clear explanation of each.