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:SurvivorRatio=8。也就是说,新生代里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)里仍然存活的对象,复制到另一个空的Survivor(S1)中,然后把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收集器用的是15,CMS收集器在JDK 8中默认是6。不同收集器的默认值不一样,但上限都一样。
4.2 动态年龄判定:不是非得活到15岁
很多资料会告诉你“对象活过15次Minor GC就会进入老年代”,这句话严格来说是不准确的。HotSpot还有一个动态年龄判定规则:如果在Survivor中,从年龄最小开始累积,到某个年龄的对象大小总和超过了Survivor空间的一半(默认-XX:TargetSurvivorRatio=50),那么等于或大于该年龄的对象就可以直接晋升老年代。
这个机制是为了防止Survivor被一群“老油条”占满。假设Survivor只有64M,里面塞了一堆年龄为2的对象,这些对象短期也不会死,如果非等它们活到15,Survivor早就爆了。动态判定会提前把这一批对象送到老年代,给新对象腾位置。
实际看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把整个堆划分成一个个大小相同的Region,Eden、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:+PrintGCDateStamps,JDK 11及以上用统一日志参数:
# JDK 8 java -Xloggc:gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps -jar app.jar # JDK 11+ java -Xlog:gc*:file=gc.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的次数,而是能读懂它们各自的成本结构了。