Core Concepts of Java Garbage Collection
Core Concepts of Java Garbage Collection
Every Java application allocates objects on the heap, but unlike C or C++, Java does not require you to free them manually. Instead, the JVM’s garbage collector (GC) reclaims memory from objects that are no longer reachable.
Understanding how this works is essential for writing performant Java applications — and it all starts with one insight: most objects die young. This observation, called the generational hypothesis, lets the JVM optimize by dividing the heap into generations and collecting them at different frequencies. G1GC (the default since Java 9) organizes memory as follows:
- Eden — where every new allocation happens first.
- Survivor spaces — objects that survive a young-gen collection are copied here and given an age. After surviving enough minor collections, they get promoted to old generation.
- Old generation — long-lived objects accumulate here. When it fills up, the collector must do a more expensive mixed or full collection that scans all generations.
- Humongous regions — G1 treats allocations larger than half a region size (default 32 KB) as “humongous,” allocating them directly into old-gen-sized regions rather than trying to fit them in Eden/Survivor.
Young-gen collections scan very little memory and are fast. Old-gen collections are slower but far less frequent. This post demonstrates the concept by running a small program under G1GC with detailed logging, so you can watch each collection event as it happens.
The code
The following program exercises all three generations in sequence:
- Phase 1 allocates eight 4 MB objects and drops each reference immediately. Under a small heap, these overflow Eden on every iteration, forcing young-gen collections. Because 4 MB exceeds the G1 region size, each is also classified as a humongous allocation.
- Phase 2 keeps six 4 MB objects alive across collection cycles so they age up and get promoted.
- Phase 3 drops more short-lived objects while the old generation already holds survivors, demonstrating what happens when young-gen pressure meets an aging old gen.
import java.util.ArrayList;
import java.util.List;
public class GcDemo {
public static void main(String[] args) throws Exception {
// Phase 1: abandon short-lived objects in tight allocation bursts
System.out.println("=== Phase 1 — Rapid short-lived allocations ===");
for (int batch = 0; batch < 8; batch++) {
byte[] data = new byte[4 * 1024 * 1024]; // 4 MB
data = null; // abandon
Thread.yield();
System.out.println("Batch " + (batch + 1) + ": allocated & abandoned 4 MB");
}
System.gc();
Thread.sleep(500);
// Phase 2: keep objects alive so they age up and get promoted
System.out.println("\n=== Phase 2 — Objects surviving (aging) ===");
List<byte[]> survivors = new ArrayList<>();
for (int i = 0; i < 6; i++) {
byte[] chunk = new byte[4 * 1024 * 1024]; // kept alive
survivors.add(chunk);
Thread.yield();
System.out.println("Survivor " + (i + 1) + ": kept alive 4 MB");
}
// Phase 3: more short-lived objects on top of survivors
System.out.println("\n=== Phase 3 — Mixed phase (survivors + new allocs) ===");
for (int batch = 0; batch < 10; batch++) {
byte[] data = new byte[2 * 1024 * 1024]; // 2 MB
data = null;
Thread.yield();
System.out.println("Mixed alloc " + (batch + 1) + ": allocated & abandoned 2 MB");
}
System.gc();
Thread.sleep(500);
System.out.println("\n=== Summary ===");
System.out.println("Survivors kept alive: " + survivors.size());
for (byte[] s : survivors) { if (s == null) System.out.print(""); }
}
}
Running it
We run the program with G1GC, a tight 24–48 MB heap, and debug-level GC logging enabled (-Xlog:gc=debug). The <video controls src="https://datmt-blog-media.datmt.com/uploads/core-concepts-java-garbage-collection-including/out-16ccc170-e3c6-4979-aec2-dafd0b4fd4e5.mp4"></video> clip below shows the full output as it runs.
What the logs tell us
Phase 1 — Young-gen pressure. Every 4 MB allocation triggers a Pause Young (Concurrent Start) (G1 Humongous Allocation). The heap drops from ~6–7 MB back to ~1 MB after each collection because all eight objects were abandoned and unreachable. G1GC does its job: collect the young, forget the rest.
You also see Concurrent Undo Cycle following each young pause — G1’s recovery bookkeeping when a concurrent mark cycle is aborted or restructured, ensuring no memory is leaked during the restructuring.
At the end of Phase 1, GC(12) Pause Full (System.gc()) at timestamp [0.028s] is the explicit call between phases. This full GC cleans up everything reachable, including humongous regions that might have accumulated.
Phase 2 — Aging and promotion. When we start keeping objects alive, they survive young-gen collections. After surviving enough minor pauses (G1’s default tenuring threshold varies by heap but is typically around 6–8), the collector starts a Concurrent Mark Cycle (GC(16)). This marks all reachable objects to identify what should be promoted.
The critical sub-phases of that concurrent cycle are visible in the logs:
- Pause Remark (
[0.541s]) — stops the world briefly to update references and determine which old-gen regions are live. At this point the heap is 21 MB used out of 39 MB. - Pause Cleanup (
[0.543s]) — compacts free space within young-gen regions so they are ready for new allocations after promotion completes.
Once the concurrent mark cycle finishes, objects that survived long enough are promoted from Eden/Survivor into old generation. Notice the heap size growing: it went from 24 MB at startup to 39 MB by this point — those extra megabytes are promoted survivors.
Phase 3 — Mixed collections. Now the old generation holds six promoted 4 MB arrays (24 MB alone). Adding 20 MB more of short-lived objects on top means:
GC(17) Pause Young (Prepare Mixed)starts a mixed collection preparation phase, scanning old-gen regions to decide which ones are worth collecting.GC(18) Pause Young (Mixed)actively reclaims memory from selected old-gen regions alongside young-gen objects. The heap drops from 37 MB back to 31 MB — meaning some old-gen garbage was reclaimed too.GC(20) Concurrent Mark Cyclekicks off a new concurrent mark pass for the next mixed collection round (G1 continuously cycles between mixed and young-only collections while old gen pressure persists).- The final
System.gc()at[0.550s]triggersGC(22) Pause Full, collecting everything down to 31 MB out of the max 48 MB heap.
Key takeaways from the log
- Minor collections happen frequently and cheaply. Young-gen pauses (like GC(0), GC(2), GC(4)) all complete in ~0.2 ms — fast enough that the application thread barely notices.
- Major/mixed collections are rarer but more expensive. The concurrent mark cycle at GC(16) took 5.8 ms and involved stop-the-world remark + cleanup pauses. Mixed collection preparation scans old-gen, which is why it costs more.
- Humongous allocations bypass normal young-gen flow. Because 4 MB exceeds G1’s region size (default 32 KB), humongous objects are allocated directly into dedicated regions and must be collected during mixed or full GCs — they never live in Eden.
- The heap grows during promotion. Objects that survive young-gen collection stay in memory longer. If survivors keep accumulating, the heap expands until a mixed/full collection reclaims enough space.
Takeaway
Java’s generational GC turns the observation “most objects die young” into a performance optimization: collect the young often and quickly, collect the old rarely and carefully. The <video controls src="https://datmt-blog-media.datmt.com/uploads/core-concepts-java-garbage-collection-including/out-16ccc170-e3c6-4979-aec2-dafd0b4fd4e5.mp4"></video> output above proves it — you can watch young-gen pauses cluster in Phase 1, then see the rarer mixed collection kick in once enough survivors accumulate.
The practical lesson for application developers is straightforward: if your application’s memory pressure stays dominated by short-lived allocations (which most do), G1GC handles that efficiently with minimal pause time. Problems arise when your objects outlive their intended lifespan — those “accidental” survivors fill old generation and force expensive mixed collections.