Skip to content
Path to Engineer
All lessons
JavaJVM17 min read

Java garbage collection: generations, G1 and ZGC

The generational hypothesis, what separates a minor from a major GC, and how G1 and ZGC differ in what they trade away.

Day 26 of the 488-day pathway. Published in full — nothing held back.

Garbage collection — generational hypothesis, minor/major GC, G1 vs ZGC Process — address space, PCB, states, lifecycle, fork/exec/wait


Part 1 — L-026 · Garbage collection

What GC actually solves

In C you call free(). Two failure modes follow, and both are severe:

  • Forget to free → memory leak
  • Free twice, or use after free → corruption, and a large share of all security vulnerabilities

GC removes both by making the runtime decide when memory is unreachable. You trade explicit control for safety and a pause you don't schedule.

Reachability, not reference counting

The question GC asks is not "is anything pointing at this?" It's "is this reachable from a GC root?"

   GC ROOTS
   ├─ local variables in every live thread's stack frames  (Day 025)
   ├─ static fields                                        (Metaspace)
   ├─ JNI references
   └─ active thread objects
        │
        │  follow every reference transitively
        ▼
   ┌──────────────────────────────────────────┐
   │  REACHABLE — keep                        │
   └──────────────────────────────────────────┘

   ┌──────────────────────────────────────────┐
   │  UNREACHABLE — collect                   │
   │   (even if objects point at each other)  │
   └──────────────────────────────────────────┘

Why reachability beats reference counting: two objects referencing each other in an island have non-zero reference counts but are unreachable. Reference counting leaks cycles; tracing from roots does not. That's the standard interview follow-up, and it's also why Python needs a separate cycle collector on top of its reference counting — a good comparison to have ready for Day 416.

The generational hypothesis

Most objects die young. Measured across real programs, the overwhelming majority of allocations become garbage almost immediately — loop temporaries, intermediate strings, short-lived DTOs.

A second observation: few old objects reference young ones.

The heap is split to exploit both:

   YOUNG GENERATION                              OLD GENERATION
   ┌──────────────┬────────┬────────┐           ┌────────────────────┐
   │     Eden     │   S0   │   S1   │ ─promote─▶│      Tenured       │
   └──────────────┴────────┴────────┘           └────────────────────┘
    new objects    survivor spaces               survived many GCs

The allocation and collection cycle

  1. New objects go into Eden — allocation is a pointer bump, nearly free
  2. Eden fills → Minor GC. Live objects are copied into a survivor space; everything else is abandoned wholesale
  3. Each survival increments the object's age
  4. Age crosses a threshold (default ~15) → promote to Old Gen
  5. Old Gen fills → Major / Full GC — much more expensive

Why minor GC is so cheap

Cost is proportional to the number of surviving objects, not the amount of garbage.

If 98% of Eden is dead, the collector copies the 2% out and resets the whole region. The dead objects are never touched — not visited, not freed individually. Making more garbage that dies immediately is nearly free.

This is why "avoid allocation" is often bad advice in Java. Short-lived allocation is cheap; allocation that survives is what costs.

Stop-the-world

Some GC phases require pausing all application threads — otherwise references would change mid-scan.

   app threads   ████████░░░░░░░░████████░░░░░░████████
                          ↑              ↑
                       GC pause       GC pause

Reducing pause time is what every modern collector is competing on. Your table:

Collector Pauses Best for Flag
Serial Long Tiny heaps, single CPU -XX:+UseSerialGC
Parallel Long but multi-threaded Throughput over latency; batch -XX:+UseParallelGC
G1 ~10–200 ms, targeted The default since Java 9. Balanced. -XX:+UseG1GC
ZGC < 1 ms, heap-size independent Large heaps, latency-critical -XX:+UseZGC
Shenandoah < 10 ms Similar goals to ZGC -XX:+UseShenandoahGC

G1 — the default, and the one to be able to explain

Garbage-First divides the heap into ~2,000 equal regions rather than fixed contiguous generations.

   ┌───┬───┬───┬───┬───┬───┬───┬───┐
   │ E │ O │ E │ S │ O │ H │ E │ O │   E=Eden  S=Survivor
   ├───┼───┼───┼───┼───┼───┼───┼───┤   O=Old    H=Humongous
   │ O │ E │ H │ O │ E │ O │ S │ E │
   └───┴───┴───┴───┴───┴───┴───┴───┘
   A region's ROLE can change between collections.
  • Any region can be any generation — the layout is dynamic
  • It tracks how much garbage each region holds and collects the fullest first — hence "garbage-first"
  • You give it a pause-time goal (-XX:MaxGCPauseMillis=200) and it collects as many regions as it estimates it can within that budget
  • Humongous objects (larger than half a region) get dedicated regions and are handled specially — allocating many large arrays is a known G1 pain point

The one-line answer: "G1 divides the heap into regions, tracks which hold the most garbage, and collects those first, sized to meet a configurable pause-time target."

ZGC — how sub-millisecond pauses are possible

ZGC does nearly all work concurrently with the application, including compaction. It uses coloured pointers — metadata bits inside the reference itself — plus load barriers, so that when application code reads a reference to an object that's being relocated, the barrier fixes the pointer transparently.

Pause times are independent of heap size. A 16 TB heap has the same sub-millisecond pauses as a 1 GB heap.

The tradeoff: somewhat lower throughput (barriers cost on every reference load) and more memory overhead. Use ZGC when tail latency matters more than raw throughput — trading APIs, real-time services. Otherwise G1 is the sensible default.

Weak, soft and phantom references

Object o = new Object();                          // strong  — never collected while reachable
WeakReference<Object> w = new WeakReference<>(o);  // collected at the NEXT GC once no strong refs
SoftReference<Object> s = new SoftReference<>(o);  // collected only under memory pressure
PhantomReference<Object> p = new PhantomReference<>(o, queue);  // for cleanup after finalisation

Where you'll actually meet these:

  • WeakHashMap — entries vanish when the key is no longer strongly referenced elsewhere. Used for metadata caches keyed on objects you don't own.
  • SoftReference — memory-sensitive caches. In practice, often a bad idea: it delays GC and makes behaviour unpredictable. Prefer an explicit bounded cache (Caffeine) with a size limit.
  • ThreadLocal uses weak keys — and this is precisely why ThreadLocal still leaks in thread pools: the key is weak but the value is strongly held by the thread. Day 066A.

Never use finalize(). Deprecated, non-deterministic, may never run, and it can resurrect objects. Use try-with-resources (Day 061) or Cleaner.


Type this yourself

import java.lang.ref.*;
import java.util.*;

public class GcDemo {
    public static void main(String[] args) throws Exception {

        // ---- 1. Watch generational GC. Run with the flags below. ----
        System.out.println("Allocating 5 million short-lived objects...");
        long start = System.currentTimeMillis();
        for (int i = 0; i < 5_000_000; i++) {
            byte[] garbage = new byte[100];      // dies immediately — cheap
        }
        System.out.println("Short-lived: " + (System.currentTimeMillis() - start) + " ms");

        // ---- 2. Now objects that SURVIVE — this is what actually costs ----
        start = System.currentTimeMillis();
        List<byte[]> retained = new ArrayList<>();
        for (int i = 0; i < 200_000; i++) {
            retained.add(new byte[100]);          // survives → promoted → real cost
        }
        System.out.println("Retained:    " + (System.currentTimeMillis() - start) + " ms");
        System.out.println("(fewer objects, but they survive — compare the two)");

        // ---- 3. Reachability: a cycle is still collectable ----
        Node a = new Node(), b = new Node();
        a.other = b; b.other = a;                 // they reference each other
        a = null; b = null;                       // unreachable from any GC ROOT
        System.gc();                              // a hint, not a command
        System.out.println("\nCycle discarded — reference counting would have leaked it");

        // ---- 4. Weak reference ----
        Object obj = new Object();
        WeakReference<Object> weak = new WeakReference<>(obj);
        System.out.println("Before: " + (weak.get() != null));
        obj = null;                               // drop the strong reference
        System.gc();
        Thread.sleep(100);
        System.out.println("After GC: " + (weak.get() != null));   // likely false
    }

    static class Node { Node other; }
}
# Watch every collection
java -Xlog:gc GcDemo

# Full detail — heap sizes before/after, pause durations
java -Xlog:gc* GcDemo

# Force a small heap to make GC frequent and visible
java -Xmx64m -Xlog:gc GcDemo

# Compare collectors on the same workload
java -XX:+UseSerialGC   -Xlog:gc GcDemo
java -XX:+UseParallelGC -Xlog:gc GcDemo
java -XX:+UseG1GC       -Xlog:gc GcDemo
java -XX:+UseZGC        -Xlog:gc GcDemo

Two things to record in your notes:

  1. Compare part 1 and part 2 timings. 5,000,000 short-lived allocations versus 200,000 retained ones. The retained set is 25× fewer objects and often comparable or slower, because GC cost tracks survivors, not garbage. That result is the generational hypothesis, measured by you.
  2. Compare pause times across collectors in the -Xlog:gc output. You'll see Serial's long single-threaded pauses versus G1's targeted ones.

Interview questions

Q: How does Java's garbage collector decide what to collect?

By reachability from GC roots — thread stacks, static fields, JNI references and active threads. Anything not transitively reachable is garbage, regardless of whether other garbage objects point at it. That's why reference cycles are collected correctly, which naive reference counting can't do.

Q: What is the generational hypothesis and how does the heap exploit it?

Most objects die very young. So the heap is split into a young generation, where new objects are allocated and collected frequently and cheaply, and an old generation for objects that survive several collections. Minor GC copies out the few survivors and abandons the rest wholesale, so its cost is proportional to surviving objects, not to garbage produced.

Q: Minor vs major GC?

Minor collects only the young generation — frequent, fast, cost proportional to survivors. Major or full GC collects the old generation too — much less frequent and considerably more expensive, historically with a long stop-the-world pause.

Q: How does G1 work?

It divides the heap into around two thousand equal-sized regions whose generational role can change. It tracks how much garbage each region contains and collects the ones with the most first, choosing how many to collect to meet a configurable pause-time target.

Q: When would you use ZGC over G1?

When tail latency matters more than throughput, or with very large heaps. ZGC does almost all work concurrently using coloured pointers and load barriers, giving sub-millisecond pauses independent of heap size, at the cost of some throughput and extra memory.

Q: What's the difference between weak and soft references?

A weakly referenced object is collected at the next GC once no strong references remain — used by WeakHashMap. A softly referenced one is kept until the JVM is under memory pressure, intended for caches, though in practice an explicitly bounded cache is usually a better choice.

Q: Should you call System.gc()?

No. It's a hint the JVM may ignore, and forcing a full collection usually hurts. Legitimate uses are essentially limited to benchmarking and diagnostics.


Part 2 — B-02 · Processes

The Process Control Block

Day 003 introduced the process. The OS's record of one is the PCB:

   ┌──────────────────────────────────────┐
   │  PCB                                 │
   │   PID, parent PID                    │
   │   State (running/ready/blocked/…)    │
   │   Saved registers, program counter   │  ← restored on context switch
   │   Memory map / page table pointer    │
   │   Open file descriptors              │  ← including sockets (Day 016)
   │   User & group IDs                   │
   │   Accounting: CPU time, priority     │
   └──────────────────────────────────────┘

A context switch is saving one PCB's registers and loading another's — which is exactly why it costs (Day 033, B-04).

The five states

                    ┌──────┐
                    │ NEW  │
                    └──┬───┘
                       │ admitted
                       ▼
      ┌────────▶  ┌───────┐  scheduler dispatch   ┌──────────┐
      │           │ READY │ ────────────────────▶ │ RUNNING  │
      │           └───────┘ ◀──────────────────── └────┬─────┘
      │                       preempted / yield        │
      │                                                │ I/O request
      │           ┌─────────┐                          │ or wait
      └───────────│ BLOCKED │◀─────────────────────────┘
        I/O done  └─────────┘                          │
                                                       │ exit
                                                  ┌────▼──────┐
                                                  │TERMINATED │
                                                  └───────────┘

READY vs BLOCKED is the distinction that matters. A ready process wants CPU and is queued for it. A blocked process cannot use CPU even if offered — it's waiting for something external.

That's exactly what your Day 004 server was doing in accept() — blocked, consuming zero CPU, removed from the scheduler's queue until a connection arrived.

fork / exec / wait

The Unix process model, which surprises people coming from Windows:

pid_t pid = fork();          // DUPLICATES the current process
if (pid == 0) {
    execvp("java", args);    // REPLACES this process's image with a new program
} else {
    wait(&status);           // parent waits for the child
}
Call Does
fork() Creates a near-identical copy. Returns 0 in the child, the child's PID in the parent.
exec() Replaces the current process image with a different program. Does not create a process.
wait() Parent blocks until a child exits and reaps its exit status

Creating a process is fork then exec — copy, then replace. It seems wasteful, which is why copy-on-write exists: the child shares the parent's pages until either writes, and only then is a page actually copied.

Zombie processes: a child that exited but whose parent hasn't called wait(). The PCB lingers to hold the exit status. Orphans — parent died first — get re-parented to init/systemd, which reaps them.

In Java

ProcessBuilder pb = new ProcessBuilder("ls", "-la");
Process p = pb.start();                       // fork + exec under the hood
int exit = p.waitFor();                       // wait()

ProcessBuilder is the safe API. Avoid Runtime.exec(String) — it tokenises on whitespace, which is a command-injection hazard when any part comes from user input. Passing an argument array means no shell parses it.

That's a direct link to the "no subprocess(shell=True) on user input" rule in your security material — same class of bug, different language.


Type this yourself

# Watch process states
ps -eo pid,ppid,state,comm | head -20     # Linux: R=running S=sleeping Z=zombie
top                                        # live view

# Windows
tasklist
Get-Process | Select-Object Id, ProcessName, Responding | Select-Object -First 10
public class ProcessDemo {
    public static void main(String[] args) throws Exception {
        System.out.println("My PID: " + ProcessHandle.current().pid());

        ProcessBuilder pb = new ProcessBuilder(
                System.getProperty("os.name").startsWith("Windows")
                        ? new String[]{"cmd", "/c", "dir"}
                        : new String[]{"ls", "-la"});
        pb.inheritIO();
        Process child = pb.start();
        System.out.println("Child PID: " + child.pid());
        System.out.println("Exit code: " + child.waitFor());

        ProcessHandle.current().children()
                .forEach(h -> System.out.println("child: " + h.pid()));
    }
}

Interview questions

Q: What is a PCB?

The kernel's record of a process — PID, state, saved registers and program counter, memory map, open file descriptors, user IDs and scheduling information. A context switch saves one process's PCB register state and restores another's.

Q: Difference between READY and BLOCKED?

A ready process can run and is queued for the CPU. A blocked process is waiting on an external event such as I/O and cannot use the CPU even if given it, so the scheduler skips it entirely.

Q: Explain fork and exec.

fork duplicates the calling process, returning zero in the child and the child's PID in the parent. exec replaces the current process image with a different program without creating a new process. Starting a program is typically fork followed by exec, with copy-on-write making the duplication cheap.

Q: What is a zombie process?

A child that has exited but whose parent hasn't called wait to collect its exit status, so the kernel keeps its PCB. Many zombies indicate a parent that isn't reaping children.


Mini task

  1. Run GcDemo and record part 1 vs part 2 timings. Explain the result via the generational hypothesis.
  2. Run the same program under all four collectors with -Xlog:gc. Tabulate pause times.
  3. Use -Xmx64m and watch full GCs appear in the log. Identify a "Pause Full" entry.
  4. Write out the GC root categories from memory.
  5. Run ProcessDemo and find both PIDs in your OS's process list.

Exit questions

  1. What question does GC actually ask — and why isn't it reference counting?
  2. Why can Java collect reference cycles when reference counting can't?
  3. State the generational hypothesis and how the heap layout exploits it.
  4. Why is minor GC cheap even when it collects millions of objects?
  5. Minor vs major GC — cost and frequency.
  6. Explain G1 in one sentence. What's a humongous object?
  7. How does ZGC achieve sub-millisecond pauses, and what does it trade away?
  8. Weak vs soft vs phantom references — where does each show up in real code?
  9. READY vs BLOCKED, and which one was your Day 004 server in?
  10. What is fork/exec, and why is Runtime.exec(String) dangerous?

Articulation drill

Two minutes: "How does garbage collection work in Java?"

Reachability from roots → generational hypothesis → young/old split → minor vs major → pause-time goals in G1. Five beats, in that order.


Previous: Day 025 · Tomorrow: Day 027 — GC tuning, memory leaks, and diagnosing an OutOfMemoryError