Topic 14.3
Garbage Collection
In one line
The garbage collector frees objects that can no longer be reached from any GC root, so you never free memory by hand. Modern JVMs split the heap into generations because most objects die young, and offer collectors with different goals: G1 (the default since Java 9) balances throughput and pauses, ZGC and Shenandoah keep pauses around a millisecond, Parallel maximises throughput.
Think of it like this
Cleaning a playground at the end of the day. The cleaner doesn't ask "is this toy broken?". She starts from the children still playing, follows what each one is holding, and what those toys are tied to, and keeps all of that. Everything she never reached goes in the bin, even two toys tied to each other in a corner, because no child holds either. The children are the GC roots, following ties is marking, and the bin run is sweeping.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Garbage collector (GC)
- The part of the JVM that finds objects nothing can reach any more and reuses their memory.
- GC root
- A starting point the collector always treats as alive: local variables in running methods, static fields, live threads.
- Reachable
- An object you can get to by following references from a GC root. Reachable objects are kept; unreachable ones are garbage.
- Generation
- A part of the heap for objects of similar age. New objects start in the young generation; long-lived ones move to the old generation.
- Promotion
- Moving an object that has survived enough young collections into the old generation.
- Stop-the-world pause
- A moment when the JVM stops every application thread so the collector can work safely.
- Safepoint
- A point in the code where a thread can be paused safely because the JVM knows exactly where all its references are.
- Concurrent collector
- A collector that does most of its work while your program keeps running, so pauses are short.
- Throughput
- The share of time the program spends running your code rather than collecting garbage.
- Memory leak (Java)
- Objects that are no longer needed but are still reachable, so the collector is not allowed to free them.
Step by step
01Reachability, not usefulness
The collector only knows the reference graph. The diagram shows a heap with one root: order and everything it references are live; oldCart is garbage because nothing points to it; nodeA and nodeB point to each other but are garbage too, because no root reaches them. A reference-counting scheme would leak that cycle; tracing collectors don't.
02Young collections: copy the few survivors
New objects land in eden (inside each thread's TLAB). When eden fills, a minor GC starts: live objects from eden and the current survivor space are copied to the other survivor space and their age goes up by one; objects whose age reaches the tenuring threshold are copied to the old generation instead. Then eden and the old survivor space are simply declared empty. If 98% of eden is dead, the GC touched only 2% of it.
This is why short-lived objects are cheap in Java, and why objects that live *medium* long (cached for a few seconds, then dropped) are the expensive kind: they get copied several times, promoted, and then have to be found by an old-generation collection.
03G1: a heap of regions
G1 cuts the heap into equal regions (typically 1 to 32 MB, picked from the heap size, or set with -XX:G1HeapRegionSize). Each region is, at any moment, eden, survivor, old, humongous or free. A young collection evacuates all eden and survivor regions. When the heap passes an occupancy threshold, G1 marks the old generation concurrently, then runs mixed collections that also evacuate the old regions with the most garbage ("garbage first", the name).
Each region has a remembered set recording which other regions point into it, so G1 can evacuate a few regions without scanning the whole heap. G1 chooses how many regions to collect so that the pause fits MaxGCPauseMillis. If it can't keep up, it falls back to a full GC (parallel since Java 10), which is the pause you want to avoid.
04Reading a GC log
Unified logging (Java 9+) turns on GC logs with -Xlog:gc (one line per collection) or -Xlog:gc* (details). Each line shows the collection type, the cause, heap used before -> after (committed size) and the pause time. The output is sample output from G1; numbers vary on every run.
Read it like this: Pause Young (Normal) (G1 Evacuation Pause) 24M->3M(256M) 2.412ms means eden filled, a young GC took 2.4 ms and heap use dropped from 24 MB to 3 MB. A Concurrent Mark Cycle runs alongside the application; only Remark and Cleanup pause it. A Pause Full line is a warning sign.
05ZGC and Shenandoah: moving objects while you run
Concurrent compaction is the hard part: how can the collector move an object while application threads hold references to it? ZGC stores a few metadata bits in each reference (coloured pointers) and the JIT inserts a load barrier on every reference load: if the bits say the object moved, the barrier fixes the reference on the spot. Shenandoah uses its own load-reference barriers for the same purpose. Pauses then only cover scanning thread stacks' roots, which is why they stay around a millisecond on heaps of hundreds of gigabytes.
The cost is throughput and memory: barriers make every reference load slightly slower, and a concurrent collector needs headroom in the heap so the application can keep allocating while it works. If allocation outruns it, ZGC stalls the allocating threads ("Allocation Stall" in the logs).
06Choosing a collector
Start with the default (G1) and measure. Switch to Parallel for batch jobs where total time matters and pauses don't. Choose ZGC (generational on Java 21+) or Shenandoah for latency-sensitive services with large heaps where 50-200 ms pauses hurt. Use Serial for tiny containers (one CPU, small heap) and CLI tools.
-XX:+UseSerialGC # one thread, small heaps
-XX:+UseParallelGC # throughput
-XX:+UseG1GC -XX:MaxGCPauseMillis=100 # default collector, tighter pause goal
-XX:+UseZGC -XX:+ZGenerational # Java 21-22: opt in to generational ZGC
-XX:+UseZGC # Java 23+: generational is already the default
-XX:+UseShenandoahGC # in OpenJDK builds that include it
-Xlog:gc*:file=gc.log:time,uptime:filecount=5,filesize=10m # rotating GC log07Reference types and cleanup
A normal reference is strong. The java.lang.ref classes wrap an object without keeping it alive: WeakReference.get() returns null once the object was collected; a SoftReference survives until memory is tight (a poor man's cache, often a bad one because it clears in bulk under pressure); a PhantomReference never returns its object and is used, with a ReferenceQueue, to learn that an object is gone. Cleaner (Java 9) is built on phantom references and is the modern replacement for finalize().
Exactly *when* these are cleared depends on the collector and timing, which is why this lesson doesn't print them in a runnable example.
import java.lang.ref.Cleaner;
final class NativeBuffer implements AutoCloseable {
private static final Cleaner CLEANER = Cleaner.create();
private final Cleaner.Cleanable cleanable;
NativeBuffer(long address) {
// the cleanup action must NOT capture 'this', or the object never becomes unreachable
this.cleanable = CLEANER.register(this, () -> free(address));
}
@Override public void close() { cleanable.clean(); } // normal path: try-with-resources
private static void free(long address) { /* release native memory */ }
}Try it yourself
- 1
Watch the collector work
On your own JDK, run the leak example with a loop of 1,000,000 requests and
java -Xmx64m -Xlog:gc Main.java. Predict: the young GCs stop freeing much, the heap line climbs, and it ends withOutOfMemoryError: Java heap space. Then use only the bounded map and watch heap-after-GC stay flat. - 2
Compare collectors
Run the same allocation-heavy program with
-XX:+UseSerialGC -Xlog:gc,-XX:+UseParallelGC -Xlog:gc,-XX:+UseG1GC -Xlog:gcand-XX:+UseZGC -Xlog:gc. Compare the number of pauses and their lengths. Expect Parallel to finish the work soonest and ZGC to show the shortest pauses. - 3
Change the tenuring threshold in the model
In "A generational heap in miniature", set
tenureAgeto 1, then to 3. Predict which objects end up inoldafter GC 4 before running. Note how a lower threshold promotesbuf-like medium-lived objects, which then need an old-generation collection to free.
Code & diagrams
A model of what a tracing collector does. The nodeA/nodeB cycle is collected because tracing starts from roots; reference counting would never free it.
Expected output
roots: [order]
live: [order, items, customer, address]
garbage: [oldCart, oldItem, nodeA, nodeB]
heap now holds 4 objectsRequest objects die before their first GC and cost nothing to free. Only long-lived objects are copied and eventually promoted. HotSpot's real threshold is adaptive, up to 15 (-XX:MaxTenuringThreshold).
Expected output
young GC 1: freed 2, survivors [session(age 1), buf(age 1)], old []
young GC 2: freed 3, survivors [], old [session]
young GC 3: freed 1, survivors [cacheEntry(age 1)], old [session]
young GC 4: freed 1, survivors [], old [session, cacheEntry]The GC never frees what is reachable. The bounded map keeps memory flat however many requests arrive (LinkedHashMap access order is Topic 9.6).
Expected output
leaky map entries: 10000 (10000 KB of arrays kept alive)
bounded map entries: 100
oldest kept key: 9901
after touch + put, oldest: 9902Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
An ever-growing static list
Add every request object to a static List<Object> AUDIT = new ArrayList<>() and never remove them; run with -Xmx64m.
Break #2
Relying on finalize()
Close a file in finalize() instead of using try-with-resources, and compile with -Xlint:removal.
Myth vs fact
Myth
Setting a variable to null frees the object.
Fact
It removes one reference. The object is freed only when it is unreachable and a collection runs. Nulling locals is almost never needed; the JIT already knows when a local is dead.
Myth
Java can't have memory leaks.
Fact
It can't leak unreachable memory, but it leaks reachable memory easily: growing static maps, caches without bounds, listeners never removed, ThreadLocals on pooled threads.
Myth
System.gc() runs a collection immediately.
Fact
It's a request the JVM may ignore (-XX:+DisableExplicitGC). When honoured it usually triggers a full collection, which is often slower than letting the collector decide.
Myth
ZGC is always better than G1.
Fact
ZGC trades some throughput and memory headroom for very short pauses. For batch work or small heaps, G1 or Parallel often finishes sooner.
When it breaks
p99 latency spikes to several seconds every few hours on a G1 service.
What you see
GC logs show Pause Full (G1 Compaction Pause) or To-space exhausted: G1 couldn't evacuate in time and fell back to a full collection.
Fix & prevent
Give G1 headroom (larger heap or lower occupancy), start concurrent marking earlier (-XX:InitiatingHeapOccupancyPercent, or let adaptive IHOP work), reduce humongous allocations, or move to ZGC if pauses must stay tiny. Fix the allocation and live-set growth first.
A service's CPU is at 100% but request throughput collapsed.
What you see
GC logs show back-to-back collections freeing almost nothing; eventually OutOfMemoryError: GC overhead limit exceeded (Parallel) or Java heap space.
Fix & prevent
This is a live-set problem (a leak or under-sized heap), not a GC tuning problem. Take a heap dump, find the retainer, and add -XX:+ExitOnOutOfMemoryError so the instance restarts instead of limping.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Concurrent marking must not miss objects the application moves around while marking runs. G1 and Shenandoah use snapshot-at-the-beginning (SATB): a pre-write barrier records the old value of every overwritten reference, so everything reachable when marking started is marked. A post-write barrier maintains G1's remembered sets.
- ▸
Safepoint pauses are not only GC: deoptimisation, biased-lock revocation (biased locking was disabled by default in JDK 15 and later removed), class redefinition and some
jcmdoperations also stop the world.-Xlog:safepointshows each one with time-to-safepoint, which exposes long counted loops that delay every pause. - ▸
Allocation rate drives young GC frequency; promotion rate and the live set drive old-generation work. Reducing allocation in hot paths (Topic 14.6) often helps more than any GC flag. JFR's allocation profiling shows which code allocates the most (Topic 14.5).
- ▸
Humongous objects in G1 (at least half a region) go straight to old regions; G1 can reclaim some of them eagerly at young collections, but many wait for a concurrent cycle. If logs show many
G1 Humongous Allocationcauses, raiseG1HeapRegionSizeor avoid giant temporary arrays.
Remember this
- 1
An object is live if a chain of references leads to it from a GC root; everything else is garbage, whatever its contents. GC roots include local variables and operands in every thread's stack frames, static fields of loaded classes, JNI references, live threads themselves, and monitors held by synchronisation. Because the test is reachability, not reference counting, cycles of objects pointing at each other are collected as soon as no root reaches them. And the flip side: an object that is still reachable is never collected, even if you'll never use it again, which is what a Java memory leak is (a static
Mapthat only grows, a listener never removed). - 2
The basic algorithms combine three moves. Mark: trace from the roots and flag reachable objects. Sweep: free the unmarked space (fast, but leaves holes, called fragmentation). Compact or copy: move live objects together so free space is one block and allocation stays a pointer bump (Topic 14.2). Copying costs time proportional to *live* objects, not to the heap size, which is why it's used where most objects are dead.
- 3
The weak generational hypothesis says most objects die young: request objects, iterators, temporary strings. So the heap is split into a young generation (an eden space where new objects are allocated, plus two survivor spaces) and an old generation. A minor (young) GC copies the few live young objects to a survivor space and wipes eden in one go; objects that survive several young GCs (the tenuring threshold, up to 15) are promoted to the old generation, which is collected less often. A card table remembers old-to-young references so a young GC needn't scan the whole old generation.
- 4
Stop-the-world (STW) pauses stop all application threads at a safepoint so the collector sees a consistent heap. Concurrent collectors do most of their work, such as marking, while the application runs, using barriers (small pieces of code the JIT adds around reference reads or writes) to track changes. The trade-off is always the same triangle: throughput (share of CPU spent on your code), latency (pause length) and footprint (memory overhead). No collector wins all three.
- 5
HotSpot's collectors: Serial (
-XX:+UseSerialGC, one thread, STW; good for small heaps and single-CPU containers, and chosen automatically on machines the JVM doesn't consider "server class"). Parallel (-XX:+UseParallelGC, STW with many threads; best throughput, the default up to Java 8). G1 (-XX:+UseG1GC, default since Java 9): the heap is divided into equal regions; young collections and mixed collections evacuate the regions with the most garbage first, aiming for a pause-time goal (-XX:MaxGCPauseMillis, default 200 ms), with concurrent marking of the old generation. ZGC (-XX:+UseZGC, production since Java 15): almost everything is concurrent, including moving objects, using coloured pointers and load barriers; pauses are typically well under a millisecond regardless of heap size. Java 21 added Generational ZGC (-XX:+ZGenerational), which became the default ZGC mode in Java 23. Shenandoah (-XX:+UseShenandoahGC, production since Java 15, in most OpenJDK builds but not in Oracle's JDK) also compacts concurrently. Epsilon (Java 11) allocates and never collects, for tests and benchmarks. - 6
You can't force a collection:
System.gc()is only a request (often honoured, sometimes disabled with-XX:+DisableExplicitGC).finalize()is unreliable, slows the collector, and is deprecated for removal since Java 18; use try-with-resources (Topic 7.6) for resources andjava.lang.ref.Cleaner(Java 9) as a safety net. Thejava.lang.refpackage also gives soft references (cleared only when memory is tight), weak references (cleared at the next GC once nothing strong points to the object; used byWeakHashMap) and phantom references (for post-mortem cleanup).
Explain it without notes
How does the JVM decide that an object is garbage? What are GC roots?
Explain the generational hypothesis and how a minor GC works.
Compare G1, ZGC and Parallel GC. When would you pick each?
What is a memory leak in Java, and how would you find one?
Practice
Extend the mark-and-sweep model so oldCart becomes reachable again by adding it as a second root, and print the new garbage list.
Write an LRU cache of capacity 3 with LinkedHashMap, insert keys 1 to 5, read key 3 and insert key 6; print the keys.
Show the difference between a GC root reference and a local that is out of scope: write a method that builds a big list and returns only its size, and explain in a comment why the list is collectable after the method returns.
Trade-offs
- ↔
Throughput vs latency vs footprint: Parallel maximises throughput, ZGC and Shenandoah minimise pauses, Serial minimises overhead; G1 sits in the middle.
- ↔
A bigger heap means fewer collections but more memory, and with STW collectors, longer full pauses.
- ↔
Concurrent collectors need spare CPU and heap headroom; on a CPU-starved container they can fall behind and stall allocation.
- ↔
Caches trade memory for speed; without bounds or expiry they become leaks.
Done when you can
Done when you can explain reachability, GC roots, and why cycles are collected.
Done when you can describe eden, survivors, ageing and promotion.
Done when you can compare Serial, Parallel, G1, ZGC and Shenandoah and pick one for a workload.
Done when you can read a G1 log line and spot full GCs and a rising live set.
Done when you can explain why finalize() is deprecated and what replaces it.