Lesson 44 of 71
GC Fundamentals
This lesson explains the ideas that every HotSpot collector is built from: what makes an object live, how a collector finds and reclaims garbage, why the heap is split into generations, and how a collector can mark objects while the application keeps running. It builds on Object Layout and Allocation (headers and TLABs), Runtime Data Areas (heap and memory pools), and Safepoints, Warmup, and Deoptimization (safepoints). After it, a reader can explain a GC log line in terms of these ideas and measure collections from code.
| Idea | One-line meaning |
|---|---|
| Reachability | An object is live if a chain of references leads to it from a GC root |
| Mark, sweep, compact, copy | The four basic steps or strategies of a tracing collector |
| Generations | Young (eden plus two survivor spaces) and old, collected at different rates |
| Tenuring | Promotion of an object from young to old after it survives enough collections |
| Card table and write barrier | How a young collection finds old-to-young references without scanning the old generation |
| Tri-color marking, SATB | How marking stays correct while application threads change references |
MXBeans and ReferenceQueue |
How a program observes collections and the death of objects |
Collection timing depends on the machine. Every example fixes the heap size and collector with jvm= flags, and prints checks (young collections > 0: true) instead of counts or times.
Example 1: tracing collects cycles
A GC root is a reference the collector treats as live without proof: a local variable in an active stack frame, a static field of a loaded class, a JNI handle, or a live thread object. A tracing collector starts at the roots and follows references; everything it reaches is live, and everything else is garbage. Reference counting, the alternative, keeps a count of incoming references per object and frees an object when its count drops to zero. Reference counting cannot free a cycle, because each object in the cycle keeps the other's count above zero. HotSpot collectors all trace.
import java.lang.ref.WeakReference;
public class CycleIsGarbage {
static class Node {
Node next;
}
public static void main(String[] args) {
Node a = new Node();
Node b = new Node();
a.next = b;
b.next = a; // a <-> b cycle
WeakReference<Node> watchA = new WeakReference<>(a);
WeakReference<Node> watchB = new WeakReference<>(b);
System.gc();
System.out.println("reachable from a local: a alive = " + (watchA.get() != null));
a = null;
b = null; // the cycle is now unreachable
System.gc();
System.out.println("cycle collected: a cleared = " + (watchA.get() == null)
+ ", b cleared = " + (watchB.get() == null));
}
}reachable from a local: a alive = true
cycle collected: a cleared = true, b cleared = trueA WeakReference (introduced in Immutability and Object Lifecycle) does not keep its referent alive, so get() returning null shows that the collector found no strong path from a root. Each node still points to the other, but no root points to either. System.gc() with G1 runs a full stop-the-world collection by default (ExplicitGCInvokesConcurrent is false in JDK 25), which makes these checks deterministic.
Example 2: the kinds of GC roots
This example keeps one object alive through each common kind of root and leaves one unreachable.
import java.lang.ref.WeakReference;
import java.util.concurrent.CountDownLatch;
public class GcRoots {
static Object staticRoot = new Object(); // root: static field of a loaded class
public static void main(String[] args) throws InterruptedException {
Object localRoot = new Object(); // root: local in an active frame
CountDownLatch picked = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
Object[] holder = { new Object() };
Thread worker = new Thread(() -> {
Object onWorkerStack = holder[0]; // root: local in another thread's frame
picked.countDown();
try { release.await(); } catch (InterruptedException e) { return; }
System.out.println("worker still holds: " + (onWorkerStack != null));
});
worker.start();
picked.await(); // the worker has read holder[0]
var wStatic = new WeakReference<>(staticRoot);
var wLocal = new WeakReference<>(localRoot);
var wThread = new WeakReference<>(holder[0]);
var wNone = new WeakReference<>(new Object());
holder[0] = null; // only the worker's frame refers to it now
System.gc();
System.out.println("static field root: " + (wStatic.get() != null));
System.out.println("local variable root: " + (wLocal.get() != null));
System.out.println("other thread's stack root: " + (wThread.get() != null));
System.out.println("no root: " + (wNone.get() != null));
System.out.println("localRoot used later: " + (localRoot != null));
release.countDown();
worker.join();
}
}static field root: true
local variable root: true
other thread's stack root: true
no root: false
localRoot used later: true
worker still holds: trueRoots are found at a safepoint, where every thread's frames have a known map of which slots hold references. The JIT may treat a local as dead after its last use, so a local that is never read again does not keep its object alive in compiled code. The example reads localRoot after the collection for that reason.
Example 3: mark, sweep, compact, and copy
The basic strategies differ in what they do with free space:
| Strategy | Steps | Result | Cost grows with |
|---|---|---|---|
| Mark-sweep | Mark live objects, add dead ones to free lists | Free space is fragmented | Heap size (sweep visits everything) |
| Mark-compact | Mark, then slide live objects together | One free block, bump allocation possible | Heap size and live data |
| Copying | Copy live objects from a "from" space to an empty "to" space | From-space is empty after the copy | Live data only |
Fragmentation means free memory exists but no single free block is large enough for a request. Copying needs a spare empty space, but its cost depends only on live objects, which is why young generations use it. The program below simulates both on a heap of eight slots.
import java.util.Arrays;
public class SweepVsCompact {
public static void main(String[] args) {
String[] heap = { "A", "b", "C", "d", "e", "F", "g", "H" }; // upper case = live
String[] swept = Arrays.copyOf(heap, heap.length);
for (int i = 0; i < swept.length; i++) {
if (Character.isLowerCase(swept[i].charAt(0))) swept[i] = "."; // sweep: free in place
}
System.out.println("after sweep: " + String.join("", swept)
+ " largest free block = " + largestFree(swept));
String[] compacted = new String[heap.length];
Arrays.fill(compacted, ".");
int top = 0;
for (String s : heap) {
if (Character.isUpperCase(s.charAt(0))) compacted[top++] = s; // slide live objects down
}
System.out.println("after compact: " + String.join("", compacted)
+ " largest free block = " + largestFree(compacted)
+ ", next allocation at slot " + top);
}
static int largestFree(String[] heap) {
int best = 0, run = 0;
for (String s : heap) {
run = s.equals(".") ? run + 1 : 0;
best = Math.max(best, run);
}
return best;
}
}after sweep: A.C..F.H largest free block = 2
after compact: ACFH.... largest free block = 4, next allocation at slot 4After the sweep, a three-slot object cannot be placed even though four slots are free. After compaction, the free space is one block and allocation is a pointer bump, the same mechanism TLABs use. Moving objects requires updating every reference to them, so compaction and copying need the application stopped or a barrier that redirects stale references.
Example 4: the generational hypothesis and young collections
The weak generational hypothesis states that most objects die young. HotSpot's generational collectors split the heap into a young generation (an eden space for new objects plus two survivor spaces) and an old generation. A minor (young) collection copies live objects out of eden and one survivor space; because most objects are dead, it copies little. A major collection includes the old generation, and a full collection is a stop-the-world collection of the whole heap.
import java.lang.management.GarbageCollectorMXBean;
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryPoolMXBean;
import java.lang.management.MemoryType;
public class ShortLived {
public static void main(String[] args) {
ManagementFactory.getMemoryPoolMXBeans().stream()
.filter(p -> p.getType() == MemoryType.HEAP)
.map(MemoryPoolMXBean::getName).sorted()
.forEach(name -> System.out.println("heap pool: " + name));
long checksum = 0;
for (int i = 0; i < 1_000_000; i++) {
byte[] tmp = new byte[256]; // dies right after this iteration
checksum += tmp.length;
}
System.out.println("allocated MB: " + checksum / (1024 * 1024));
for (GarbageCollectorMXBean gc : ManagementFactory.getGarbageCollectorMXBeans()) {
switch (gc.getName()) {
case "G1 Young Generation" ->
System.out.println("young collections > 0: " + (gc.getCollectionCount() > 0));
case "G1 Old Generation" ->
System.out.println("full collections: " + gc.getCollectionCount());
default -> { }
}
}
}
}heap pool: G1 Eden Space
heap pool: G1 Old Gen
heap pool: G1 Survivor Space
allocated MB: 244
young collections > 0: true
full collections: 0The program allocates about 244 MB in a 64 MB heap with no full collection, because each young collection finds almost nothing live. For G1, the bean named "G1 Old Generation" counts full collections only; concurrent old-generation work is reported by a third bean, covered in G1 in Depth.
Example 5: survivor spaces and the tenuring threshold
Each object has an age (stored in its header) that grows by one every time it survives a young collection by being copied into a survivor space. When the age reaches the tenuring threshold, the next copy goes to the old generation instead; this is promotion (also called tenuring). MaxTenuringThreshold caps the threshold; the collector lowers the actual threshold when survivor space fills past TargetSurvivorRatio percent. PrintFlagsFinal on JDK 25 reports MaxTenuringThreshold = 15, InitialTenuringThreshold = 7, and TargetSurvivorRatio = 50. The age field has four bits, so 15 is also the highest legal value.
The program sets the threshold to 0 so every surviving object is promoted at its first young collection, and checks the survivor pool.
import com.sun.management.HotSpotDiagnosticMXBean;
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryPoolMXBean;
import java.util.ArrayList;
import java.util.List;
public class Tenuring {
public static void main(String[] args) {
var diag = ManagementFactory.getPlatformMXBean(HotSpotDiagnosticMXBean.class);
System.out.println("MaxTenuringThreshold = "
+ diag.getVMOption("MaxTenuringThreshold").getValue());
List<byte[]> retained = new ArrayList<>();
for (int i = 0; i < 200_000; i++) {
byte[] b = new byte[512];
if (i % 10 == 0) retained.add(b); // 10% survive
}
MemoryPoolMXBean survivor = pool("G1 Survivor Space");
MemoryPoolMXBean old = pool("G1 Old Gen");
System.out.println("retained objects: " + retained.size());
System.out.println("survivor space empty: " + (survivor.getUsage().getUsed() == 0));
System.out.println("old gen holds promoted data: " + (old.getUsage().getUsed() > 0));
}
static MemoryPoolMXBean pool(String name) {
return ManagementFactory.getMemoryPoolMXBeans().stream()
.filter(p -> p.getName().equals(name)).findFirst().orElseThrow();
}
}MaxTenuringThreshold = 0
retained objects: 20000
survivor space empty: true
old gen holds promoted data: trueA threshold of 0 removes survivor aging. It moves medium-lived objects into the old generation, where only a more expensive collection can reclaim them. Premature promotion is the general name for this effect; a too-small survivor space causes it too.
Example 6: old-to-young references, card tables, and write barriers
A young collection must not free a young object that only an old object refers to. Scanning the whole old generation to find such references would make young collections as slow as full ones. HotSpot instead keeps a card table: a byte array where each byte (a card) covers 512 bytes of heap. A write barrier is a few instructions the JIT and interpreter add to every reference store into a heap field; the post-write barrier marks the card of the updated object as dirty. A young collection scans only dirty cards of the old generation as extra roots.
import java.lang.management.ManagementFactory;
import java.lang.ref.WeakReference;
public class OldToYoung {
static class Holder {
Object field;
}
public static void main(String[] args) {
Holder oldHolder = new Holder();
System.gc(); // full GC moves oldHolder to the old gen
System.gc();
Object young = new Object(); // allocated in eden
oldHolder.field = young; // store: write barrier dirties a card
WeakReference<Object> watch = new WeakReference<>(young);
young = null; // only the old object refers to it
long youngBefore = youngCount();
long garbage = 0;
for (int i = 0; i < 500_000; i++) garbage += new byte[256].length; // force young GCs
System.out.println("young GCs ran: " + (youngCount() > youngBefore) + " (" + garbage + " bytes)");
System.out.println("young object kept alive by old holder: " + (watch.get() != null));
System.out.println("same object: " + (watch.get() == oldHolder.field));
}
static long youngCount() {
return ManagementFactory.getGarbageCollectorMXBeans().stream()
.filter(gc -> gc.getName().equals("G1 Young Generation"))
.mapToLong(gc -> gc.getCollectionCount()).sum();
}
}young GCs ran: true (128000000 bytes)
young object kept alive by old holder: true
same object: trueThe allocation loop fills eden several times over, so several young collections run. The young object survives each one only because the dirty card leads the collector to oldHolder.field. G1 refines dirty cards into per-region remembered sets, explained in G1 in Depth. Write barriers are a throughput cost on every reference store, which is one reason collectors with more barrier work run application code more slowly.
Example 7: concurrent marking, tri-color, and SATB
A stop-the-world (STW) phase runs while all application threads are paused at a safepoint. A concurrent phase runs on GC threads while application threads (the mutator) keep running. Concurrent marking uses tri-color terms: white objects are not yet reached, gray objects are reached but their fields are not yet scanned, black objects are reached and fully scanned. Marking ends when no gray objects remain, and white objects are garbage.
A live object is lost when both happen: the mutator stores a reference to a white object into a black object, and removes the last path to that white object from any gray object. A barrier breaks one of the two conditions:
| Technique | Barrier | Used by |
|---|---|---|
| Incremental update | On store, gray the new target (or re-gray the black source) | CMS (removed in Java 14) |
| Snapshot at the beginning (SATB) | On store, record the old value being overwritten | G1, Shenandoah |
SATB treats everything reachable when marking started as live, so it preserves the overwritten white object. The simulation shows the lost object and the fix.
import java.util.*;
public class TriColor {
enum Color { WHITE, GRAY, BLACK }
static Map<String, List<String>> fields = new HashMap<>();
static Map<String, Color> color = new TreeMap<>();
static Deque<String> grayStack = new ArrayDeque<>();
static boolean satb;
static void shade(String o) {
if (color.get(o) == Color.WHITE) { color.put(o, Color.GRAY); grayStack.push(o); }
}
static void store(String obj, int slot, String value) { // mutator reference store
String old = fields.get(obj).get(slot);
if (satb && old != null) shade(old); // SATB pre-write barrier
fields.get(obj).set(slot, value);
}
static String run(boolean useSatb) {
satb = useSatb;
fields.clear(); color.clear(); grayStack.clear();
fields.put("root", new ArrayList<>(List.of("A")));
fields.put("A", new ArrayList<>(Arrays.asList("B", (String) null)));
fields.put("B", new ArrayList<>(Arrays.asList("C")));
fields.put("C", new ArrayList<>());
for (String o : fields.keySet()) color.put(o, Color.WHITE);
shade("root");
scanOne(); // root black, A gray
scanOne(); // A black, B gray; C still white
store("A", 1, fields.get("B").get(0)); // mutator: A.f1 = B.f0 (A is black)
store("B", 0, null); // mutator: B.f0 = null
while (!grayStack.isEmpty()) scanOne();
return color.toString();
}
static void scanOne() {
String o = grayStack.pop();
for (String child : fields.get(o)) if (child != null) shade(child);
color.put(o, Color.BLACK);
}
public static void main(String[] args) {
System.out.println("no barrier: " + run(false) + " (C is live but white)");
System.out.println("SATB: " + run(true));
}
}no barrier: {A=BLACK, B=BLACK, C=WHITE, root=BLACK} (C is live but white)
SATB: {A=BLACK, B=BLACK, C=BLACK, root=BLACK}Without a barrier, C is reachable through black A but stays white, and a sweep would free a live object. The SATB barrier records C when B.f0 is overwritten, so marking reaches it. SATB can keep some objects that died during marking (floating garbage); the next cycle reclaims them. Concurrent collectors still stop the world briefly, for example to scan roots and to finish marking.
Example 8: allocation rate, MemoryMXBean, and reference queues
The allocation rate (bytes allocated per second) sets how often young collections run: with a fixed eden size, doubling the rate doubles the young GC frequency. MemoryMXBean reports heap usage, GarbageCollectorMXBean reports counts and accumulated times per collector, and a ReferenceQueue receives a reference object after the collector clears it. A PhantomReference always returns null from get(); it exists only to be enqueued after its referent is unreachable, which is how Cleaner runs cleanup actions. A SoftReference is cleared only under memory pressure, and all softly reachable objects are cleared before an OutOfMemoryError.
import java.lang.management.GarbageCollectorMXBean;
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryMXBean;
import java.lang.ref.*;
import java.util.ArrayList;
import java.util.List;
public class GcObservers {
public static void main(String[] args) throws InterruptedException {
var threads = (com.sun.management.ThreadMXBean) ManagementFactory.getThreadMXBean();
long tid = Thread.currentThread().threadId();
long before = threads.getThreadAllocatedBytes(tid);
long sum = 0;
for (int i = 0; i < 100_000; i++) sum += new byte[1000].length;
long allocated = threads.getThreadAllocatedBytes(tid) - before;
System.out.println("allocated at least 100 MB: " + (allocated >= 100_000_000L) + " (" + sum + ")");
MemoryMXBean memory = ManagementFactory.getMemoryMXBean();
long max = memory.getHeapMemoryUsage().getMax();
System.out.println("heap max is 64 MB: " + (max == 64L * 1024 * 1024));
long totalCount = 0, totalTime = 0;
for (GarbageCollectorMXBean gc : ManagementFactory.getGarbageCollectorMXBeans()) {
totalCount += gc.getCollectionCount();
totalTime += gc.getCollectionTime();
}
System.out.println("collections > 0: " + (totalCount > 0) + ", time >= 0: " + (totalTime >= 0));
ReferenceQueue<Object> queue = new ReferenceQueue<>();
Object weakTarget = new Object(), phantomTarget = new Object();
var weak = new WeakReference<>(weakTarget, queue);
var phantom = new PhantomReference<>(phantomTarget, queue);
var soft = new SoftReference<>(new byte[8 * 1024 * 1024]);
System.out.println("phantom get(): " + phantom.get());
weakTarget = null;
phantomTarget = null;
System.gc();
List<String> enqueued = new ArrayList<>();
for (int i = 0; i < 2; i++) {
Reference<?> r = queue.remove(5_000);
enqueued.add(r == weak ? "weak" : r == phantom ? "phantom" : "timeout");
}
enqueued.sort(null);
System.out.println("enqueued: " + enqueued);
System.out.println("soft survives a GC with free memory: " + (soft.get() != null));
List<byte[]> hog = new ArrayList<>();
try {
while (true) hog.add(new byte[1024 * 1024]);
} catch (OutOfMemoryError e) {
hog.clear();
System.out.println("soft cleared before OutOfMemoryError: " + (soft.get() == null));
}
}
}allocated at least 100 MB: true (100000000)
heap max is 64 MB: true
collections > 0: true, time >= 0: true
phantom get(): null
enqueued: [phantom, weak]
soft survives a GC with free memory: true
soft cleared before OutOfMemoryError: trueThe reference handler thread enqueues cleared references after the collection, so queue.remove(timeout) blocks until they arrive; sorting makes the output independent of order. Whether a soft reference survives an ordinary collection depends on SoftRefLRUPolicyMSPerMB (default 1000): a soft referent stays alive for about that many milliseconds per free megabyte since its last access. Only the clearing before OutOfMemoryError is guaranteed by the specification.
Interview points
- HotSpot collectors trace from roots; cycles are collected, which reference counting cannot do without a cycle detector.
- GC roots include stack locals in active frames, static fields, JNI handles, and live threads. Compiled code may treat a local as dead after its last use.
- Copying costs scale with live data, mark-sweep with heap size; compaction removes fragmentation at the cost of moving objects and fixing references.
- Minor collects young only; full is a stop-the-world collection of the whole heap. "Major" is used loosely for old-generation collections.
- Objects age once per survived young GC; promotion happens at the tenuring threshold, at most
MaxTenuringThreshold(15 by default, also the maximum because of the 4-bit age). - Card table plus post-write barrier lets a young GC find old-to-young references by scanning dirty cards only.
- Concurrent marking needs a barrier: SATB records overwritten values (G1, Shenandoah); incremental update records new values (CMS).
- Allocation rate drives young GC frequency; reducing allocation is the first lever before tuning flags.
- Soft references are cleared before OOM; weak references at the first GC that finds them weakly reachable; phantom references are enqueued after the referent is unreachable and never return the referent.
Summary
- Liveness is reachability from GC roots; unreachable cycles are garbage.
- Mark-sweep leaves fragmented free space; mark-compact and copying leave one contiguous free block.
- The young generation is eden plus two survivor spaces and is collected by copying.
MaxTenuringThresholddefaults to 15,InitialTenuringThresholdto 7,TargetSurvivorRatioto 50 on JDK 25.- A card covers 512 bytes; the write barrier dirties the card on each reference store.
- Tri-color marking loses an object only when a black object gains a reference to a white object and the last gray path to it is removed; SATB prevents the second effect.
- G1 exposes the collector beans "G1 Young Generation", "G1 Concurrent GC", and "G1 Old Generation", and the heap pools "G1 Eden Space", "G1 Survivor Space", and "G1 Old Gen".
ReferenceQueuedelivers cleared weak, soft, and phantom references after a collection.
