What GC Does — In 30 Seconds
In C, you allocate with malloc and free with free. Forget to free → memory leak. Free twice → crash. Java said: "let the runtime do it." That's the GC.
The one-line definition
An object is garbage if no live thread can reach it through any chain of references. The GC's job is simple: find unreachable objects, free their memory.
What are GC Roots?
The starting points of the reachability walk. If an object is reachable from a root, it's alive:
- Local variables on every running thread's stack
- Static fields of loaded classes
- Active monitors (objects locked by
synchronized) - JNI references (native code holding Java objects)
The Heap Layout — Young, Old, Metaspace
The heap is split into regions because most objects die young. This is the famous "weak generational hypothesis" — typically 90%+ of objects become garbage within milliseconds of being created. So the JVM treats young and old objects differently.
The structure
Why three areas in Young Gen?
Two Survivors (S0 + S1) are used for a copying trick. At any moment, one is empty. When GC runs, it copies all live objects from Eden + the full Survivor into the empty Survivor — then wipes the source areas in one shot. Next time, they swap roles. This is why young-gen GC is so fast.
How an object travels
- You write
new Order()→ object lands in Eden. - Eden fills up → Minor GC runs. Live objects copied to a Survivor space, age = 1.
- Object survives more GCs → keeps bouncing between S0 ↔ S1, age++ each time.
- Age crosses threshold (default 15) → object is promoted to Old Gen.
- Old Gen fills up → Major GC runs (much slower).
Minor GC vs Major GC vs Full GC
Three terms that get used loosely. Knowing the difference is interview gold.
| Type | What it cleans | How often | Pause | Algorithm |
|---|---|---|---|---|
| Minor GC | Young Gen only | Frequent (every few seconds) | Short (1–10 ms) | Copying — copy live objects to empty Survivor, wipe source |
| Major GC | Old Gen only | Rare | Longer (100 ms+) | Mark-Sweep-Compact |
| Full GC | Whole heap (Young + Old + Metaspace) | Very rare (something's wrong) | Long (seconds possible) | Mark-Sweep-Compact, all areas |
The Minor GC algorithm — copying
- Walk roots, mark every reachable object in Young Gen.
- Copy all live objects from Eden + current Survivor → empty Survivor.
- Increment each survivor's age. If age > 15 → promote to Old Gen.
- Wipe Eden and the old Survivor in O(1) (just reset the pointer).
Cost is proportional to live objects, not heap size. Since 90%+ of Eden is dead, you copy 10% and reclaim 90% essentially for free.
The Major GC algorithm — Mark-Sweep-Compact
- Mark — walk roots, mark every reachable object as alive.
- Sweep — scan the heap; anything unmarked is freed.
- Compact — slide live objects together to eliminate fragmentation (so future allocations have one big contiguous free area).
Stop-the-World (STW) — Why GC Pauses Exist
Some GC work needs a consistent snapshot of the heap. To get that, the JVM pauses every application thread. This pause is called Stop-the-World.
Why it's needed
Imagine the GC walks references while your code is busy reassigning them. The GC could miss an object, or copy one that just moved. To avoid this corruption, the JVM halts all application threads at "safepoints" before doing the risky work.
The trade-off every GC makes
- Old GCs (Serial, Parallel) — pause for the entire collection. Simple but pauses can be seconds.
- G1 — does most work concurrently with the app. STW pauses are short (~200ms) and bounded by a target you set.
- ZGC / Shenandoah — almost everything runs concurrently. STW pauses are sub-millisecond, regardless of heap size.
G1 GC — How It Works (Default Since Java 9)
G1 stands for Garbage First. It's the default collector in modern JVMs and what most production servers use. Its key idea: throw out the rigid Eden/Survivor/Old layout and replace it with thousands of small regions.
The big idea — regions
G1 splits the heap into ~2000 equal-sized regions (1–32 MB each). Each region is dynamically labeled as Eden, Survivor, or Old — boundaries shift over time. There's no fixed "young area" — any region can play any role.
E = Eden · S = Survivor · O = Old · F = Free
Why "Garbage First"?
Each region tracks how much of it is garbage. At collection time, G1 sorts regions by garbage density and collects the most-garbage ones first. Best ROI: clean a 95%-garbage region and you reclaim almost the whole region for the cost of evacuating the 5% that's alive.
How G1 hits a pause-time target
You tell G1 "I want pauses under 200ms" via -XX:MaxGCPauseMillis=200. G1 keeps stats on how long each region took to evacuate last time. Given the budget, it picks a set of regions that fit in 200ms while reclaiming the most garbage. Whatever doesn't fit is collected next cycle.
The G1 cycle (simple version)
- Young collection — evacuates Eden + Survivor regions. STW, but short.
- Concurrent marking — when Old Gen is ~45% full, G1 walks the heap concurrently with the app, computing per-region garbage counts.
- Mixed collection — next several "young" GCs also evacuate the highest-garbage Old regions. This avoids needing a Full GC.
- Full GC — last resort if mixed can't keep up. STW, slow. Bad if it happens often.
ZGC — Sub-Millisecond Pauses on Huge Heaps
ZGC is built for one job: keep pauses under 1 millisecond, no matter how big the heap is. It can handle 16 TB heaps with the same pause time as an 8 GB heap. The trick: do almost everything concurrently with the app.
The big idea — fully concurrent
Most GCs pause the app for the bulk of their work. ZGC does almost everything while the app keeps running — including the hard part, compaction (moving live objects to defrag the heap). Only tiny coordination steps require STW.
How does it move objects without freezing the app?
This is the magic. ZGC uses two tricks (you only need the high-level idea for an interview):
- Colored pointers — ZGC stores extra info in unused bits of object references. These bits tell the GC what state the object is in (e.g., "already moved", "needs scanning").
- Load barriers — every time your code reads a reference, the JVM checks the pointer's color bits. If the object has been moved, the JVM transparently fixes the pointer to the new location. Your code doesn't notice.
What you need to know
- Pause time: < 1 ms, independent of heap size.
- Heap range: 8 GB to 16 TB.
- Trade-off: ~5–15% lower throughput than Parallel GC (because of barrier overhead).
- Flag:
-XX:+UseZGC. Generational variant since JDK 21:-XX:+ZGenerational. - Best for: latency-critical services — trading, gaming, real-time APIs.
Shenandoah — The Other Low-Pause Collector
Shenandoah was built by Red Hat with the same goal as ZGC — short pauses regardless of heap size — but a different mechanism. Both work; ZGC just won the popularity contest.
The big idea — concurrent compaction with a forwarding pointer
Like ZGC, Shenandoah does compaction concurrently with the app. Instead of colored pointers, it uses a simpler trick called the Brooks pointer: every object has an extra header field that points to itself by default. When the object is being moved, that pointer redirects to the new location.
How it works in plain English
- Shenandoah marks live objects concurrently (just like G1's concurrent mark).
- It picks high-garbage regions to evacuate.
- While the app is running, it copies live objects from those regions to fresh regions.
- If the app reads an object that's been copied, the Brooks pointer redirects the read to the new location — transparently.
- Old regions are freed once everything's evacuated.
What you need to know
- Pause time: < 10 ms (close to ZGC, slightly higher).
- Heap range: 4 GB to ~1 TB.
- Trade-off: indirection on every read (Brooks pointer dereference) → similar throughput cost to ZGC.
- Flag:
-XX:+UseShenandoahGC. Available in OpenJDK builds (Red Hat-led). - Available even on smaller / 32-bit JVMs (ZGC needs 64-bit + larger heaps).
Side-by-Side — Which GC When?
The cheat-sheet you should be able to recite in your sleep.
| GC | Pause | Throughput | Heap Size | Best For | Flag |
|---|---|---|---|---|---|
| Serial | Long (single-threaded) | Low | < 100 MB | CLI tools, embedded | -XX:+UseSerialGC |
| Parallel | Long (multi-threaded STW) | Highest | 1–8 GB | Batch jobs (throughput > latency) | -XX:+UseParallelGC |
| G1 (default) | Predictable (~200ms) | Medium-High | 4–32 GB | Most server apps | -XX:+UseG1GC |
| ZGC | < 1 ms | Medium | 8 GB – 16 TB | Latency-critical, huge heaps | -XX:+UseZGC |
| Shenandoah | < 10 ms | Medium | 4 GB – 1 TB | Low-pause on smaller heaps | -XX:+UseShenandoahGC |
The decision tree
Reference Types — Strong, Soft, Weak, Phantom
99% of references in Java are "strong" — the GC will never collect them while they exist. But sometimes you want weaker semantics. Java has three flavors for that.
| Type | GC behavior | Use case |
|---|---|---|
| Strong (default) | Object never collected while a strong ref exists | Normal fields, locals, parameters |
Soft (SoftReference) |
Collected only when JVM is low on memory | Memory-sensitive caches |
Weak (WeakReference) |
Collected at next GC if no strong refs | WeakHashMap, listener registries |
Phantom (PhantomReference) |
Object already finalized; reference enqueued for cleanup | Resource cleanup (replaces finalize()) |
Most-asked interview example — WeakHashMap:
Map<Component, Metadata> meta = new WeakHashMap<>(); meta.put(component, metadata); // When `component` is GC'd from elsewhere, the map entry vanishes automatically. // No need to manually `.remove()` — perfect for caches keyed by lifetime.
finalize() is deprecated and unreliable — unpredictable timing, can resurrect objects, blocks the finalizer thread. Use java.lang.ref.Cleaner (Java 9+) or try-with-resources (AutoCloseable) instead.Memory Leaks in Java — Yes, They Exist
"Java has GC, so it can't leak." Wrong. You can't forget to free, but you can forget to unreference — and the symptom is the same: heap fills up, OOM crash.
The 5 most common leak patterns
1. Unbounded static collections
A static HashMap used as a cache, no eviction. Every entry stays alive forever.
Fix: Caffeine or Guava cache with size + TTL limits.
2. Unremoved listeners
eventBus.subscribe(this) → no unsubscribe → bus holds a reference forever.
Fix: always pair subscribe/unsubscribe.
3. ThreadLocals in pooled threads
Servlet thread pools reuse threads. ThreadLocal set in request 1 persists into request 2.
Fix: always tl.remove() in finally.
4. Inner classes capturing outer
Non-static inner / anonymous class / lambda capturing this. If it outlives the outer (e.g., put in an executor), the outer can't be GC'd.
Fix: use static nested classes when possible.
5. Unclosed resources
Streams, connections, native buffers (DirectByteBuffer) not closed. Heap looks fine; native memory grows.
Fix: try-with-resources for everything AutoCloseable.
How to debug a leak — 4 steps
- Confirm it's a leak. Plot heap-after-Full-GC over time. Trending up = leak.
- Take a heap dump:
jmap -dump:live,format=b,file=heap.hprof <pid> - Open in Eclipse MAT → run "Leak Suspects" — points at top retainers.
- "Path to GC roots" in MAT shows the chain keeping the leaked object alive. Fix that chain.
Top Interview Q&A — The 60-Second Answers
The exact answers to give. Lead with the punchline, expand only if asked.
4-beat structure: (1) An object is garbage if no live thread can reach it from any GC root. (2) The heap is split into Young + Old based on the "most objects die young" hypothesis. (3) Young uses a fast copying collector — Eden + two Survivors, objects bounce between Survivors until old enough to promote. (4) Old uses Mark-Sweep-Compact, slower but rarer. Default collector since Java 9 is G1; latency-critical apps use ZGC.
A pause where every application thread halts so the GC can do work that needs a consistent heap snapshot. Length depends on heap size and collector. Old GCs (Parallel) had STW for the whole collection. G1 minimizes STW by doing most work concurrently. ZGC keeps STW under 1ms regardless of heap size.
Minor GC = Young Gen only, copying algorithm, fast (1–10ms), frequent. Full GC = whole heap (Young + Old), Mark-Sweep-Compact, slow (100ms to seconds), rare. Frequent Full GCs in production usually mean either heap is too small or there's a memory leak.
G1 ("Garbage First") splits the heap into ~2000 small regions. It tracks how much garbage each region has and collects the most-garbage ones first. You set a target pause time (e.g., 200ms) and G1 picks how many regions fit in that budget. Default since Java 9 because it gives predictable pauses on multi-GB heaps without much tuning.
Concurrent collector that keeps pauses under 1ms regardless of heap size — works from 8 GB up to 16 TB. Uses "colored pointers" and "load barriers" to relocate objects while the app keeps running. Costs ~10% throughput. Use it when you can't tolerate >10ms pauses — trading systems, real-time APIs, games.
Both target sub-10ms concurrent collection. ZGC uses colored pointers (needs 64-bit, 4GB+ heaps), scales bigger, ships in mainline OpenJDK. Shenandoah uses Brooks forwarding pointers, works on smaller / older JVMs, was developed by Red Hat. Pick based on your JDK build and heap size.
The starting points for the GC's reachability walk: (1) local variables on every active thread's stack, (2) static fields of loaded classes, (3) JNI references from native code, (4) objects held by active monitors (synchronized blocks). An object is alive if any chain of references reaches it from a root.
Yes — they're "reachability leaks", not allocation leaks. Top patterns: unbounded static caches, unremoved listeners, ThreadLocals in pooled threads, inner-class capture, unclosed native resources. Find them with jmap heap dumps + Eclipse MAT's "path to GC roots".
"I don't, until I have to." Then: (1) pick the right collector for the workload, (2) size the heap (-Xms = -Xmx in containers), (3) turn on GC logs (-Xlog:gc*). If pauses are bad, profile to find allocation hotspots. The single biggest lever is allocation rate — reducing it usually helps more than any flag.
PermGen (pre-Java 8) was inside the heap, fixed-size, held class metadata. Filled up easily — classic OutOfMemoryError: PermGen space in Spring apps. Metaspace (Java 8+) lives in native memory, grows automatically (cap with -XX:MaxMetaspaceSize), pairs better with class unloading.