How does the JVM's generational garbage collector decide when to promote an object from the Young generation to the Old generation, and what happens when a service starts promoting too aggressively?
Quick Answer
The JVM tracks how many Minor GC cycles each object has survived in the Young generation using an age counter in the object's header, and once that age crosses a threshold (or Survivor space fills up), the object gets promoted (copied) into the Old generation. When short-lived objects get promoted too early — a problem called premature promotion, often caused by an undersized Survivor space or a burst of allocations larger than it can hold — the Old generation fills up faster than expected, triggering more frequent, much more expensive Major/Full GCs that pause the application longer.
Detailed Answer
Picture a busy office building that sorts new hires into two kinds of desks: a temp desk near the door for people who might only be there a few days, and a permanent office deeper inside for people who prove they're sticking around long-term. Most temp workers leave within days and their desk gets cleared out constantly with almost no effort — a quick check at the door. Only after someone has stuck around through several rounds of that quick check does the company bother giving them a permanent office, because moving someone into a permanent office is expensive and disruptive, and you don't want to do it for people who are about to leave anyway. The JVM heap works on exactly this principle: it's split into a Young generation (the temp desks, further divided into Eden and two Survivor spaces) and an Old generation (the permanent offices), because most objects in real applications die young — a decades-old observation called the 'generational hypothesis' that this entire design is built around.
The JVM was designed this way because scanning the entire heap on every garbage collection would be prohibitively slow as heaps grow into gigabytes. By betting that most objects die quickly, the collector can do frequent, cheap Minor GCs that only scan the small Young generation (most objects there are already garbage and get reclaimed almost for free), and only occasionally do the much more expensive full-heap scan. Splitting Young generation into Eden and two Survivor spaces (commonly called S0 and S1) lets the collector use a 'copying' algorithm: live objects get copied out of Eden and one Survivor space into the other Survivor space, which is cheap because it only touches objects that survived, not the (usually much larger) set that died.
Step by step: new objects are allocated in Eden. When Eden fills up, a Minor GC runs — every object still referenced gets copied into an empty Survivor space, and each copied object's age counter (stored in its object header) increments by one. Objects that die (nothing references them) are simply left behind and the space is reused, no explicit deletion needed. This repeats on every Minor GC, with objects ping-ponging between the two Survivor spaces (S0 and S1 swap roles) each time they survive. Once an object's age counter crosses the JVM's tenuring threshold (a configurable value, -XX:MaxTenuringThreshold, defaulting around 15 for most collectors) it gets promoted — copied out of Young generation entirely into the Old generation, on the theory that anything that's survived this many collection cycles is probably going to live a long time and doesn't need to keep being copied back and forth.
At production scale, the thing to actually watch is the ratio of Minor GCs (cheap, sub-millisecond to low-tens-of-milliseconds pauses) to Major/Full GCs (expensive, potentially hundreds of milliseconds to seconds, because they scan the entire Old generation and sometimes the entire heap). A healthy service does frequent, fast Minor GCs and rare Full GCs. What goes wrong: if Survivor spaces are too small for a workload's actual allocation rate — say, a request-processing burst that allocates far more temporary objects than the Survivor space can hold — objects that would normally die within a few Minor GCs get force-promoted directly into Old generation simply because there's nowhere else to put them during a Minor GC, a pathology called premature promotion. This fills up Old generation with what should have been short-lived garbage, which triggers Full GCs far more often than the workload should actually need, and each Full GC pause directly shows up as p99 latency spikes on live user traffic.
The gotcha: engineers who only tune -Xmx (max total heap) without also considering the ratio between Young and Old generation sizing (-XX:NewRatio or explicit -Xmn) often make premature promotion worse, not better, when they increase heap size — a bigger heap with the same proportionally-small Survivor space just delays when the problem shows up, it doesn't fix the underlying object-survival pattern, and the service continues to pause unpredictably under bursty load even after a 'more memory' fix that looked successful in initial testing.