资讯中心

JVM分代回收:新生代/老年代如何协作,对象晋升与调优实践

📅 2026/9/30 23:21:51
JVM分代回收:新生代/老年代如何协作,对象晋升与调优实践
1. 内存回收这件事为什么没法“一锅端”把这个问题缩小到最本质的一点JVM为什么不肯把整个堆当成一块大平地每次回收时统一把所有垃圾找出来清掉因为那样做的成本高到任何一种在线业务都扛不住。1.1 一个常见但很容易被人追问的回答如果你在面试里碰到“JVM为什么把堆分成新生代和老年代”这个问题最省事的说法是“因为大多数对象朝生夕死分代之后可以用不同回收算法处理不同生命周期的对象。”这句话本身没有错但太像背课文了。面试官一旦接着问“那不分代所有对象一起回收到底会出什么问题”很多人就卡住了。我见过不少同事GC Roots、可达性分析、复制算法这些概念背得滚瓜烂熟但对分代的动机讲不清楚。其实这个问题的答案没那么玄核心就是一句话不分代垃圾回收每次都要为一小拨垃圾付出清扫整个仓库的代价分代之后JVM平时只需要盯住“垃圾率最高”的那一小片区域就能用很低的成本拿回大部分可用内存。1.2 整堆扫描的“人头税”想象一下如果你每次大扫除都要把整个屋子翻一遍只是为了找到垃圾桶里那几个空瓶子你会不会觉得亏JVM的垃圾回收本质上也是这个道理。假如堆里只有一个大区域新旧对象混在一起那么每次GC都必须从GC Roots出发沿着对象引用关系把整堆里活着的对象全部访问一遍标记出来然后才谈得上去清理垃圾。这就是标记阶段在Serial、Parallel这些收集器里是STW的业务线程得停下来等。堆越大、存活对象越多标记时间就越长。一个8GB的堆对象数量可能上亿全堆标记动辄就是几百毫秒甚至好几秒。对接口RT要求控制在几百毫秒的线上服务来说这几乎等于事故。更麻烦的是快照里真正要清理的垃圾可能只占很小比例但你没法只扫描垃圾而不扫描存活对象——你只有遍历完整个对象图才知道哪些对象已经不可达。所以“不分代”的第一个代价就是每次GC都要交一笔全量遍历的人头税。1.3 复制算法也不是万能的有的人会说既然标记成本高那用复制算法啊把存活对象搬到一边一次性清空原区域多干净。这个思路对新生代很有效但对一个混装所有对象的堆来说并不现实。复制算法的成本跟存活对象的数量成正比。如果整个堆里大部分对象都是长命的你每回收一次就要把这些长命对象从A区复制到B区下一次再从B区复制回A区反复搬运白白消耗CPU和内存带宽。另外标记-清理还会带来碎片问题回收完垃圾后存活对象还留在原地堆上被掏出一堆不连续的小空洞。后续想分配一个大对象时明明总剩余内存够却找不到连续空间只能再触发一次Full GC做压缩整理。所以不分代会让JVM陷入两难要么每轮回收都付出高昂的全堆遍历成本要么长期跟碎片化搏斗。分代的价值就在这里——把对象按生命周期分开让不同成本结构的回收动作落到不同区域各取所长。2. 分代的底气两条关于对象寿命的经验法则分代不是拍脑袋设计出来的背后是JVM多年运行中观察到的一个非常稳定的规律对象的寿命分布极度不均衡。2.1 弱分代假说绝大多数对象活不过第一轮GC做JVM调优时间长了的人对GC日志会形成一种直觉新生代每次回收都能清掉绝大部分空间。一个Eden区从占满到触发Minor GC回收完再看存活对象可能只剩下零头。这就是所谓的弱分代假说绝大多数对象在创建之后很快就不再被引用了。你写个for循环new一堆中间对象处理一次HTTP请求生成各种Request、Response对象解析一条JSON报文产生的临时结构体——这些对象基本都是“朝生夕死”。它们不是坏对象只是生命周期本来就这么短。业务系统里这类对象能占到所有新生对象的80%甚至更高。正因为这个比例如此稳定JVM才敢设计一个小巧的新生代让它在高频率的Minor GC中反复清理同一批短命对象。每次清理的绝对成本不高但收益极高因为一次就能腾出大量Eden空间。2.2 强分代假说熬过来的对象越来越难被杀掉和弱分代假说相对的是强分代假说一个对象熬过的GC次数越多它继续存活到下一轮GC的可能性就越大。这也很符合直觉。Spring容器里的单例Bean、数据库连接池里的连接对象、缓存的配置信息、被ThreadLocal持有的上下文对象这些一旦初始化基本就是要陪着JVM到最后一刻的。它们数量不多但个个都是“老资格”不能每轮都跟新生代的小年轻们一起被反复搬运。如果每次Minor GC都把这些长命对象复制一遍纯属浪费。两条假说合在一起就形成了一套分诊逻辑新生代处理那些大概率马上死的对象老年代安置那些已经证明了自己生命力的对象。前者可以频繁清理因为活下来的少后者不需要频繁清理因为里面大多是长寿对象GC做得太勤反而是负担。2.3 分代省掉的不是垃圾而是遍历和复制的数学题用一个简单的算术就能看出差距。假设新生代每次Minor GC能回收80%的空间那存活的20%用复制算法搬到Survivor代价很小。老年代因为对象存活率高不适合复制改用标记-清理或标记-整理虽然单次成本高但触发频率低。这样一来“回收频率高但单次代价小”的新生代和“回收频率低但单次代价大”的老年代组合起来整体吞吐量远高于“不分代每次全堆扫描”。分代的本质就是用空间隔离换时间让JVM平时只盯住垃圾率最高那一小片区域。维度新生代老年代目标对象新创建的短命对象为主熬过多轮GC的长命对象、大对象GC频率高Minor GC/Young GC频繁触发低Full GC或Mixed GC不常触发典型算法复制清除标记-清理 / 标记-整理 / 并发标记回收单次停顿通常较短通常较长影响更大这也是为什么很多线上服务YGC次数几千次都没事FGC只要来个几次接口RT就开始剧烈抖动。区别不在于GC执行次数而在于每次执行背后遍历和复制的规模。3. 区域舞台Eden、Survivor、老年代各自承担什么职责分代是策略要落地还得有物理或者逻辑上的分区布局。HotSpot把堆分成新生代和老年代新生代内部又进一步拆成Eden、Survivor 0、Survivor 1三块。每一块都不是白设的。3.1 Eden绝大多数对象的出生地在HotSpot的默认布局里新生代占堆的比例由-XX:NewRatio控制典型值是2表示老年代:新生代 2:1。如果显式设置-Xmn就直接指定新生代的绝对大小。新生代内部又被拆成Eden和两块Survivor默认比例是8:1:1对应参数-XX:SurvivorRatio8。也就是说新生代里Eden占80%S0和S1各占10%。正常情况下新建对象优先分配在Eden。Eden的设计目的很纯粹承接巨大的对象创建速率和同样巨大的对象死亡率。Eden被打满后触发的新生代回收就是Minor GC也有人叫Young GC。Eden区越大两次Minor GC之间的间隔越长但单次回收时存活的“残余对象”也可能更多不能一味求大。3.2 两个Survivor让复制算法真正成立Survivor区是最容易被忽视的角色也是线上调优时最容易翻车的地方。它的作用不是存放长寿对象而是给“已经活过一轮但还不确定能不能长寿”的对象一个缓冲带。每次Minor GC发生时JVM会把Eden和一个Survivor比如S0里仍然存活的对象复制到另一个空的SurvivorS1中然后把Eden和S0一次性清空。下一轮Minor GC时轮到S1里的存活对象复制回S0角色互换。整个过程里Eden和其中一个Survivor永远是被清空的一方另一个Survivor承接幸存者。为什么不把Eden里活下来的对象直接丢进老年代因为很多对象只是暂时活过了一轮后两轮里大概率还会死。如果它们一上来就进老年代老年代会被一堆短命对象迅速灌满老年代GC压力骤增。Survivor存在的意义就是给这些“还没死透”的对象多几次考验机会熬得住才进老年代熬不住就在Survivor之间被回收。那为什么需要两个Survivor而不是一个答案是为了避免碎片。如果在同一个区内既保存存活对象又清理垃圾不可避免会出现内存空洞。两个Survivor轮番倒存活对象被整体复制到一个干净区域然后旧区域直接清空整个过程不会留下碎片。这是典型的用空间换整理成本。3.3 老年代长寿对象的长期宿舍老年代里存放的是熬过了多轮Minor GC的对象以及通过特殊规则直接进入的对象比如大对象。它的GC频率比新生代低得多但一旦发生处理起来就重得多。原因很简单老年代里都是“千年的狐狸”存活率高用复制算法不划算对象之间的引用关系也复杂标记阶段要遍历的路径更长。所以常见的老年代回收策略要么是标记-清理加碎片压缩要么是CMS这种并发标记清理要么是G1的Mixed GC。这也是为什么很多服务的GC日志里YGC几百次FGC只有个位数整体还算健康一旦FGC频率上升问题通常就不是“多触发几次GC”那么简单了而是某个区域的设计容量出问题了。3.4 新生代大小不是越大越好很多人有个直觉新生代太小GC频繁那把新生代调大不就行了实际上没这么简单。新生代调大意味着老年代变小长命对象空间被挤压Full GC风险反而上升。而且新生代单次Minor GC时需要复制存活的残余对象Eden越大残余对象总量也可能越大单次停顿时间随之变长。调优时通常先看GC日志观察YGC后Survivor区的实际占用再决定要不要调整-XX:SurvivorRatio。如果Survivor经常被塞到90%以上说明缓冲空间不够可以适当调大Survivor占比如果Survivor几乎每次都是空的那它可能就浪费了可以考虑调小一点。最忌讳的是不看数据凭感觉调参数。4. 对象如何“毕业”到老年代年龄、动态判定与担保机制搞清楚了区域布局下一个关键问题是对象到底通过什么规则从新生代进入老年代这一步理解不到位调优时很容易被GC日志里的晋升数据搞迷糊。4.1 年龄记录的细节对象头里只有4位每个Java对象头的Mark Word里有一个分代年龄字段GC每发生一次存活下来的对象年龄就加1。熬到一定年龄后对象就会被晋升到老年代。这里有个冷知识年龄字段只有4位最大只能表示15所以晋升阈值的上限也就是15。这是JVM为了在对象头上抠内存空间做出来的设计也是为什么你在设置-XX:MaxTenuringThreshold时不能超过15。默认情况下Parallel收集器用的是15CMS收集器在JDK 8中默认是6。不同收集器的默认值不一样但上限都一样。4.2 动态年龄判定不是非得活到15岁很多资料会告诉你“对象活过15次Minor GC就会进入老年代”这句话严格来说是不准确的。HotSpot还有一个动态年龄判定规则如果在Survivor中从年龄最小开始累积到某个年龄的对象大小总和超过了Survivor空间的一半默认-XX:TargetSurvivorRatio50那么等于或大于该年龄的对象就可以直接晋升老年代。这个机制是为了防止Survivor被一群“老油条”占满。假设Survivor只有64M里面塞了一堆年龄为2的对象这些对象短期也不会死如果非等它们活到15Survivor早就爆了。动态判定会提前把这一批对象送到老年代给新对象腾位置。实际看GC日志时在JDK 8里加上-XX:PrintTenuringDistribution能看到类似这样的输出Desired survivor size 9175040 bytes, new threshold 7 (max 15) - age 1: 1048576 bytes, 1048576 total - age 2: 2097152 bytes, 3145728 total - age 3: 1048576 bytes, 4194304 total看到“new threshold 7”就说明JVM没有等到15而是根据Survivor实际占用情况动态下调了晋升阈值。这是很常见的现象不是异常。4.3 大对象直接进老年代有心还是无意除了熬年龄还有一类对象会绕过新生代大对象。通过-XX:PretenureSizeThreshold可以设置一个字节数阈值超过该大小的对象直接分配到老年代。原因很好理解大对象在Eden和Survivor之间反复复制非常浪费带宽不如直接放到老年代让它慢慢被回收。但这里有个非常容易踩的坑这个参数只对Serial和ParNew收集器有效对Parallel Scavenge是无效的G1也不通过这个参数来实现大对象跳过新生代。G1有自己的大对象区域Humongous Region来处理。如果你在Parallel GC下以为设置了PretenureSizeThreshold就能让大对象进老年代打开GC日志会发现根本没生效。参数名字看着通用实际各收集器各表各账调优前一定要确认当前用的是哪个收集器。4.4 空间分配担保Minor GC失败后的兜底还有一个大家面试常问、实际也容易踩的机制空间分配担保。Minor GC开始前JVM需要判断老年代最大可用连续空间是否大于新生代所有对象总大小。如果足够可以放心执行Minor GC如果不够就要根据历史担保记录判断是否允许冒险。一旦担保失败就会升级为Full GC。用大白话说新生代Minor GC后存活对象要往Survivor搬Survivor放不下的部分要挪到老年代。如果老年代也没地方接盘这次Minor GC没法愉快地进行只能对全堆来一次彻底回收。所以调优时不能只看新生代老年代的剩余空间同样关乎新生代能不能顺利完成回收。5. 分代思想在收集器中的演化从CMS到G1再到分代ZGC分代不是一套死板的物理布局而是一种策略。每个时代的垃圾收集器都在用不同的方式实现分代理解这些差异才能真正看懂参数。5.1 不同收集器对分代的具体实现不一样Parallel Scavenge加Parallel Old的组合下新生代和老年代都是物理连续的通过-XX:NewRatio或-Xmn就能调整它们的大小。CMS时代新生代使用ParNew收集器老年代采用并发的标记清除CMS的痛点之一就是老年代碎片化严重最终可能退化为Serial Old做整堆压缩。到了G1思路又变了。G1把整个堆划分成一个个大小相同的RegionEden、Survivor、Old只是逻辑角色Region之间可以互相转换。G1也保留了新生代和老年代的概念但新生代大小不再是一块固定连续的地址空间而是由一组Region动态组成受-XX:MaxGCPauseMillis等参数影响。这意味着从CMS迁移到G1后还习惯性地设置-Xmn往往不是好事。G1本来会根据暂停时间目标动态调整新生代Region数量你一旦手动钉死新生代大小它的自适应能力就打了折扣。5.2 一次线上晋升压力过大导致的FGC复盘这里分享一次比较典型的晋升问题排查过程。有个订单处理服务平时YGC几秒一次老年代增长非常平稳。某次上线新功能后YGC频率变化不大但老年代占用从30%一路涨到85%FGC开始出现接口RT跟着抖动。第一步是看GC日志。JDK 8下加-XX:PrintGCDetails和-XX:PrintGCDateStampsJDK 11及以上用统一日志参数# JDK 8 java -Xloggc:gc.log -XX:PrintGCDetails -XX:PrintGCDateStamps -jar app.jar # JDK 11 java -Xlog:gc*:filegc.log -jar app.jar用jstat周期性查看区域占用jstat -gcutil 12345 1000典型的危险信号是S区接近100%、E区反复占满、老年代O区持续上升S0 S1 E O M CCS YGC YGCT FGC FGCT GCT 0.00 100.00 30.25 85.32 95.10 ... 4523 12.345 17 8.432 ...看到S1满、老年代O持续上升就要怀疑晋升压力太大。继续挖代码发现新功能里有个地方用正则表达式处理大量请求还把匹配后的临时结果存在ThreadLocal里导致一批批对象被误认为“有引用”在Survivor里一直存活最终被挤进老年代。处理分两步先规范代码把ThreadLocal里的临时大对象及时清理这是根治同时临时调大Survivor占比避免存活对象因为Survivor放不下而被过早晋升。压测确认老年代曲线变平后又把参数改回了相对适中的值。这个案例里最值得记住的不是具体参数而是排查顺序先看GC日志里的存活对象和晋升数据再定位代码里的引用持有最后才动参数。顺序反了很容易变成盲调。5.3 不分代也可以但分代ZGC说明了很多ZGC刚推出时给人的印象是“不分代也能做到极低停顿”。它通过染色指针和读屏障实现全堆并发回收不依赖传统新生代的复制算法停顿时间也能压制在毫秒级。这让很多人一度觉得分代可能要过时了。但JDK 21正式带来了分代ZGC官方明确说目的就是为了降低CPU开销和内存占用。不分代的全堆回收每次都要把所有Region都纳入并发标记范围哪怕老年代里全是长寿对象没什么垃圾也得陪跑一遍。分代之后绝大多数短命对象可以在年轻代快速清理老年代不需要每次都跟着扫描整体CPU使用率明显下降。这个变化非常说明问题哪怕在最追求低延迟的收集器上分代依然能带来实打实的收益。因为分代的底层依据是对象寿命分布的客观规律这个规律不会因为收集器变先进了就消失。6. 三个容易误判分代机制的坑最后再聊几个我在实际工作中看到过很多次的误区把这些避开调优就能少走很多弯路。6.1 “老年代里都是老对象”其实是误解大对象可以直接进老年代所以老年代里完全可能有刚创建几毫秒的大数组。对象晋升也不一定代表它生命力强有时候只是Survivor放不下了被“挤”过去的。GC日志里看到老年代快速上升时先怀疑晋升压力不要一口咬定是长命对象太多。两种情况的处理方向完全不同。6.2 “Full GC会先回收老年代再回收新生代”也不对Full GC通常会把新生代、老年代乃至元空间作为一个整体来处理而不是只去搞定老年代。尤其Parallel Old处理Full GC时会对整个堆做一次完整的标记-整理。监控里看到FGC次数高往往意味着整个堆层面出了问题单纯盯着老年代反而会忽略真正的原因。6.3 调参不要“头痛医头”只调大新生代、只调大Survivor、只把MaxTenuringThreshold调到15这些操作单独拎出来都可能适得其反。参数之间是联动的Eden变大Minor GC间隔变长单个Survivor要承接的存活对象也可能变多阈值调高Survivor可能装不下反而触发提前晋升。最稳妥的路径永远是先看GC日志算清楚分配速率和晋升速率再动参数。我自己做GC调优的习惯是先看几分钟jstat或GC日志确认老年代增长主要来自晋升还是来自内部对象膨胀。如果是晋升问题大概率在新生代或Survivor配置如果是老年代内部对象在涨那就去查缓存、线程上下文、静态集合这类真正持有长命引用的地方。理解了分代背后的这套逻辑你在看GC问题时就不再只是数YGC和FGC的次数而是能读懂它们各自的成本结构了。

看完文章,想为自己的企业也做一次专业网站诊断?

尧图顾问免费为您评估现有网站,并给出建站/改版建议与报价方案。

免费获取方案