Photo by Adrien on Unsplash

Generational Garbage Collection: Why Your Runtime Tracks Object Age


Generational Garbage Collection: Why Your Runtime Tracks Object Age

Almost every managed runtime—JVM, .NET CLR, V8, Go—has to reclaim heap memory without requiring the programmer to do it manually. The naive approach, scanning the entire heap to find unreachable objects, works but scales poorly: a 10 GB heap is expensive to fully traverse on every collection cycle.

The key insight that makes modern GC practical is empirical rather than theoretical: most allocated objects die very quickly. Short-lived strings, per-request context objects, intermediate computation results—the vast majority never survive beyond the function that created them. A small fraction of allocations become genuinely long-lived: caches, connection pools, configuration objects.

This pattern, called the generational hypothesis, holds across wildly different workload types and languages. It is the reason virtually every production-grade runtime uses a generational design.

Splitting the Heap by Age

A generational collector partitions the heap into at least two regions: a young generation (also called the nursery or eden space) and an old generation (tenured heap).

New objects are allocated into the young generation. Collections that sweep only this region are called minor GCs. Because the young generation is small and most of its contents are already dead by the time collection runs, minor GCs are fast—often sub-millisecond. Objects that survive a configurable number of minor collection cycles get promoted to the old generation.

The old generation is collected far less frequently in what is called a major GC, or full GC. Major GCs are expensive because the tenured heap is large and packed with surviving objects. The whole point of the generational design is to make major GCs rare.

The Write Barrier Problem

Splitting the heap creates a correctness problem: an old-generation object might hold a reference to a young-generation object. If a minor GC only scans the nursery, it would incorrectly treat that young object as unreachable and collect it.

The fix is a write barrier—instrumented code that runs on every heap pointer write. When an old-generation object is updated to point to a new-generation object, the write barrier records that cross-generational reference in a data structure called a remembered set or card table. Minor GCs use the remembered set as additional roots, ensuring they don’t miss live objects referenced from tenured space.

Write barriers are cheap—a few instructions—but not free. High pointer-write workloads, such as those manipulating mutable graphs or trees, pay a measurable overhead. This is one reason immutable data structures carry GC-related performance advantages beyond thread safety: fewer pointer writes means fewer barrier executions and a smaller remembered set to scan.

Stop-the-World vs. Concurrent Collection

The simplest GC implementation stops all application threads while collecting—a stop-the-world pause. For minor GCs this is acceptable; pauses stay short because the nursery is small. For major GCs, stop-the-world becomes a serious problem at scale: a full collection on a large heap can pause an application for hundreds of milliseconds.

Modern collectors address this with concurrent and incremental techniques. The GC does most of its work alongside running application threads, scanning the heap in small increments rather than all at once. This introduces new correctness challenges—application threads can mutate the heap while the GC is scanning it—requiring additional barriers and careful coordination.

Go’s GC runs almost entirely concurrently with application goroutines, targeting short, predictable pauses. On the JVM, G1 divides the heap into equal-size regions and prioritizes collecting the most garbage-dense ones first—garbage-first, hence the name. ZGC takes this further, targeting sub-millisecond pauses by doing nearly all work concurrently and encoding object state directly in pointer bits rather than a separate bitmap.

What This Means in Practice

Allocating many short-lived objects is usually cheap: the runtime is designed for exactly this pattern, and the nursery handles it efficiently. What causes pressure is retaining objects longer than necessary. An object that survives a few minor GCs gets promoted to the old generation, where it occupies tenured space until a major collection eventually reclaims it.

Large heaps don’t help as much as they seem to. A 64 GB heap delays old-generation collection, but when that collection finally runs, the pause—or the concurrent overhead—is proportionally larger. Tuning heap size treats the symptom; reducing object retention treats the cause.

Workloads with extreme latency requirements—low-latency APIs, high-frequency data processing—often find GC tuning central to their p99 tail latency. Techniques like off-heap memory, object pooling, and value types (the direction Java’s ongoing Project Valhalla is heading) exist largely to keep objects out of the generational collector’s reach entirely. Understanding why the collector works the way it does makes it much clearer when those tradeoffs are worth taking on.