尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
JVM新生代垃圾回收全链路解析:从Eden/Survivor到晋升调优
线上服务每隔几分钟就抛一次 Young GC日志里 Survivor 区像坐过山车一样忽上忽下老年代占用却一路缓慢爬升——这大概是新生代垃圾回收最典型的现场。很多人背得出Eden 和两块 Survivor 是 8:1:1对象熬过 15 次 Minor GC 就晋升老年代但真到调优的时候往往是参数一改、效果反向越调越乱。这篇想聊的不是把教科书上的分代模型再复述一遍而是把新生代回收这条链路从内存布局、对象流转、触发条件、日志证据到参数取舍完整走一遍顺便把几个踩过的坑摊开讲。适合已经能看懂 GC 日志、但还没搞清楚为什么调了这个参数反而更糟的后端同学也适合刚接触 JVM 内存模型、想一次性把新生代脉络理顺的读者。看完至少能做到两件事看到一段 GC 日志知道该往哪儿看面对一组分代参数知道它为什么这么配。1. 新生代被单独切出来的真正原因1.1 弱分代假说不是纸面上的漂亮话分代收集的理论根基是弱分代假说绝大多数对象都是朝生夕灭的。这句话听着像常识但它背后有一个非常硬的经验数据支撑——在典型的业务系统里一次方法调用里创建的临时对象、一次请求处理里拼装的 DTO、一次 JSON 反序列化产生的中间集合生命周期往往只有几十微秒到几毫秒。真正能活到老年代的对象占比通常在个位数百分比。这个比例意味着什么意味着如果整个堆用同一套算法回收你会为了那不到 10% 的长寿对象每次都把 100% 的堆扫描一遍。而分代之后回收器可以把绝大部分精力投在新生代那小块区域上用最低的成本回收掉最大比例的死对象。这就是分代的核心收益不是回收得更干净而是回收得更划算。我在一个日均千万级请求的订单服务上做过对比同一份代码堆总量固定 4G把新生代从 512M 调到 1.5G 之后Young GC 单次停顿从 30ms 左右涨到 60ms 上下但频率从每分钟 40 多次降到 10 次以内整体吞吐反而上去了。这个取舍后面第 5 章会细讲先记住一个结论新生代的大小不是越大越好也不是越小越好它是在单次停顿和回收频率之间找平衡点。1.2 从对象活多久反推新生代该占多大HotSpot 里新生代默认占堆的 1/3由-XX:NewRatio控制默认值 2也就是 新生代:老年代 1:2。这个默认值不是随手拍的它对应的是大部分对象很快死掉、只有少部分沉淀到老年代的通用业务形态。但通用不等于适配。你可以用一个很简单的方法反推自己的系统该给多少先把 GC 日志里的晋升速率算出来。看一次 Young GC 之后老年代增长了多少 MB乘以每分钟的 GC 次数就得到晋升速率。如果晋升速率很低比如每分钟几 MB说明绝大多数对象在新生代就死透了新生代可以适当放大减少 GC 次数如果晋升速率很高说明大量对象在新生代没撑住就进了老年代这时候盲目放大新生代只会让每次复制存活对象的时间变长老年代该满还是满。还有一个容易被忽略的指标是存活对象总量。Minor GC 的本质是把 Eden 加一块 Survivor 里的存活对象复制到另一块 Survivor 里。如果存活对象总量超过了目标 Survivor 的容量就会触发空间分配担保直接把这些对象送进老年代——这就是很多莫名其妙提前晋升的根源。所以新生代的合理尺寸至少要保证一次 GC 后存活对象总量能舒服地塞进一块 Survivor。2. Eden 与两块 Survivor 的空间账本2.1 8:1:1 这个比例是怎么算出来的新生代的默认结构是一块 Eden 加两块大小相等的 Survivor由-XX:SurvivorRatio8控制含义是 Eden : 单个 Survivor 8 : 1。所以按 10 份来分Eden 占 8 份From 和 To 各占 1 份新生代总容量 10 份但同一时刻只有 9 份可用Eden 8 份 当前在用的那块 Survivor 1 份另一块 Survivor 永远是空的等着接收复制过来的存活对象。为什么不是 1:1:1因为复制算法要求有一块预留空间做腾挪但如果预留太多空间利用率就崩了。8:1:1 是在预留足够应对大多数场景的存活对象和空间浪费别太夸张之间取的折中只要一次 Minor GC 后存活对象不超过新生代容量的 10%就永远不需要动用老年代做担保。这里有个非常实用的自检方法翻 GC 日志看每次 Young GC 前后的 Eden 和 Survivor 使用量。观察到的现象说明处置方向GC 后 Survivor 使用量接近 100%存活对象逼近 Survivor 上限随时可能溢出调大新生代或调大 SurvivorRatio 的 Survivor 份额GC 后 Survivor 长期只用 20% 以下预留过多空间浪费可适当提高 SurvivorRatio让 Eden 更大GC 后老年代明显跳涨存活对象溢出或大对象直接晋升查晋升阈值、查大对象、查担保失败2.2 一次 Minor GC 里对象的三条去向很多人对 Minor GC 的理解停留在把 Eden 清空实际上一次 Minor GC 结束后对象会走三条完全不同的路。第一条就地消亡。Eden 里绝大部分对象是不可达的直接被回收这块空间整体归还。这也是分代回收最省事的部分——不需要逐个清理只需要把 Eden 的分配指针一拨整块区域宣告可用。第二条复制到 To Survivor年龄加一。仍然可达的对象连同当前 From Survivor 里的存活对象一起被复制到 To Survivor同时把对象的年龄计数器加一对象头里的 mark word 有 4 个 bit 用来存 GC 年龄最大值 15。复制完成后Eden 和原来的 From Survivor 整体清空两块 Survivor 的角色互换——原来的 To 变成下一次的 From原来的 From 变成下次的 To。第三条直接进老年代。触发条件有四种必须分清年龄达到晋升阈值-XX:MaxTenuringThresholdParallel 系默认 15CMS 下默认 6动态年龄判定命中Survivor 中所有年龄相同的对象大小总和超过 Survivor 空间的一半由-XX:TargetSurvivorRatio控制默认 50那么年龄大于等于这个年龄的对象直接晋升不再等阈值大对象超过-XX:PretenureSizeThreshold直接在老年代分配注意这个参数只对 Serial 和 ParNew 生效Parallel Scavenge 不认To Survivor 装不下触发空间分配担保剩下的对象全部进老年代。第三条里的动态年龄判定是最容易被忽视的一条。它的设计意图是如果某一代的对象已经在 Survivor 里占了一半以上说明它们大概率还会继续活下去与其反复复制不如早点送走。但副作用是——晋升阈值会被动态压低你以为设了 15实际可能 3 次就晋升了。这一点在第 5 章会展开。3. 对象从出生到晋升的完整链路3.1 分配阶段TLAB 与逃逸分析改变了起点对象并不是直接落在 Eden 的公共区域上的。HotSpot 默认开启TLABThread Local Allocation Buffer-XX:UseTLAB每个线程在 Eden 里预先划一小块私有区域线程内分配对象只动自己的指针不需要任何同步。这个设计对多核场景下的分配性能影响极大——没有 TLAB 的话所有线程抢同一个 Eden 分配指针高并发下光 CAS 就能把 CPU 打满。TLAB 的默认大小是 Eden 的 1%可用-XX:TLABSize显式指定浪费容忍度由-XX:TLABWasteTargetPercent控制默认 1%。TLAB 装不下的对象会退化到 Eden 公共区域分配这个过程叫慢分配。如果你在 GC 日志里看到大量对象绕过 TLAB通常说明 TLAB 偏小或者对象偏大。更极端的情况是对象压根没进堆。开启逃逸分析-XX:DoEscapeAnalysis默认开后如果一个对象的作用域没有逃出方法JIT 可能直接把它标量替换成几个局部变量放在栈上连 Eden 都不占用。这也解释了一个常见困惑为什么压测时 Eden 的分配速率比理论值低——有一部分对象被优化掉了。3.2 存活判定从 GC Roots 出发的可达性分析判定对象死活用的是可达性分析而不是引用计数引用计数解决不了循环引用。从一组 GC Roots 出发向下遍历走不到的对象判定为可回收。新生代的 GC Roots 主要包括虚拟机栈中局部变量表引用的对象、方法区中静态属性引用的对象、常量引用的对象、本地方法栈中 JNI 引用的对象以及老年代指向新生代的对象。最后这一项是关键难点。如果每次 Minor GC 都要全量扫描老年代来找跨代引用分代回收就失去意义了。HotSpot 的解法是卡表Card Table把老年代切成一个个 512 字节的卡页用一张字节数组记录哪些卡页可能含有指向新生代的引用。当老年代对象引用新生代对象时写屏障会把对应的卡页标记为脏。Minor GC 时只需要扫描脏卡页不用扫整个老年代。这套机制有个副作用值得知道卡表脏标记是可能脏而不是精确脏所以会出现一些本来已经失效的跨代引用被当作 GC Roots 扫描造成少量对象假存活多活一轮。这不是 bug是设计上的取舍。3.3 复制与年龄自增三道闸门决定去留把上面的链路串起来一个对象的完整命运大概是这样在 TLAB 或 Eden 公共区分配年龄为 0第一次 Minor GC 后仍可达被复制到 To Survivor年龄变 1每次 Minor GC 都存活年龄递增撞上三道闸门之一就晋升年龄到阈值、动态年龄判定命中、Survivor 装不下进入老年代后等待 Full GC 或 Mixed GC 处理。这里有个细节复制阶段是多线程并行的Parallel 系收集器每个线程领取一部分 GC Roots 扫描任务通过工作窃取队列平衡负载。所以 Minor GC 的停顿时间大致正比于存活对象数量而不是 Eden 的大小。这也是为什么Eden 调大、停顿变长但次数变少——单次要复制的存活对象变多了。4. 触发条件、日志证据与参数验证4.1 Minor GC 到底什么时候被触发最直接的触发条件是Eden 空间不足。新对象分配时发现 Eden 剩余空间不够就触发一次 Minor GC。这里面有几个容易误解的地方。第一Minor GC 不一定会回收老年代。但如果开启空间分配担保且老年代剩余连续空间小于历次晋升到老年代对象的平均大小就会先做一次 Full GC 再决定是否继续 Minor GC。这条逻辑在老版本 JDK 里依赖-XX:HandlePromotionFailure参数JDK 6u24 之后这个参数已经不再影响担保行为默认就允许担保。所以看到老版本文章说要加 -XX:HandlePromotionFailure的先确认你的 JDK 版本别照抄。第二System.gc() 默认触发的是 Full GC不是 Minor GC。线上代码里出现显式调用 System.gc() 的地方一定要排查掉或者在启动参数里加-XX:ExplicitGCInvokesConcurrent让它走并发回收。第三元空间不足也会触发 Full GC很多人以为是新生代的问题其实是类加载器泄漏导致元空间撑爆这时候调新生代参数毫无意义。4.2 用日志把感觉变成证据JDK 8 和 JDK 9 的日志参数完全不同这里各给一套能直接用的。JDK 8-XX:PrintGCDetails \ -XX:PrintGCDateStamps \ -XX:PrintTenuringDistribution \ -Xloggc:/data/logs/gc-%t.log \ -XX:UseGCLogFileRotation \ -XX:NumberOfGCLogFiles5 \ -XX:GCLogFileSize50MJDK 9 及以上-Xlog:gc*,gcagetrace,gcheapdebug:file/data/logs/gc-%t.log:time,uptime,level,tags:filecount5,filesize50MPrintTenuringDistribution或gcagetrace这个开关是排查新生代问题的核心它会打出类似这样的内容Desired survivor size 10485760 bytes, new threshold 7 (max 15) - age 1: 2097152 bytes, 2097152 total - age 2: 1048576 bytes, 3145728 total - age 7: 6291456 bytes, 9437184 total这段输出告诉你三件事当前期望的 Survivor 大小、实际生效的晋升阈值这里是 7不是你设的 15、每个年龄段的存活字节数。看到new threshold小于max就说明动态年龄判定生效了阈值被压低了。再配合jstat做实时观察jstat -gcutil pid 1000 20输出列里重点看S0、S1两块 Survivor 的使用率、EEden 使用率、YGC年轻代 GC 次数、YGCT年轻代 GC 总耗时、FGC。如果S0和S1长期一个接近 100%、一个为 0而E增长很快说明存活对象几乎塞满了 Survivor随时会溢出。如果FGC在缓慢增长但YGC很平稳问题多半不在新生代。参数作用默认值什么时候需要动-Xmn直接指定新生代大小堆的 1/3想精确控制新生代、避免 NewRatio 换算-XX:SurvivorRatioEden 与单个 Survivor 的比例8存活对象偏多、Survivor 经常溢出-XX:MaxTenuringThreshold晋升年龄上限15CMS 为 6想延长对象在新生代的存活轮次-XX:TargetSurvivorRatio动态年龄判定的触发线50想抑制过早晋升-XX:PretenureSizeThreshold大对象直接晋升阈值0不启用有大对象反复复制仅 Serial/ParNew 生效-XX:-UseAdaptiveSizePolicy关闭自适应尺寸调整开启手动设的比例总是不生效5. 新生代调优的五类典型症状与处置5.1 症状一Survivor 溢出导致的提前晋升现象GC 日志里 Survivor 使用率几乎每次都顶到 100%老年代在 Young GC 后有明显跳涨new threshold长期停留在 2 到 3。根因通常有两个一是存活对象总量确实超过了新生代的 10%二是 Survivor 被自适应策略悄悄改小了。第二个原因极其隐蔽——-XX:UseAdaptiveSizePolicy在 Parallel Scavenge 下默认开启JVM 会根据停顿时间和吞吐目标自己调整 Eden 和 Survivor 的比例你设的-XX:SurvivorRatio8可能早就被覆盖了。处置顺序先用-XX:-UseAdaptiveSizePolicy关掉自适应再用-Xmn固定新生代大小最后根据PrintTenuringDistribution里的实际存活字节数把 Survivor 调到能装下 1.5 倍左右存活对象。这套顺序不能反否则你会在一堆互相冲突的参数里绕圈。5.2 症状二大对象在新生代反复横跳如果一批对象的大小刚好卡在比 Survivor 装得下但很占地方的区间它们会在两块 Survivor 之间来回复制很多轮每次都要付出内存拷贝的代价。这类对象的典型代表是几 MB 的缓存条目、大 JSON 字符串、Excel 导出时的行对象列表。处理办法有两条路。一是从代码侧解决比如把大对象拆小或者用对象池复用二是用-XX:PretenureSizeThreshold让它们直接进老年代。但第二条路有个大坑这个参数对 Parallel Scavenge 无效如果你的系统用的是默认的 Parallel 收集器设了也不会生效。想让它生效得切换到 Serial 或 ParNew。所以实际操作中我更倾向于先改代码参数只是兜底手段。5.3 症状三动态年龄判定把阈值压到了不该有的低位这是我认为最反直觉的一个坑。假设你设了-XX:MaxTenuringThreshold15期望对象在新生代多待几轮结果日志里new threshold 2。原因就是动态年龄判定年龄 1 和年龄 2 的对象加起来超过了 Survivor 的一半JVM 认为这批对象大概率还会继续活直接把阈值降到 2之后所有年龄 ≥2 的对象全部晋升。想抑制它可以从两个方向动调大 Survivor让 50% 的线更靠后或者把-XX:TargetSurvivorRatio从 50 调到 80 甚至 90。后者要谨慎因为 Survivor 填得越满溢出触发担保的风险越高。我一般的做法是先调 Survivor 大小实在不行再动 TargetSurvivorRatio而且每次只动一个参数用日志验证效果。提示验证动态年龄判定是否生效只看PrintTenuringDistribution里的new threshold就够了。如果它始终等于你设的上限说明动态判定没触发如果明显偏低说明 Survivor 相对存活对象太小了。5.4 症状四跨代引用引发的隐性问题老年代对象持有新生代对象引用是很多框架里的常态比如长期存活的服务单例缓存了某次请求的上下文对象。这类引用会让新生代对象被误判为存活本该回收的对象多活好几轮进而推高晋升速率。排查这类问题不能只看 GC 日志得用堆快照。用jmap -dump:live,formatb,fileheap.hprof pid导出再用堆分析工具看新生代对象的引用链重点关注是不是被老年代的静态容器持有。我之前排查过一个案例一个静态的本地缓存没有设置过期时间把所有请求对象都挂住了表现就是新生代对象存活率高得离谱Survivor 怎么调都不够用。改成有界 Caffeine 缓存之后Young GC 后 Survivor 使用率从 95% 掉到 30% 以内。5.5 症状五新生代放大之后停顿反而变长这个现象的成因很明确Minor GC 的停顿时间正比于存活对象数量而存活对象数量往往随着 Eden 变大而增加因为两次 GC 之间积累了更多对象其中有更多半老不老的对象。所以把新生代从 1G 放到 2GGC 频率减半但单次停顿可能翻倍。判断该不该放大的依据是停顿预算。如果你的 SLA 要求 P99 在 100ms 以内单次 Young GC 停顿就必须控制在 30ms 以内这时候能不能放大新生代取决于实测的停顿数据而不是GC 次数少了就是好事。我通常的做法是做两组对照压测一组保持原参数一组只改新生代大小记录 YGC 次数、平均停顿、P99 停顿、老年代增长速度四项选综合最优的那组。别凭感觉凭数据。// 一个用来验证新生代晋升行为的最小复现程序 // 启动参数建议-Xmn128m -Xmx512m -XX:PrintGCDetails -XX:PrintTenuringDistribution -XX:SurvivorRatio8 public class TenuringProbe { private static final int _1MB 1024 * 1024; public static void main(String[] args) { byte[] holder null; for (int i 0; i 20; i) { // 每次分配 3MB制造稳定的小对象流量 byte[] tmp new byte[3 * _1MB]; if (i % 4 0) { holder tmp; // 每 4 轮留一个引用制造半老不老的存活对象 } } System.out.println(done, holder (holder null ? null : holder.length)); } }跑这段代码把日志里每次 GC 的new threshold和 Survivor 使用量列出来基本就能直观感受到动态年龄判定是怎么工作的。把-Xmn从 128m 改到 256m 再跑一遍对比两组日志比看十篇文章都管用。6. 我在实际项目里踩出来的几条经验第一条是关于参数顺序的。先固定、再观察、后调整这个顺序不能乱。很多人一上来就同时改-Xmn、SurvivorRatio、MaxTenuringThreshold三个参数然后发现效果不对根本不知道是哪个参数的锅。正确做法是先关掉自适应策略用固定尺寸跑一轮基线拿到 GC 日志基线数据之后每次只改一个参数对照观察。第二条是关于调参能解决什么的边界认知。新生代参数只能调整对象在堆里的流转节奏它解决不了对象本来就该早点死但没死的问题。如果晋升速率异常高八成是代码里有引用没释放、缓存没设界、ThreadLocal 没清理这些都不是 JVM 参数能救的。排查顺序永远是先看代码有没有明显泄漏再看 GC 日志确认现象最后才动参数。第三条是关于可观测性的。我习惯在服务里暴露几个关键指标jvm.gc.young.count、jvm.gc.young.time、jvm.gc.promotion.bytes每次 Young GC 后老年代的增量然后对晋升速率设一个告警阈值。这样不用等老年代满了才发现问题晋升速率异常升高的那几天就能提前介入。这套东西搭起来不到半天但能省掉好几次深夜排查。最后分享一个小技巧。想快速验证某个新生代参数组合的效果不必搭完整压测环境用一段分配密集的小程序加上-XX:PrintTenuringDistribution跑几十秒就够了重点看new threshold和 Survivor 使用量两个数。这两个数稳了线上的大致行为也就稳了。真到了线上再按实际日志微调比纸上推演靠谱得多。
RELATED

相关推荐

Standalone集群实战:PySpark作业提交与spark-submit调度

Standalone集群实战:PySpark作业提交与spark-submit调度

这篇日志写在2022年2月初,离我把Python和数据分析基础过完大概一个月。当时想往大数据方向走,身边所有人的建议几乎一致:先学PySpark。于是春节假期后的第一件事,就是把Spark环境和Standalone集群跑起来。真正动手之后才发现&…

📅 2026/10/23 13:14:37
知识图谱+大模型:企业级智能问答系统原理与落地实战

知识图谱+大模型:企业级智能问答系统原理与落地实战

讲实话,最早接到这个"知识图谱大模型智能问答系统"的需求时,我是有点抵触的。当时我刚被纯大模型问答方案的幻觉问题折磨过一轮:模型煞有介事地给出一个看起来毫无破绽、实际完全编造的答案,用户再追问一句"你的数…

📅 2026/10/23 13:14:37
51单片机驱动WS2812灯带:时序控制与代码实现

51单片机驱动WS2812灯带:时序控制与代码实现

简介:这份资源面向使用51单片机开发智能灯效的电子爱好者与嵌入式初学者,围绕WS2812单线级联LED的驱动展开,解决时序精度要求高、协议不易上手的问题。压缩包共17个文件,约348KB,包含C语言源码、头文件、Keil工程与编译…

📅 2026/10/23 13:14:37
MORE NEWS

更多资讯

📰

Electron跨平台打包实战:electron-builder配置、体积优化与常见坑

1. 打包方案怎么选:为什么最后锁定了electron-builder先交代一下背景。我手里的这个项目是个跨平台桌面应用,技术栈是Electron Vue,开发环境是Windows,目标平台需要覆盖Windows和macOS。开发阶段一切正常,npm run dev…

📰

双端相册通讯录获取源码解析:权限申请、数据导出与增量同步

简介:一套2023年双端获取相册通讯录系统源码,面向需要实现手机相册与通讯录读取管理功能的应用开发者,提供可直接部署的PHP后端、后台管理端和移动端示例。资源共2000个文件,核心为1589个PHP业务文件,另有510个PNG界面…

📰

告别手动翻代码:用 AI 分析 APK 的新姿势——MCP 接入 TaoToken 实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

多星发射技术:商业航天的成本革命与核心突破

1. 多星发射技术的战略价值当一枚火箭同时将数十颗卫星送入预定轨道时,这不仅仅是简单的数量叠加。现代航天领域最显著的技术跃迁之一,就是多星发射能力从国家战略层面向商业市场的延伸。传统单星发射模式下,每千克载荷的运输成本高达数万美元…

📰

2026年期货量化工具评测与选型指南

1. 期货量化工具评测背景与价值2026年的期货市场已经进入算法交易占比超过80%的时代,量化工具的选择直接决定了交易团队的竞争力。这次评测我们耗时6个月,对市面主流的27款量化平台进行了从回测精度到实盘稳定性的全流程检验。有个有趣的发现&#xff1a…

📰

【2026年SCI一区新算法汤普森-ADWIN算子选择、动态收敛率缩放和线性种群规模缩减的自适应冠豪猪优化器ACPO公式原理详解与性能测评附Matlab代码

✅作者简介:热爱科研的Matlab仿真开发者,擅长数学建模、数据处理、算法改进、程序设计科研仿真。 🍎 往期回顾关注个人主页:完整代码获取 定制创新 论文复现私信 🍊个人信条:做科研,博学之、审…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬