On This Page
The flag says 15. The log says 4.
-XX:MaxTenuringThreshold=15 # the default, on every collector I tried
[0.333s] GC(40) Desired survivor size 3342336 bytes, new threshold 4 (max threshold 15)
[0.333s] GC(40) Age table with threshold 4 (max threshold 15)
[0.333s] GC(40) - age 1: 1082640 bytes, 1082640 total
[0.333s] GC(40) - age 2: 1017088 bytes, 2099728 total
[0.333s] GC(40) - age 3: 1017088 bytes, 3116816 total
[0.333s] GC(40) - age 4: 1017088 bytes, 4133904 total
[0.333s] GC(40) Pause Young (Allocation Failure) 90M->40M(249M) 1.025ms
OpenJDK 21.0.12.1, Serial collector, 256 MB heap, 64 MB young generation, -Xlog:gc+age=trace,gc. A tiny program, the same one you'll run below, that keeps about 1 MB alive per young GC and drops it after six GCs.
Read the first line again. The maximum is 15. The JVM decided, on its own, that objects only get four young collections before they're moved to Old Gen. Nothing I'd call a bug, and I never typed a 4 anywhere. If you've ever tuned MaxTenuringThreshold and seen no effect, this is the line that explains why.
That's object promotion in Java working as designed on a default configuration, and the number that matters isn't the one you set. It took me a bit more than a day of lab runs, a dead end with the wrong workload, and a read through ageTable.cpp to get it straight. The write-up is so you can skip the day.
The mental model
Every object that survives a young collection gets its age bumped by one. An object that's copied out of Eden into a survivor space has age 1. Copied again at the next young GC, age 2. And so on.
At the end of each young GC the JVM looks at how many bytes sit at each age, adds them up from the youngest age upward, and finds the first age where the running total passes a budget: half of one survivor space, by default. That age becomes the tenuring threshold for the next young GC. Objects at or past it get promoted (copied into Old Gen) instead of going into the other survivor space again.
So the "15" is a ceiling. The threshold you actually run with is somewhere between 1 and 15 and it moves every GC. When survivors are crowded it drops, sometimes to 1, and objects that would have died in another collection or two get moved into Old Gen where only a much more expensive collection can reclaim them.
The rest of the article is proof, source and what to do about it.
Watch it happen
What you need. OpenJDK 21.0.12.1 (I used the Ubuntu 24.04 package; SDKMAN's Temurin 21 will behave the same), javac, a shell. No dependencies. I ran this on a 2-core, 8 GB sandbox, so your pause times will differ. The age tables won't.
java -version
# openjdk version "21.0.12.1" 2026-08-18
# OpenJDK 64-Bit Server VM (build 21.0.12.1+1-1-24.04.4-Ubuntu, mixed mode, sharing)
The code. Save as Aging2.java. It makes lifetimes countable in young GCs instead of wall-clock time, which is the thing I got wrong the first time (more on that below).
import java.lang.management.*;
import java.util.*;
public class Aging2 {
static Object sink;
static long youngGcs() {
long n = 0;
for (GarbageCollectorMXBean b : ManagementFactory.getGarbageCollectorMXBeans()) {
String s = b.getName(); // "Copy", "PS Scavenge", "G1 Young Generation"
if (s.contains("Young") || s.contains("Scavenge") || s.contains("Copy")) n += b.getCollectionCount();
}
return n;
}
public static void main(String[] a) {
int batchKb = Integer.parseInt(a[0]); // bytes kept alive per young GC
int lifeGcs = Integer.parseInt(a[1]); // keep each batch for this many young GCs, then drop it
int totalGcs = Integer.parseInt(a[2]);
ArrayDeque<Long> born = new ArrayDeque<>();
ArrayDeque<byte[][]> live = new ArrayDeque<>();
long last = youngGcs();
while (last < totalGcs) {
for (int i = 0; i < 64; i++) sink = new byte[64 * 1024]; // Eden churn, all garbage
long now = youngGcs();
if (now != last) {
last = now;
byte[][] b = new byte[batchKb / 16][];
for (int i = 0; i < b.length; i++) b[i] = new byte[16 * 1024]; // survives lifeGcs collections
live.addLast(b); born.addLast(now);
while (!born.isEmpty() && now - born.peekFirst() >= lifeGcs) { born.removeFirst(); live.removeFirst(); }
}
}
System.out.println("done, live batches=" + live.size());
}
}
Step 1: a live set that fits in the survivor budget.
javac Aging2.java
java -Xmx256m -Xms256m -Xmn64m -XX:+UseSerialGC \
-Xlog:gc+age=trace,gc:file=A.log:uptime Aging2 500 6 60
grep 'GC(40) ' A.log
Expected, roughly:
[0.397s] GC(40) Desired survivor size 3342336 bytes, new threshold 15 (max threshold 15)
[0.397s] GC(40) - age 1: 574120 bytes, 574120 total
[0.397s] GC(40) - age 2: 508568 bytes, 1082688 total
...
[0.397s] GC(40) - age 6: 508568 bytes, 3116960 total
[0.397s] GC(40) Pause Young (Allocation Failure) 55M->4M(249M) 0.363ms
Six ages, about half a megabyte each, 3.1 MB total, under the 3.3 MB budget. Threshold stays at 15. Every batch ages to six and dies. Nothing reaches Old Gen. Add -Xlog:gc+heap+exit and the tenured generation reports about 1.3 MB used at exit.
Step 2: double the live set.
java -Xmx256m -Xms256m -Xmn64m -XX:+UseSerialGC \
-Xlog:gc+age=trace,gc:file=B.log:uptime Aging2 1000 6 60
grep 'GC(40) ' B.log
That's the block from the top of the article. Same code, same flags, same lifetimes. Steady-state survivor content is now about 6 MB against a 3.3 MB budget, so the age table crosses it at age 4 and the threshold drops to 4. Objects that live six GCs now get promoted at age 4 and then die in Old Gen. Pause lines show the heap growing instead of settling:
GC(0) 52M->1M(249M)
GC(19) 70M->19M(249M)
GC(39) 89M->39M(249M)
GC(59) 108M->58M(249M)
About 1 MB of garbage added to Old Gen per young GC, and the tenured generation reported 55,932K used at exit. Only a full GC will take that back.
Forcing levers. -Xmn64m and the 6.4 MB survivor spaces exist so you can overflow them in under a second. Production young generations are gigabytes. The ratio of live-set-per-GC to survivor size is what matters, not the absolute numbers.
Confirmation signal. In step 2 you see new threshold below 15 in the gc,age debug line, and the -> after-sizes climb. If threshold stays at 15, your live set per GC is under the budget (reduce the survivor size with -XX:SurvivorRatio=12, or raise the first argument to 1500).
Near-miss. You'll only see the Age table lines at trace. At debug you get only the Desired survivor size ... new threshold line, which is enough to see the drop.
Environment differences. G1 and Parallel size survivor spaces themselves, so the numbers move. I show those below. If your JVM picks a different collector inside a small container, set -XX:+UseSerialGC explicitly.
Cleanup. rm A.log B.log Aging2.class. The logs are small.
The dead end
My first lab was a ring buffer: keep 8 MB of 64 KB chunks alive for 6 rounds of an allocation loop. Every collector, every run, new threshold 1. I decided the flag was being ignored and spent a while reading about AlwaysTenure. It wasn't ignored. A round isn't a GC, so I had no idea how many collections each object survived, and the live set per GC was about 5.6 MB against a 3.3 MB budget. The JVM was doing the right thing for a workload I hadn't understood. Count lifetimes in GCs, not in iterations.
Who decides when an object moves up
Three separate things get called "promotion", and they're different:
- Threshold promotion. The object's age reached the current tenuring threshold. The normal path.
- Overflow promotion. The object was younger than the threshold, but the survivor space it should have gone into was full. It goes to Old Gen anyway. No setting asks for this; it's the fallback.
- Direct allocation in Old Gen. The object never lived in the young generation. Humongous objects in G1 are the common case; I covered that in G1 humongous objects.
-XX:PretenureSizeThresholddefaults to 0 (off) on JDK 21; I didn't test it.
Both of the first two happen during a young GC, which is a copying collection. The collector walks live objects from the roots, copies each one to a new home, and fixes the reference. The age, a counter in the object's header, decides where that new home is. (The header layout is its own article: the Java object header.) The age lives in a small field of the header, which is why there's a hard ceiling; the source writes the NeverTenure value as markWord::max_age + 1, and the log shows 16.
Why a dynamic threshold at all? Because a fixed one is wrong in both directions. If the survivor space is nearly empty, keeping objects young a little longer costs nothing and gives short-lived stragglers a chance to die cheaply. If it's about to overflow, holding on to anything is pointless, because the overflow rule will promote it anyway, probably the wrong objects. Lowering the threshold chooses which objects go (the oldest ones) rather than leaving it to whoever arrived when the space ran out.
I'll admit I find the half-a-survivor budget a bit arbitrary. The design reason is that survivor spaces are copied wholesale at every young GC, so a survivor space that's more than about half full leaves no room for the next batch of survivors coming out of Eden. That's my reading of why 50 is the default, not something I found written in a comment.
Eden ──young GC──▶ Survivor A (age 1) ──▶ Survivor B (age 2) ──▶ ...
│ │
└── age ≥ threshold ─────┴──────▶ Old Gen
└── survivor space full ─────────▶ Old Gen (overflow)
What the source says, and what JDK 21 prints
The threshold logic lives in AgeTable::compute_tenuring_threshold in src/hotspot/share/gc/shared/ageTable.cpp. I read it at tag jdk-21+35. In plain words:
- If
AlwaysTenureorNeverTenureis set, the answer isMaxTenuringThreshold(0 or 16) and the table is ignored. - Otherwise, start at age 1 and keep adding that age's bytes to a running total. The first age where the total is greater than the desired survivor size is the answer. Cap it at
MaxTenuringThreshold. - The desired size is passed in by the caller. For Serial it's the to-space capacity times
TargetSurvivorRatiodivided by 100, computed inDefNewGeneration::adjust_desired_tenuring_threshold().
Two details caught me out. First, the threshold computed at the end of GC n is the one GC n+1 uses; the line you read in the log describes the future. Second, the loop stops at the first age that crosses the budget and that age itself becomes the threshold, so with ages 1 to 4 totalling 4.1 MB against 3.3 MB, objects of age 4 and up are the ones promoted next time. That matches the lab: 3.12 MB at age 3, 4.13 MB at age 4, threshold 4.
Overflow is in DefNewGeneration::copy_to_survivor_space(). An object goes to to-space only if its age is below the threshold and the allocation in to-space succeeds. Otherwise it's promoted. If Old Gen can't take it either, you get a promotion failure and a much worse day, which is a different story.
And NeverTenure? It doesn't mean nothing ever reaches Old Gen. The source shows it sets the threshold to 16, which is above any possible age, so only overflow promotion remains. In my lab it kept Old Gen clean at 1,073K because the survivors fit. In a workload with real long-lived data, it pushes everything through overflow instead.
JDK 8 -XX:+PrintTenuringDistribution prints the age table; Parallel is default; CMS around
JDK 9 G1 default (JEP 248); unified logging replaces the Print* flags (JEP 158, 271)
JDK 14 CMS removed (JEP 363)
JDK 21 gc+age is the only way in; -XX:+PrintTenuringDistribution is rejected at startup
On 21.0.12.1 that last flag ends the JVM with Unrecognized VM option 'PrintTenuringDistribution'. Half the tuning advice on page one tells you to add it. Use -Xlog:gc+age=trace (log syntax here). I only ran 21 for this article; I haven't run 17 or 25, so the per-version rows above beyond 21 come from the JEPs, not the lab.
Object promotion in Java goes wrong in one main way
Survivor overflow. A live set per young GC bigger than half a survivor space, and the threshold falls to whatever age fits. The objects promoted are then, by definition, ones that haven't had time to prove they're short-lived. That's premature promotion, and it's the cause of the Old Gen that fills with request-scoped garbage and then needs full collections. (The diagnosis side, with the fix ordering for a real service, is going to live in premature promotion and GC overhead. This article is the mechanism.)
Here's how the three collectors I ran behaved with the same workload (1 MB kept per GC, six GCs of life), 256 MB heap:
Serial -Xmn64m desired survivor 3.3 MB threshold 4 tenured ~56 MB at exit
Parallel -Xmn64m desired survivor 2.1 MB threshold 1 ParOldGen ~58 MB at exit
Parallel -Xmn64m -UseAdaptiveSizePolicy ParOldGen ~1.3 MB
G1 (no -Xmn) desired survivor 10 MB threshold 15 heap settles ~10 MB
G1 -Xmn64m desired survivor 4 MB threshold 5 heap 98M->40M at GC(40)
A few things in that table I didn't expect.
G1 is only fine because it sized the young generation itself. Without -Xmn, G1 gave itself a 10 MB survivor budget and never moved the threshold. Pin the young size with -Xmn or -XX:NewSize and G1 is back to the Serial behaviour. People copy -Xmn from Parallel-era configs onto G1 all the time. I'd leave it off, because G1's pause-target logic wants to move young size around.
Parallel's adaptive sizing made it worse. With default flags it started with small survivor spaces and set the threshold to 1 on my workload; switching -XX:-UseAdaptiveSizePolicy (default true, product, Parallel) gave stable results. I haven't read psAdaptiveSizePolicy.cpp, so I can't tell you which decision did this, and I ran it once, so treat it as a lead rather than a finding.
Flags people reach for, and what they do on JDK 21:
-XX:MaxTenuringThreshold=15. Already the default. Setting it to 15 changes nothing. Setting it lower makes promotion sooner and rarely helps.-XX:+AlwaysTenure. Threshold 0: everything surviving one GC goes straight to Old Gen. In the lab, 63,749K of tenured garbage. It exists for experiments.-XX:+NeverTenure. Covered above. Not a fix for premature promotion, because overflow still promotes.-XX:MaxTenuringThreshold=0. Same asAlwaysTenurein effect.
Tuning it without guessing
Order matters. The cheapest fix is almost always before any flag.
- Allocate less that lives a medium time. Per-request caches, big parsed documents held across calls, a builder kept in a field. If the live set per GC drops under the budget, the problem is gone with no flags.
- Give survivors more room.
-XX:SurvivorRatio=3on Serial (default 8; product) took the budget to 6.7 MB, threshold back to 15, and tenured to 1,301K in my lab. Cost: Eden shrinks, so young GCs happen more often. Fewer surviving bytes per GC trade for more GCs. - Move the budget.
-XX:TargetSurvivorRatio=90(default 50, product) gave a 6.0 MB budget and threshold 6, tenured 1,303K. Cost: if survivors really do fill up to 90%, you're one spike away from overflow, and overflow promotion is the thing we're avoiding. - Grow the young generation if sizing allows: more Eden means fewer GCs per second and fewer ages stacked in survivors. Cost: bigger heap, longer individual young pauses.
Don't touch MaxTenuringThreshold unless the log shows the threshold at the maximum and objects are still dying in Old Gen. That's a different problem: lifetimes longer than 15 GCs, which no survivor tuning fixes.
A decision I'd make on JDK 21+ with G1: leave -Xmn off, set -XX:MaxGCPauseMillis honestly, and look at the gc+age debug line for a week before touching anything. See G1 explained for how the young size gets chosen.
Catching it before prod does
Leave -Xlog:gc+age=debug on in production. One line per young GC. trace prints the full table and costs more; turn it on for an hour when you're investigating, not permanently.
What to look at:
new thresholdbelow the max, sustained, with the heap after each GC creeping up. That's overflow.- Old Gen occupancy after young GCs climbing, then a sudden drop after a full or mixed collection. Garbage in Old Gen.
- The ratio of promoted bytes to allocated bytes. If more than a few percent of what you allocate ends up in Old Gen, look at lifetimes. (Micrometer's
jvm.gc.memory.promotedandjvm.gc.memory.allocatedgive you that; I haven't verified the names on the current Micrometer release.)
Across upgrades. The mechanism itself hasn't changed in the JDKs I've read about: age table, half-survivor budget, ceiling. What changes is the flags around it and the logging. The old Print* flags are gone, so any startup script that has them will fail on 21. G1's young sizing is adaptive, so the same live set may behave differently if you move from Parallel to G1. When moving LTS to LTS: re-run your service for an hour with gc+age=debug before and after, and compare threshold and Old Gen growth.
Takeaways
MaxTenuringThresholdis a ceiling. The real threshold is recomputed every young GC from the age table and half a survivor space.- If your live set per young GC is bigger than the survivor budget, objects get promoted early and die in Old Gen. That's the mechanism behind most "why is Old Gen full of garbage" tickets.
- Read
-Xlog:gc+age=debug.-XX:+PrintTenuringDistributionno longer starts on JDK 21. -Xmnon G1 takes away the thing that was keeping you safe.- Fix allocation first, survivor size second,
TargetSurvivorRatiothird. Almost never the threshold flag.
Quick answers
What is the default MaxTenuringThreshold in Java? 15 on JDK 21 for Serial, Parallel and G1 (I checked with -XX:+PrintFlagsFinal). That's a maximum, not the value in use.
How does an object get promoted to Old Gen? Either its age reaches the current tenuring threshold, or the survivor space is full when it's copied. A third path, allocating directly in Old Gen, exists for oversized objects.
What is premature promotion? Short-lived objects reaching Old Gen because the threshold dropped or survivors overflowed. It's detailed in the premature promotion article.
Is a minor GC the same as a young GC? Yes, in practice. Details per collector are in minor vs major vs full GC.