在Java这行混得越久,越觉得“JVM垃圾收集器”这几个字是个照妖镜。简历上写“熟悉JVM调优”的人很多,可真到线上被concurrent mode failure打脸,或者排查一个诡异的Full GC时,是背过面试题还是真懂原理,立刻就现出原形。垃圾收集器从Serial一路演进到CMS、G1、ZGC,核心绕不开的一件事就是并发标记,而并发标记的理论底座,正是三色标记算法。
这篇文章我想用一线工程师的口吻,把三件事彻底讲透:JVM内存模型里GC到底只盯哪块地方、主流垃圾收集器的设计取舍是怎么权衡出来的、三色标记算法以及CMS和G1到底怎么把它落到代码里。中间会夹大量的实操参数、线上故障排查经验,还有一些我踩过的坑。
适合谁看?正在准备JVM面试的工程师;已经用上G1但只敢照抄别人参数的人;被线上GC问题折磨过、想系统补课的人。读完你至少能回答清楚一个问题:为什么GC要分代、要并发,以及为什么并发标记会漏对象。
1. 先把基础钉牢:JVM内存分布与GC的目标
1.1 运行时数据区到底长什么样
JVM的运行时数据区按《Java虚拟机规范》划分,主要包括程序计数器、虚拟机栈、本地方法栈、Java堆和方法区。其中GC“工作”的主战场是Java堆,这一点先刻在脑子里。
堆内部又按对象存活时长分成新生代和老年代。新生代再细分为Eden区和两个Survivor区(S0、S1),大部分对象刚new出来都先落在Eden,甚至“朝生夕死”,活不过第一轮Minor GC。Survivor区之间通过年龄计数器轮转,对象每躲过一轮GC年龄就加1,默认到15就会晋升老年代。这里有几个关键参数你需要刻在肌肉里:-Xms控制初始堆大小,-Xmx控制最大堆大小,-Xmn控制新生代大小。
很多人问为什么要分代,答案其实很朴素:绝大多数对象活不长,把它们集中起来用最快的复制算法清理掉,性价比最高。如果你不分代,每次GC都要扫全部堆,那大堆就完全没法玩了。
1.2 什么样的对象才会被回收
判断对象是否存活,主流思路是可达性分析:从一系列称为GC Roots的根对象出发,沿着引用链往下走,凡是被引用链连着的对象都算“活着”,没有连上的就是垃圾。
GC Roots不是某一种神秘对象,而是一组“被JVM认定可以从外部直接触及的引用起点”,主要包括:虚拟机栈中栈帧局部变量表里引用的对象、方法区里类静态属性引用的对象、方法区中常量引用的对象、本地方法栈中JNI引用的对象、被synchronized持有的对象,以及JMXBean、JVMTI回调这类JVM内部引用。
这里有个地方容易误会:GC Roots扫描的是“引用”,不是对象本身。引用的类型(强引用、软引用、弱引用、虚引用)也影响判定策略,比如软引用会在内存不足时被优先回收。
1.3 引用计数为什么总是差一口气
早年的语言实现喜欢用引用计数:每有一个地方引用对象,计数器加一;引用失效,计数器减一;归零就回收。这个思路实现简单,也能立刻回收垃圾,但有一个致命缺陷——循环引用。
举个最经典的例子,两个对象互相持有对方,但从外部再也没有任何引用指向它们时,两个计数器的值都不为0,于是这对“死循环”对象永远无法回收,程序里就等于内存泄漏。可达性分析没有这个问题,因为它根本不数引用次数,而是从根出发看“还能不能到达”。这也是为什么HotSpot最终选择了可达性分析。
理解了这个前提,你再去读垃圾收集器的任何资料,都不会被“标记”两个字绕晕:标记的实质,就是沿着GC Roots把存活的引用图遍历一遍。
2. 垃圾收集器全家福:从Serial到ZGC的选型逻辑
2.1 串行与并行时代:原始但有效的少年期
最早的Serial收集器是单线程的,GC时所有工作线程都要暂停,也就是著名的STW(Stop The World)。单核时代这无所谓,客户端小堆也够用,胜在简单可靠,到现在它仍然是很多嵌入式、桌面场景的默认选择。它和Serial Old配合,走的是新生代复制、老年代标记-整理的老路。
后来有了ParNew,它是Serial的多线程版本,本质上还是复制算法,因为能配合CMS的老年代并发收集,曾一度是JDK 8时代最经典的“新生代组合”。
同一时期还有Parallel Scavenge与Parallel Old,这对组合的核心目标是吞吐量。它们不太关心单次停顿长不长,关心的是单位时间内跑业务代码的时间占比。Parallel系列支持-XX:MaxGCPauseMillis和-XX:GCTimeRatio这类参数,配合自适应的调节策略,服务端批处理任务很喜欢用。
2.2 CMS:第一个真正意义上的并发收集器
CMS全名Concurrent Mark Sweep,是第一个让老年代回收的标记和清除阶段能和业务线程同时跑的收集器。它的出现解决了“大堆老年代Full GC停顿过久”的痛点,四个核心阶段里只有初始标记和重新标记需要STW,而且这两个阶段停顿都很短。
代价也很明显:它用的是标记-清除算法,不整理内存,运行久了碎片化严重;并发阶段业务线程还在产生新垃圾,这部分“浮动垃圾”只能放到下一次收集处理;更麻烦的是,并发清除时如果老年代空间又不够了,它会直接退化到Serial Old做Full GC,这一下停顿可能是几十秒级的事故。
CMS在JDK 9被标记废弃,JDK 14被正式移除。但不要因为它过时就不学,它的并发标记思路和问题模型,就是理解三色标记算法最好的案例。
2.3 G1:把堆切成棋盘,用Region换可控停顿
G1没有再沿用“整个新生代/老年代连续”的物理划分,而是把堆分成一个个大小相等的Region(默认大约2048个)。逻辑上新生代、老年代只是一组Region的集合,哪个Region当前扮演什么角色,可以动态调整。
这种设计让收集有了“局部性”:每次GC不必全堆处理,只需要挑价值最高的Region集合回收。G1还引入了可预测停顿模型,通过-XX:MaxGCPauseMillis来约束停顿时间,这也是它在JDK 9之后成为默认收集器的关键原因。G1的完整回收周期包含年轻代GC、并发标记、混合回收等阶段,其中并发标记阶段就跟三色标记算法强相关。
2.4 Shenandoah与ZGC:大堆低延迟的新解法
ZGC用染色指针和读屏障实现了接近零停顿的并发收集,理论上能处理TB级堆;Shenandoah则是通过连接矩阵减少全局扫描。这两个新收集器的核心思想依然是并发标记,只不过把标记信息的载体和引用维护方式做得更极致。
选型这事真的看场景,没有谁绝对好。我列个表,你在脑子里当速查卡用:
| 收集器 | 工作范围 | 算法核心 | STW表现 | 典型场景 |
|---|---|---|---|---|
| Serial | 新生代+老年代 | 复制+整理 | 全阶段 | 客户端、小堆 |
| ParNew | 新生代 | 复制多线程 | 全阶段 | 配合CMS时代 |
| Parallel | 新生代+老年代 | 复制+整理 | 较久 | 吞吐量优先 |
| CMS | 老年代 | 标记-清除 | 少但碎片化 | 响应优先(已废弃) |
| G1 | 全堆Region | 复制+整理+并发标记 | 可控 | 大堆响应优先 |
| ZGC | 全堆 | 染色指针 | 极短 | 超大堆低延迟 |
3. 三色标记算法拆解:并发标记的理论地基
3.1 白、灰、黑三色到底在表达什么
三色标记算法把可达性分析里每个对象的状态抽象成三种颜色:
白色代表对象还没被访问到,在标记结束前它都是“候选垃圾”;灰色代表对象已经被访问到了,但它引用的对象还没全部处理完,也就是说它是一个“进行中”的对象;黑色代表对象本身和它引用的对象都已经被处理完了,业务线程访问它可以放心。
你可以脑补一个打扫房间的场景:白色是还没扫到的房间,灰色是正在打扫、但抽屉还没打开的房间,黑色是已经全部擦干净锁好的房间。三色模型其实是一个抽象框架,实际实现里不一定真在对象上涂颜色,而是用标记位、位图等方式表示状态,但逻辑完全一致。
3.2 一次完整标记流程:从全白走到全黑
假设堆里现在有对象A、B、C、D,其中A是根可直达的。标记开始前,所有对象都标记为白色。第一步,从GC Roots扫描根引用,把根直接引用的A标记成灰色。第二步,取出灰色对象A,扫描A的所有引用字段,把被引用的B、C标记成灰色,然后A变成黑色。第三步,继续处理灰色对象B和C,如果它们引用了D,就把D标记成灰色,B和C自己变黑。循环往复,直到灰色的队列清空,标记结束。此时还留在白色集合里的对象,就是垃圾,可以被回收。
这个流程在STW环境里没有任何问题,因为标记期间业务线程不改变引用。但垃圾收集器一旦追求“并发”,也就是业务线程一边跑一边标记,麻烦就来了。
3.3 并发标记的致命问题:漏标是怎么发生的
并发标记时,业务线程随时可能改动对象引用关系。最危险的情况是:一个对象明明是活的,却因为引用关系变化,没有被标记成黑色,最终被当成白色垃圾回收掉,这在生产环境是灾难性的,等于Java程序“活马当死马医”,直接丢了数据。
具体触发生并发漏标需要同时满足两个条件。第一,某个黑色对象新增了一条指向白色对象的引用;第二,原来能到达该白色对象的灰色对象,恰好在这个时刻断开了与它的引用。两个条件同时成立,这个白色对象就彻底“失联”了——根到它的路径断了,唯一可能发现它的黑色对象又已经扫描完了。
我用一个例子说明:根能到达A和B,A是黑色,B是灰色,C是白色且当前只有B引用着它。这时候业务线程做两件事:把C也赋值给A的某个字段(条件一成立),同时把B里的C引用清除(条件二成立)。标记线程已经不会再扫描A了,所以它不知道A现在指向C;而B的引用清除动作又没有被标记线程“看见”,于是C活生生被当成垃圾收集掉了。
3.4 两条补救路线:增量更新与SATB
解决漏标,核心思路就是破坏上面两个条件中的至少一个,业界给出的两个经典方案:
增量更新的做法是:黑色对象新增引用时,通过写屏障把这个“新增引用”记录下来,重新标记阶段把曾经的黑对象变成灰色再扫描一遍。这个思路的逻辑是“我盯着新增引用,你多引了我就重新查”,CMS走的就是这条路线。
SATB(Snapshot At The Beginning)的做法是:当灰色对象要删除某个白色引用时,把它记录下来。因为记录的是堆“开始时”的引用快照,即使后来引用被删了,最终标记阶段还是会按快照把那个白色对象重新标记为灰色。G1走的是这条路线,它的潜台词是“我给你建立一份快照,后面引用怎么变我不管,但我保证快照里的对象一律不丢”。
这两条路都不完美:增量更新在重新标记时可能多扫描许多对象,SATB则可能保留下来一些“并发期间已经死掉的对象”,造成浮动垃圾,只能等下次收集。但两害相权,宁可多留垃圾,也绝不漏标活对象。
4. CMS与G1如何把并发标记落到实处
4.1 CMS的标记-清除流程逐段拆解
CMS给老年代做回收时分了四个阶段。第一阶段初始标记,STW时间极短,只标记GC Roots能直接引用的对象,相当于先圈出“起点集合”。第二阶段并发标记,与业务线程同时运行,从起点集合出发,按照三色标记的流程去遍历整个老年代,这一步最耗时间。第三阶段重新标记,再次STW,处理并发标记期间业务线程修改引用造成的变动,CMS在这里就用增量更新把新增的引用对象重新挂上灰度队列。第四阶段并发清除,把标记好的垃圾真正清掉,同时尽量保持业务线程可用。
实操里有个参数很值得提:CMSScavengeBeforeRemark。重新标记前先触发一次Young GC,把年轻代对象尽量清一遍,减少重新标记阶段要扫描的年轻代引用,能明显缩短停顿。另外CMSInitiatingOccupancyFraction可以控制老年代占用多少时启动CMS,设置太低会频繁GC,太高又容易并发失败,这是一个需要压测找平衡的点。
4.2 G1的Region化与快照采集细节
G1的并发标记周期分几步走。初始标记会伴随着一次Young GC的完成,顺便把根集合抄下来。根区域扫描阶段分析这些根能引用到的Region。并发标记阶段就是轰轰烈烈的三色遍历了,区别在于G1把堆看成Region集合,要走全堆的Region,但可以用多个并发线程分区推进。最终标记阶段STW,处理掉SATB队列里积压的数据,把需要重新标记的对象变灰。最后是清理阶段,统计各个Region的存活率,决定下一次混合回收优先回收哪些高收益Region。
SATB在G1里的实现细节很有意思:每个Java线程都有自己的SATB Buffer,写屏障发现引用要被删除时,把这个引用记录进Buffer;并发标记线程会不定期把这些Buffer合并处理。这种“先记后算”的设计避免了在每个写操作上都抢全局锁,是G1并发性能的关键。
4.3 关键JVM参数与Tomcat启动配置示例
收集器选型和参数设置在线上一向是敏感操作,我建议你按“先看默认、再小步调、最后固化”的顺序来。比如你现在用JDK 11,默认G1,想控制停顿,核心先看这几个参数:
- -XX:+UseG1GC:显式启用G1
- -XX:MaxGCPauseMillis=100:目标最大停顿毫秒数
- -XX:InitiatingHeapOccupancyPercent=45:堆占用达到45%开始并发标记周期
- -XX:G1HeapRegionSize=16m:Region大小,影响大对象区判定
- -XX:ConcGCThreads与-XX:ParallelGCThreads:并发标记线程数和并行GC线程数
Tomcat启动时设置JVM参数,最常改的就是CATALINA_OPTS。我以JDK 11 + G1为例,给一个保守但稳的模板:
export CATALINA_OPTS=" -Xms4g -Xmx4g -XX:+UseG1GC -XX:MaxGCPauseMillis=100 -XX:InitiatingHeapOccupancyPercent=45 -Xlog:gc*:file=/data/logs/gc.log:time,uptime,level,tags "这里有几个细节容易被忽略。-Xms和-Xmx为什么要相等?为了避免堆在运行中途反复扩容缩容,这种抖动会带来额外的STW;MaxGCPauseMillis是个软目标,不是硬保证;G1HeapRegionSize不要手动乱设,默认2MB到32MB自适应一般够用,除非你确认有大对象问题。
5. 实战排查:GC日志与线上事故速查
5.1 先把现场拍下来:GC日志怎么看
排查GC问题第一步不是改参数,而是看日志。JDK 8以前是-XX:+PrintGCDetails + -XX:+PrintGCDateStamps + -XX:+PrintHeapAtGC,JDK 9开始统一成统一的日志框架,用-Xlog:gc*就能把所有GC相关事件导出来。
拿到日志后先看三个关键指标:停顿类型是Young GC还是Full GC、停顿耗时是多少(尤其看real这一列)、每个区间的用量变化。比如日志里出现“[Full GC ... [Metaspace: ...]”,你第一反应应该是查元空间是不是快满了,而不是急着调堆大小。先定位是哪块区域引发的,再动参数,这条原则救过我很多次。
5.2 三个让我印象深刻的线上事故
事故一:CMS并发失败。某业务高峰期,日志里出现“concurrent mode failure”,紧接着是几十秒的Serial Old Full GC,接口超时一片。根因是老年代碎片化严重,浮动垃圾又多,CMS清理速度跟不上分配速度。处理方案是让-Xms和-Xmx相等,适当把CMSInitiatingOccupancyFraction从默认调低,给CMS留出更早启动的余量。如果在JDK 8时代,更省心的做法是直接迁移到G1。
事故二:G1频繁Full GC。另一套系统用G1,堆并不大,却周期性Full GC。看日志和堆转储发现,业务里有一个巨型缓存数组,动辄几十MB,被当成了Humongous对象直接放进大对象Region。大对象Region一旦分配频繁,会拖垮整个GC。解决方案有两个层面:代码层面拆分缓存结构,配置层面用-XX:G1HeapRegionSize把Region调大,让大对象不再是“特大”。这个案例也说明,收集器本身没有锅,对象结构不合理才是根源。
事故三:非堆问题伪装成堆问题。某次报警显示Full GC频繁,大家先调堆,没用。最后看日志发现频繁退化发生在Metaspace扩容时,原因是动态生成类太多,元空间默认值被反复越界。只需要调大-XX:MetaspaceSize和-XX:MaxMetaspaceSize问题就消失了。
5.3 面试高频追问与选型速查表
平时面试新人和被面试,关于GC的问题来来去去其实就那几个高频点,我整理成一个速查表,看一遍哪怕不深入也能答出框架:
| 问题 | 一句话答案要点 |
|---|---|
| 什么对象会被回收 | 不可达于GC Roots的对象 |
| 三色标记为什么能并行 | 白灰黑三态让标记状态可增量推进 |
| 并发标记为什么漏标 | 黑色新增指向白色引用且灰色断开引用,同时发生 |
| 增量更新与SATB区别 | 前者盯新增引用,后者盯删除引用快照 |
| CMS为什么废弃 | 碎片化、浮动垃圾、并发失败退化 |
| G1如何控制停顿 | Region局部回收 + 暂停预测模型 |
| STW与安全点 | 线程必须在安全点才能暂停,循环、方法调用都会安插安全点 |
这几个问题,每个都能再往深挖一层,但核心能够串起来,说明你对垃圾收集器的理解已经不是一个一个背知识点的状态了。
6. 写在最后:调GC的几条实在体会
摸爬滚打这些年,我对GC调优最大的体会是:调参数只是最后的10%,前面90%是对业务的测量和理解。你连对象分配速率、存活率、大对象分布都搞不清楚,改任何一个参数都是瞎蒙。而且别迷信“网上大神的参数模板”,每套系统的对象生命周期、请求模型差别太大,别人调出来的值很可能在你的业务里反而更糟。
还有一个很多人踩的坑:调优前不设监控、不记录基线。我建议任何一次GC参数变更,都必须配合三样东西:GC日志落地文件、堆转储预案、压测对比数据。调完一轮后,用同样的压测场景跑一边,对比停顿P99和吞吐量,而不是看一眼Full GC次数少了就以为成功。
最后分享一个我个人的小习惯:新项目直接用最新稳定版JDK的默认收集器,先把-Xms和-Xmx设置合理,MaxGCPauseMillis只设一个你觉得能接受的软目标,其他参数一律不动,上线后让GC日志告诉你哪里有毛病,再逐项去调。这套做法比一上来就堆十几个“高级参数”要稳妥得多,也是我现在一直在用的工作流。