JavaJVM & Memoryadvanced
Updated:

Java Garbage Collection: How GC Really Works

7 min read

GC roots, the generational hypothesis, and how G1 and ZGC actually reclaim memory — with runnable demos and the tuning flags that matter in production.

TL;DR – Quick Answer

Java garbage collection automatically reclaims heap memory from objects that are no longer reachable from GC roots such as thread stacks and static fields. Modern collectors are generational: they collect the young generation frequently and cheaply because most objects die young, and promote long-lived objects to the old generation. G1 is the default collector; ZGC targets very low pause times on large heaps.

On This Page

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 generationEden plus two survivor spaces (S0/S1). All new objects start in Eden.
  • Old generation — objects that survived enough young collections get promoted here.

The lifecycle:

  1. Eden fills up, triggering a minor GC.
  2. 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.
  3. Each survival increments the object's age; survivors ping-pong between S0 and S1.
  4. Past a tenuring threshold (or when survivor space overflows), objects are promoted to the old generation.
  5. 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.
  6. 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:

  1. Are pauses too long? For G1, lower -XX:MaxGCPauseMillis — and accept more frequent collections — or evaluate ZGC if the target is single-digit milliseconds.
  2. Are collections too frequent? High allocation rate. A bigger young generation helps (-Xmx/-Xms first, fine-grained flags later), but fixing allocation-heavy code helps more.
  3. 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?
GC roots are the starting points the collector traces from: local variables on live thread stacks, active threads themselves, static fields of loaded classes, and JNI references. Any object reachable by following references from a root is live; everything else is garbage, including entire cycles of objects that only reference each other.
What is the difference between minor GC and major GC?
A minor GC collects only the young generation, is triggered when Eden fills, and is usually fast because most young objects are already dead. A major or old-generation collection reclaims the old generation and costs more. A full GC collects the entire heap including metaspace-related structures and causes the longest pauses.
Which garbage collector is the default in Java?
G1 has been the default collector since Java 9. Java 8 defaulted to the Parallel collector. You can select others explicitly, for example -XX:+UseZGC for ZGC or -XX:+UseSerialGC for the serial collector on small containers.
Does calling System.gc() force garbage collection?
No. It is only a hint, and the JVM is free to ignore it. Production code should not call it; an explicit full GC at the wrong moment can cause a long pause. Some JVMs are even run with -XX:+DisableExplicitGC to neutralize such calls.
Can garbage collection collect objects that reference each other?
Yes. Java uses tracing collectors, not reference counting. If a group of objects form a cycle but none of them is reachable from a GC root, the whole island is collected together. This is why circular references are not a memory leak in Java.
When should I tune GC instead of using defaults?
Only after measuring. G1 defaults serve most services well. Tune when GC logs show pause times breaching your latency target, allocation rates causing back-to-back collections, or premature promotion filling the old generation. Always change one flag at a time and compare GC logs before and after.

Want to Build Your Career in Java Full Stack with AI?

Join CodeBegun and train with working industry engineers — Explore the Program

Apply for Demo Class →
Siva Prasad Galaba
Founder, CodeBegun · Staff Engineer

Founder of CodeBegun. 15+ years building Java systems at companies like Crunchyroll. Teaches Java, Spring Boot and system design the way the industry actually works, and mentors students through projects, mock interviews and placement preparation.

Technically reviewed by CodeBegun Technical TeamLast reviewed 14 July 2026 LinkedIn
Chat with us