Garbage collection is the JVM feature everyone relies on and few can explain. That gap is exactly why GC questions dominate senior Java interviews: they separate developers who write code from developers who understand what runs it.
This guide builds the mental model in layers — reachability, generations, then the real collectors (G1 and ZGC) — and ends with the small set of tuning flags worth knowing. If heap, stack and metaspace are still fuzzy, read Java memory management first; this article assumes that map.
What GC actually decides: reachability
The collector's only question is: can the program still reach this object? It starts from GC roots — local variables on live thread stacks, active threads, static fields, JNI handles — and traces every reference it can follow. Objects it reaches are live. Everything else is garbage, no matter what references garbage holds.
That "no matter what" is important. Java uses tracing collection, not reference counting, so cycles are collected for free:
public class ReachabilityDemo {
static class Node {
Node next;
byte[] payload = new byte[1_000_000]; // ~1 MB so the effect is visible
}
public static void main(String[] args) {
Node a = new Node();
Node b = new Node();
a.next = b;
b.next = a; // a and b now reference each other in a cycle
a = null;
b = null; // no root reaches the cycle anymore
System.gc(); // hint: both nodes are eligible despite the cycle
long used = Runtime.getRuntime().totalMemory()
- Runtime.getRuntime().freeMemory();
System.out.println("Used after hint: " + used / 1024 / 1024 + " MB");
}
}
What to notice: after both locals are nulled, the two nodes form an "island of isolation" — mutually referenced but unreachable from any root — and the tracing collector reclaims the whole island. In a reference-counting scheme this would leak.
Common mistake: A common mistake beginners make is treating
System.gc()as a "free memory now" button. It is a hint the JVM may ignore, and when honored it can trigger an expensive full collection at the worst possible moment. If you find yourself wanting it in application code, the real problem is elsewhere.
The generational hypothesis
Decades of measurement across real programs support one observation: most objects die young. Request DTOs, temporary strings, iterator objects — allocated, used, dead within microseconds. A minority of objects (caches, connection pools, application state) live for the whole process.
Generational collectors exploit this by splitting the heap:
- Young generation — Eden plus two survivor spaces (S0/S1). All
newobjects start in Eden. - Old generation — objects that survived enough young collections get promoted here.
The lifecycle:
- Eden fills up, triggering a minor GC.
- Live objects (usually few) are copied to a survivor space; dead ones cost nothing — nobody touches them. This is why minor GCs are fast even when Eden is large.
- Each survival increments the object's age; survivors ping-pong between S0 and S1.
- Past a tenuring threshold (or when survivor space overflows), objects are promoted to the old generation.
- When the old generation fills, an old-generation ("major") collection runs — more expensive, because old regions are large and objects there tend to be live.
- A full GC collects everything and produces the longest pauses; healthy services see them rarely or never.
You can watch this happen with one flag. Compile any allocation-heavy program and run it with GC logging:
public class AllocationChurn {
public static void main(String[] args) {
long total = 0;
for (int i = 0; i < 2_000_000; i++) {
byte[] block = new byte[1024]; // 1 KB, dead by the next line
total += block.length;
}
System.out.println("Allocated ~" + total / (1024 * 1024) + " MB in total");
}
}
Run it as java -Xlog:gc AllocationChurn. You will see a stream of Pause Young lines while the program allocates roughly 2 GB in total — yet heap usage stays low, because nearly every block is already dead when the minor GC copies survivors. That log line pattern is the generational hypothesis working.
Interview note: "Difference between minor, major and full GC" is a standard question in the 3–5 year experience band. Structure your answer around what is collected (young / old / entire heap), when it triggers (Eden full / old gen full / promotion failure or explicit request), and relative cost. Bonus points for mentioning that minor GC cost scales with surviving objects, not with Eden size.
The collectors you will actually meet
| Collector | Flag | Design goal | Typical use |
|---|---|---|---|
| Serial | -XX:+UseSerialGC |
Minimal footprint, single-threaded | Tiny containers, CLI tools |
| Parallel | -XX:+UseParallelGC |
Maximum throughput | Batch jobs where pauses don't matter |
| G1 | -XX:+UseG1GC (default since Java 9) |
Balance throughput with predictable pauses | Most services |
| ZGC | -XX:+UseZGC |
Ultra-low pause times, huge heaps | Latency-critical systems |
G1: the default, region by region
G1 ("Garbage-First") divides the heap into equal-sized regions (1–32 MB each) instead of two big contiguous generations. Any region can play the role of Eden, survivor or old at a given moment. During a collection G1 picks the regions with the most garbage first — hence the name — and evacuates their live objects into fresh regions, which compacts memory as a side effect.
G1's headline feature is the pause-time target: -XX:MaxGCPauseMillis (default 200 ms). G1 tracks how long region evacuations take and sizes the next collection set to fit the target. It is a goal, not a guarantee, but it makes latency roughly tunable with one flag.
ZGC: pauses that ignore heap size
ZGC does almost all of its work — marking, relocation, reference fixing — concurrently while application threads keep running, using colored pointers and load barriers to stay correct. The result is pause times in the sub-millisecond to low-millisecond range that stay flat whether the heap is 8 GB or multiple terabytes.
ZGC became production-ready in Java 15 and gained a generational mode in Java 21, recovering the "most objects die young" efficiency it originally gave up. The trade-off versus G1 and Parallel is somewhat lower raw throughput and higher CPU overhead from the barriers — you pay a tax on every read to almost never stop the world. If you are choosing an LTS baseline for a latency-sensitive service, this is one of the reasons teams move to Java 17 or 21.
GC tuning basics: measure, then touch one flag
Rule zero: G1's defaults are good. Tuning without GC logs is guessing. Start every investigation with:
-Xlog:gc*:file=gc.log:time,uptime,level,tags
Then read the log (or load it into a visualizer) and ask three questions:
- Are pauses too long? For G1, lower
-XX:MaxGCPauseMillis— and accept more frequent collections — or evaluate ZGC if the target is single-digit milliseconds. - Are collections too frequent? High allocation rate. A bigger young generation helps (
-Xmx/-Xmsfirst, fine-grained flags later), but fixing allocation-heavy code helps more. - Is the old generation filling steadily? Either a genuine leak (take a heap dump, as covered in memory management) or premature promotion — survivor spaces too small for your objects' actual lifetimes, pushing medium-lived objects into old gen where they are expensive to collect.
The flags worth memorizing as a starting kit:
-Xms4g -Xmx4g # fixed heap size, no resize churn
-XX:MaxGCPauseMillis=100 # G1 pause goal
-XX:+UseZGC # switch collector when latency demands it
-Xlog:gc* # never tune blind
Pro tip: Change one flag per experiment and compare the same metric (p99 pause, GC frequency, promotion rate) across runs with production-like load. A flag that helps a batch workload can hurt a latency-sensitive one — copied "tuning recipes" from blog posts are the leading cause of self-inflicted GC problems.
What GC does not do
Three boundaries keep expectations honest. First, GC reclaims heap memory only — thread stacks, and direct ByteBuffers are outside its remit (metaspace is reclaimed only when class loaders die). Second, GC cannot fix a leak, because a leaked object in Java is by definition still reachable — a growing static Map looks like live data to the collector. Third, finalize() was never a reliable cleanup mechanism and is deprecated for removal; use try-with-resources or java.lang.ref.Cleaner for resource cleanup.
Where GC fits in the machine as a whole — alongside the class loaders that feed metaspace and the JIT compiler that works with the collector on barriers — is the subject of the JVM architecture guide.
How to make this stick
Run the two demos above with -Xlog:gc and read every line the JVM prints — that single exercise makes generational collection concrete in a way no diagram can. Then take a heap dump of any running Java process with jmap and open it in a profiler, so "GC roots" stops being vocabulary and becomes something you have clicked on. When you can narrate a GC log out loud, the interview version of this topic takes care of itself.
Frequently Asked Questions
What are GC roots in Java?
What is the difference between minor GC and major GC?
Which garbage collector is the default in Java?
Does calling System.gc() force garbage collection?
Can garbage collection collect objects that reference each other?
When should I tune GC instead of using defaults?
Want to Build Your Career in Java Full Stack with AI?
Join CodeBegun and train with working industry engineers — Explore the Program

