上周排查一个线上服务,现象很典型:CPU不高、内存看着也够,但请求延迟每隔半小时就有一个尖刺。jstat一查,Full GC次数在一小时里从个位数飙到三十几次,老年代在两次Full GC之间就涨了几个G。这种场面对写Java的同学来说,算是噩梦级的熟悉。但问题本身并不难,难的是很多人对垃圾回收的理解停留在“会调-Xmx”的层面,一遇到GC瓶颈就盲目改堆大小,最后越调越乱。
按这个系列的进度,“上”篇聊完了JVM的运行时数据区和对象存活判定,这篇“中”篇正好往深里走一步:分代假说到底在说什么,三种经典回收算法为什么谁都没能独大,CMS和G1的演进究竟在解决什么问题,以及一套可以直接抄进Tomcat启动脚本的参数清单。整个系列的目标读者很明确——正在为线上Full GC发愁的人、准备面试时被JVM问懵的人,以及想真正理解GC设计取舍而不是只背概念的开发者。这篇不会堆概念,所有内容都尽量贴着实战场景讲,很多细节是我自己踩过坑之后才真正想通的。
1. 分代假说:JVM堆内存拆成两段的底层逻辑
很多人一上来就背“新生代、老年代、Eden、Survivor”,但从来没有人讲清楚:JVM为什么非要把堆拆成两段来回收?直接整个堆一起清不行吗?要回答这个问题,得先接受一个统计性观察——这个观察在GC设计里叫分代假说。
1.1 两个假说:为什么大量对象活不过一场Minor GC
分代假说本质上是两个经验规律:
- 弱分代假说:绝大多数对象都是“朝生夕死”的,创建之后很快就变成垃圾。
- 强分代假说:熬过越多次GC的对象,越有可能继续存活下去。
这两个规律听起来像废话,但它是整个分代收集策略的地基。只要微观世界里你new过的对象大多活不了太久,那让GC每次都从整个堆里找垃圾就是巨大的浪费——绝大多数时间你都在反复扫描老年代里那堆其实很稳定的长命对象。
信不信由你,HotSpot的社区经验数据显示,80%甚至90%以上的对象,在第一轮Minor GC时就已经变得不可达。这批对象集中在新生代,回收它们的成本非常低。而老年代里的对象存活率普遍高,不值得频繁清理。
基于这个观察,JVM把堆拆成**新生代(Young Generation)和老年代(Old Generation)**两段物理区域,新生代的GC叫Minor GC,老年代的GC叫Major GC(老年代专用)或者Full GC(整堆回收)。服务端调优时,我们最关心的其实就是这两个代各自的空间比例和回收频率。
1.2 内存区域划分:JVM堆里的Eden、Survivor和老年代如何呼应假说
再往细里看,新生代又被切成了三个区:一个Eden(伊甸园)加两个Survivor(幸存者区,通常叫S0和S1)。默认比例是8:1:1。
这套切分不是拍脑袋定的,它和“标记-复制”算法强相关。Eden是所有普通对象诞生的地方,S0/S1负责承接Minor GC后仍存活的对象。每次Minor GC发生时,JVM把Eden和正在使用的那个Survivor里存活的对象,统一复制到另一个空闲Survivor,然后清空Eden和旧Survivor,整个过程不需要碎片整理。由于绝大多数对象在Eden里就已经死了,真正需要拷贝到Survivor的数量很小,所以把Eden划得很大、Survivor划得很小是划算的——内存利用率能接近90%。
说白了,分代是一种布局优化,而不是一种具体算法。它利用的是对象生命周期的统计规律,把回收成本集中在最可能有垃圾的区域,同时让老年代少被折腾。
1.3 分代的意义:GC Roots和全堆扫描的关系
还有一个关键点必须提一下:无论回收哪个代,判断对象是否存活都要从GC Roots出发做可达性分析。通俗讲,GC Roots就是一组“根引用”——线程栈里的局部变量、静态变量、JNI引用、活跃线程对象等。从根出发能遍历到的对象都视为存活,遍历不到的就是垃圾。
在上篇里我已经详细聊过GC Roots,这里只补充一个容易误解的点:Minor GC并不是不看其他区域。新生代回收时,如果老年代对象引用了新生代对象,那这个新生代对象不能被当成垃圾。为了处理这种跨代引用,JVM用了一种叫**卡表(Card Table)**的结构,把老年代按区域划分,标记哪些卡页有跨代引用,这样Minor GC扫描时不必全量扫老年代。很多调优事故都是因为对这块理解不到位,以为“Minor GC只动Eden”,才误判了观察数据。
2. 三种经典回收算法:没有谁的完美,只有谁的合适
分代搞定了“在哪里回收”,接下来的问题是“用什么方式回收”。常说的**标记-清除(Mark-Sweep)、标记-复制(Mark-Copy)、标记-整理(Mark-Compact)**三种算法,名字里都带“标记”,因为它们的第一步完全一样:从GC Roots出发,把存活对象打个标。区别全在第二步怎么处理垃圾和存活对象的关系。
2.1 标记-清除:最直白的思路,但留下了碎片难题
标记-清除的逻辑最简单:先标记所有存活对象,第二步把没有标记的内存区回收掉。听起来效率很高,因为不清扫存活对象,垃圾内存直接“晾着”等待后续分配。
它的致命伤是内存碎片。想象一个书架上摆满大小不一的旧书,你抽走几本后,空出来的缝隙零散分布,新书根本塞不进去。JVM的分配器在堆上找连续内存时,如果可用空间是一地碎片,一个大数组或者大对象就可能分配失败,逼不得已触发Full GC或直接抛OOM。另外,标记和清除两个阶段的工作量都和堆大小成正比,堆一大,整体效率就上不去。CMS是这套算法最出名的使用者,后面会聊到——它的衰落,很大程度上就是为碎片问题买单。
2.2 标记-复制:拿空间换时间,新生代的赢家
标记-复制的思路不是原地清理,而是把堆分成两块,只使用其中一块。GC时把存活对象整体拷贝到另一块空白区域,再一次性把原来那一整块清空。因为拷贝之后所有存活对象挤在一起,分配新对象永远只需要移动一个指针,天然无碎片。空间换来了时间,代价是内存利用率低。
如果傻乎乎地对半分,那真浪费了一半内存。HotSpot没有这么干,它假设“绝大多数对象会被回收”,于是设计成Eden占大头、Survivor占小头,GC时把存活对象平移到一块较小的Survivor里。这就是上节说的8:1:1。当Survivor实在塞不下时怎么办?那就要用到分配担保——把多余的存活对象直接送进老年代。这个决定看起来聪明,但也是很多问题的源头:如果Survivor容量严重偏小,大量“本该留在新生代多熬几轮”的对象会被提前晋升,老年代迅速膨胀,最终引来更可怕的Full GC。
2.3 标记-整理:老年代求稳的解决方案
老年代的对象存活率高,用复制算法不划算——拷贝大量长效对象,成本太高。所以老年代通常用标记-整理:标记完存活对象后,把它们全部往内存一端移动,然后清理掉边界以外的空间。既消灭了碎片,又不用老搬移对象。
代价是整理过程非常“重”:存活对象越多,移动量越大,停顿时间也越长。因此老年代的GC频率必须压得很低,否则服务会频繁感受到明显的“世界停止”。Parallel Old和Serial Old都走这个路线。
2.4 组合拳与分配担保机制
现实中JVM不是只用一种算法,而是“一鱼两吃”:新生代用标记-复制,老年代用标记-整理(或标记-清除)。CMS的特殊性在于,它在老年代使用了标记-清除的并行变种,结果吃了碎片的大亏;而G1在逻辑上把堆拆成Region,宏观上走混合回收,但微观上每个Region的存活对象也是靠复制和整理来移动的。
理解这三种算法后,再看一个实战里经常冒出来的概念:空间分配担保。前面说了,Minor GC之后如果Survivor装不下存活对象,多出来的对象会直接进老年代。进老年代之前,JVM会判断老年代剩余空间是否足够容纳“历史晋升对象的大致规模”。不够时,就会提前触发Full GC,或者直接抛Promotion Failed。很多线上服务莫名抖动,就是这条担保链路的某个环节出了漏洞,对象被过度晋升。
3. 收集器演进史:从Serial到G1,停顿时间是怎样被一步步压低的
分代假说和算法是理论底座,真正落地的是一批批具体的垃圾收集器。从JDK诞生到现在,收集器的演进主线可以用一句话概括:在“吞吐量”和“停顿时间”之间不断找平衡。这条线比背一串名词有意思得多。
3.1 单线程时代:Serial和Parallel的取舍
最早的Serial收集器是单线程GC,GC时必须暂停所有应用线程(Stop The World,STW)。它简单可靠,至今仍可用于小堆、客户端应用或调试环境。Parallel Scavenge在同一时代主打多线程并行回收,把吞吐量当作核心指标——吞吐量=运行用户代码时间 /(运行用户代码时间+GC时间)。配合Parallel Old后,成为很长一段时间内服务端默认收集器。
选择它们,意味着你接受“GC时整段业务停顿”换取“每秒吞吐够高”。早年大数据批处理、离线任务就吃这一套,因为跑一次任务动辄几小时,多停顿几秒无感,但吞吐量高能明显缩短总耗时。
3.2 CMS:并发收集的破局者,成也并发败也并发
CMS(Concurrent Mark Sweep)的正确历史地位是“第一款真正追求低停顿的收集器”。它把老年代收集拆成四步:
- 初始标记(STW):只标记GC Roots直接可达的对象,后者数量少,停顿极短。
- 并发标记:和应用线程同时运行,遍历对象图。这一步很长,但业务无感。
- 重新标记(STW):修正并发期间因引用变动导致的标记偏差。
- 并发清除:和应用线程并发清理垃圾。
听起来很美,现实很骨感。CMS最大的问题是它会一边回收一边产生新垃圾——并发清除阶段业务线程还在new对象,这些新垃圾只能留到下一轮,称为浮动垃圾。更要命的是,因为老年代用了标记-清除而非整理,长期运行后碎片越来越严重,稍微分配一个大对象就会触发并发模式失败(Concurrent Mode Failure),CMS直接放弃治疗,退化成Serial Old串行Full GC——停顿时间瞬间爆炸。
我在生产上见过很多次这种“偶尔一下几十秒的卡顿”,查GC日志多半就是CMS退化。正因如此,JDK 9之后CMS被标记为废弃,JDK 14直接移除。到今天还守着CMS的存量服务,应该尽快迁出去。
3.3 G1:Region化堆,把停顿变成可预期
G1(Garbage First)的出现,让收集器的设计范式换了个赛道。它不再是“新生代一个物理区、老年代一个物理区”,而是把整个堆切成若干大小相等的Region,新生代和老年代只是Region的逻辑集合。每个Region既能当Eden,也能当Survivor或Old,Flexible是关键词。
G1为每个Region保存了一份Remembered Set(RSet),记录谁引用了这个Region里的对象。这样跨Region引用关系被记录下来,Minor GC时不需要扫全堆,只需处理相关RSet。代价是维护RSet要插入写屏障(Write Barrier),每次引用字段赋值都要顺手记账,这带来一些CPU开销。
G1的回收路径分为几步:
- Young GC:只清理Eden和部分Survivor,速度很快。
- 并发标记:与业务线程并发,为Mixed GC做准备。
- Mixed GC:不只要收年轻代,还选一批老年代Region一起收,按停顿目标挑选性价比最高的Region。
- Full GC:并发回收退化为全停顿串行回收,通常意味着程序已经有严重问题。
这套设计把它变成一个“可以聊停顿目标”的收集器——通过-XX:MaxGCPauseMillis设定软目标,G1用积累的历史数据去估算回收成本,挑性价比最高的Region回收,尽量把停顿压在目标以内。
但G1不是银弹,实战中常见的坑包括:停顿目标设太激进(比如50ms),导致回收频率猛增、吞吐量下降;幸存者区规划不合理,产生大量Humongous超大对象,连续占多个Region又很难回收;另外,大堆场景下RSet本身的内存开销也不可小觑。
3.4 中篇的一次横向对比
到了这一步,可以把CMS和G1放一张表里对比着看,面试和选型时都有用。
| 维度 | CMS | G1 |
|---|---|---|
| 堆模型 | 物理分代,老年代连续 | Region逻辑分代,物理连续但可灵活划分 |
| 老年代算法 | 标记-清除,易碎片 | Region内复制/整理,缓解碎片 |
| 停顿控制 | 尽量低,但并发模式失败后可能爆炸 | 有停顿目标,增量回收,相对可控 |
| 跨代处理 | 卡表 | RSet写屏障 |
| 适用场景 | JDK8时代的低延迟老项目 | JDK8u20+之后的中大堆、更均衡的新项目 |
| 现状 | JDK9废弃,JDK14移除 | JDK9+默认收集器 |
再往后还有ZGC和Shenandoah,主打超大堆和极低停顿,但我计划把它们的细节留到“下篇”展开,中篇先把分代、算法和收集器演进这条主线理扎实。
4. 对象的一生:从Eden诞生到老年代定居,晋升路径全拆解
搞懂了收集器,你会发现真正的实战核心是“对象怎么被分配、被晋升、被回收”。这一节就完整走一遍Java对象的生命周期,结合上面的一套理论。
4.1 分配路径:先走快车道,再进Eden
一个Java对象诞生,不一定立刻进堆。JVM的JIT编译器在做逃逸分析后,会把没有逃逸出方法的小对象直接在栈上分配,方法一退出内存自然回收,完全绕开GC。这是性能最好的路径,可惜只有部分纯局部对象享受得到。
大多数普通对象会走第二条快车道——TLAB(Thread Local Allocation Buffer)。Eden被划分成很多线程私有小块,线程在自己那块里分配对象不需要锁竞争,效率极高。TLAB空间用完,才回到Eden的主空间通过CAS去抢。
也就是说,对象真正进入托管堆时,默认都在Eden。Eden满到一定程度,Minor GC触发,存活下来的对象被复制到S0,接着才有资格谈后续晋升。
4.2 晋升规则:年龄、动态判定与大对象
对象每熬过一次Minor GC,年龄(age)加1。达到-XX:MaxTenuringThreshold(默认15)的对象会晋升到老年代。但实际中晋升往往比15次更早,因为还有一个动态年龄判定:当Survivor中某一年龄的对象总大小超过了TargetSurvivorRatio(默认为50%),系统就会把不小于该年龄的对象全部提前晋升。这也是为什么很多对象看着年龄不大,却早早进了老年代。
除了年龄,还有两类特殊入口:
- 大对象直接进老年代:通过
-XX:PretenureSizeThreshold设置阈值,超过大小的对象不经过Eden,直接放老年代。这样做的原因是大对象在Eden和Survivor之间来回复制很昂贵,还给新生代GC造成压力。 - 分配担保路径:Minor GC后如果Survivor装不下剩余存活对象,直接晋升老年代。
4.3 一个请求对象的完整旅程
拿一个电商“下单”接口举例:请求进来,控制器里new了一批DTO、List、Map,还有一个上传用的字节缓冲。大部分对象在方法执行完就不可达了;小部分被JIT优化直接栈上分配,方法返回即消失;剩下的进入Eden,经历了一次Minor GC后仅剩少数进入S0。如果这批对象在S区来回拷贝了几轮还没被业务缓存引用释放,最终就被晋升到了老年代,等待下一次Major/Full GC来终结。
这条路径里最容易出事故的点是:Survivor容量偏小,导致大量存活对象提前晋升。用jstat -gcutil观察时,你会看到老年代占比(O列)持续走高,Minor GC不频繁但老年代每几分钟就涨一圈,这类信号基本就是晋升节奏出了问题,而不是堆不够大。
5. 落地到Tomcat:一份可上线的GC参数清单与验证方法
理论讲得再多,最后都要变成启动脚本里的几行参数。以最常见的Tomcat部署为例,配置会写在catalina.sh的JAVA_OPTS里。
5.1 JAVA_OPTS里到底该写什么
下面是我在JDK 8 + Tomcat 8.5场景下比较稳妥的一套起始配置,直接可抄,但抄完要按自己的业务观察去微调:
# 堆与元空间 JAVA_OPTS="-Xms4096m -Xmx4096m -Xmn1536m" JAVA_OPTS="$JAVA_OPTS -XX:MetaspaceSize=512m -XX:MaxMetaspaceSize=1g" # 收集器与停顿目标 JAVA_OPTS="$JAVA_OPTS -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:G1HeapRegionSize=8m" # 新生代细节 JAVA_OPTS="$JAVA_OPTS -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=10" # 故障现场留痕 JAVA_OPTS="$JAVA_OPTS -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/logs/heapdump.hprof" # GC日志(JDK 9+ 用 -Xlog 统一格式,JDK 8 用下面这组) JAVA_OPTS="$JAVA_OPTS -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/data/logs/gc.log"如果你用的是JDK 11或17,最后一行要换成-Xlog:gc*:file=/data/logs/gc.log:time,uptime,level,tags,旧参数在新版本里已经识别不出来了。
5.2 参数背后的计算与选择
很多文章只给参数,不给理由,这里补上我当时做这些选择的逻辑。
-Xms和-Xmx为什么必须相等:如果不相等,JVM会在业务高峰期堆扩容、低谷期又收缩,扩容本身就是一次昂贵的“搬家”,还会让GC行为变得不可预测。让起始堆等于最大堆,等于告诉JVM“你老老实实把内存吃满,别瞎折腾”。
-Xmn设多大的问题:年轻代设大了,Minor GC次数减少但单次停顿变长;更关键的是它挤占了老年代,老年代太小会让Full GC变得频繁。我的经验是年轻代控制在堆的1/3到1/2之间比较稳,不能贪大。
为什么用G1而不是Parallel:有不少“吞吐量优先”的教程还在推荐Parallel。但现代业务大多是对延迟敏感的在线服务,G1的可控停顿更贴心,而且JDK 9后它就是默认收集器,社区踩坑少,资料多。
MaxGCPauseMillis真别设太小:设成50ms的话,G1每次回收的Region数量会很少,回收频率变高,浮动垃圾来不及清理,反而让Full GC风险升高。200ms左右是均衡点,某些超低延迟场景再配合ZGC而不是硬调G1。
5.3 用jstat和GC日志验证调优效果
参数不是调完就完事,必须去验证。我的验证三板斧:
jstat -gcutil <pid> 1000:每秒输出Eden、Survivor、老年代、元空间的使用百分比和GC次数。重点看E(Eden)是不是频繁到100%,O(Old)的长期趋势是持平还是缓慢上涨。jmap -heap <pid>:看当前各代容量和实际使用量,确认参数真的生效。- GC日志:启动参数里已经留了
gc.log,线上出了问题直接看日志里有没有Concurrent Mode Failure、Humongous Allocation、Promotion Failed这三个高频关键词。
5.4 调优现场最常见的三个翻车姿势
第一,照抄网上的CMS配置到JDK 11上,启动直接报错。CMS已经被移除,老文章里的-XX:+UseConcMarkSweepGC在新版本里是无效参数。
第二,盲目调大Xmx。我见过一次案例:把堆从2G改成6G后Minor GC次数确实降了,但因为老年代容量没同步调整,对象晋升空间反而变紧张,Full GC从原本的每天几次变成每小时几次。堆的大小要和其他代参数联动设计。
第三,生产环境只装了JRE没装JDK。JRE是JVM加标准类库,JDK是JRE再加编译和诊断工具。你可以在JRE上跑Java服务,但需要jstat、jmap、jcmd排查问题时会发现命令全都没有。线上至少留一个JDK的bin目录,这个问题值得在部署规范里写死。
6. 面试官追问GC时的真实意图与答题框架
这几年看过不少候选人背GC八股文,说出来的名词都对,一问场景就露馅。面试官真正想考核的其实有三层:概念是否自洽、有没有真实问题处理经验、做决策时有没有取舍意识。下面把这些年的高频考题和背后的考察点梳理一遍。
6.1 高频考题背后的三个考核层
以“如何判断对象可回收”为例,很多人张口就答“引用计数和可达性分析”,这就落在第一层。但面试官紧接着一定会问“GC Roots有哪些”,或者抛一个“循环引用会不会泄漏”。如果你能说出“JVM主流的可达性分析天然免疫循环引用,但ThreadLocal的ThreadLocalMap里key是弱引用、value是强引用,处理不当会形成另类泄漏”,那就进入第二层,而且是你真的有实战体会。
再来“CMS和G1的区别”。合格答案是堆模型、回收粒度、停顿控制三个维度横向对比;优秀答案是讲清楚CMS的并发模式失败是怎么来的,G1的RSet和写屏障又付出了什么代价。第三个维度“取舍意识”的体现是:“G1更均衡,但如果你追求极低延迟且能接受超大堆成本,ZGC更合适。”
6.2 一套不会冷场的答题思路
我总结过一套答GC题的固定框架:先讲分代假说,再讲算法,再落到收集器,最后给参数和排查方法。
比如被问到“什么时候触发Full GC”,按这套框架你会依次展开:Minor GC是Eden满时触发,晋升是Survivor装不下时发生,Full GC是老年代空间不足或元空间不足时触发;接着补一句“在实际服务里,我不太关注它叫什么名字,更关注GC日志里的三个关键词”;最后说“排查时我先看老年代增长曲线,再看晋升速率,而不是一上来就改堆大小”。这样答,面试官想追问都很难打断你。
6.3 别忽略的细节:内存模型和内存区域是两回事
必须要澄清一个高频混淆点:JMM内存模型和**JVM内存区域(运行时数据区)**完全不是一回事。面试时讲“jvm内存模型”多半问的是JMM——它讨论的是并发可见性、原子性、有序性,是规范和抽象;而“jvm内存区域”才是堆、栈、方法区这些数据结构。若把“内存分为堆和栈”当成JMM的答案,一般会被直接判负。
还有一个常见追问:“元空间不足会触发Full GC吗?”答案是会。JDK 8以后方法区由Metaspace承担,默认无上限,会随类加载数量增长,一旦本机物理内存不足或设置MaxMetaspaceSize过小,照样会触发Full GC。所以生产上我习惯把元空间的上限显式配出来,防止无脑类加载把服务拖垮。
最后分享一个我在调参过程里养成的习惯:做任何JVM优化之前,先把GC日志落地,至少保留两周。没有日志的调参就是蒙着眼调方向盘。等你真正从日志里读懂了老年代增长曲线,很多Full GC问题在爆发前就能看到苗头;这时候再回来翻这篇“中篇”里的理论,你会发现每个参数背后都站着一条明确的演进逻辑。下篇我打算把G1、ZGC的细节和GC日志分析方法展开写,到时候见。