在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时所有工作线程都要暂停也就是著名的STWStop 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和BA是黑色B是灰色C是白色且当前只有B引用着它。这时候业务线程做两件事把C也赋值给A的某个字段条件一成立同时把B里的C引用清除条件二成立。标记线程已经不会再扫描A了所以它不知道A现在指向C而B的引用清除动作又没有被标记线程“看见”于是C活生生被当成垃圾收集掉了。3.4 两条补救路线增量更新与SATB解决漏标核心思路就是破坏上面两个条件中的至少一个业界给出的两个经典方案增量更新的做法是黑色对象新增引用时通过写屏障把这个“新增引用”记录下来重新标记阶段把曾经的黑对象变成灰色再扫描一遍。这个思路的逻辑是“我盯着新增引用你多引了我就重新查”CMS走的就是这条路线。SATBSnapshot 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:MaxGCPauseMillis100目标最大停顿毫秒数-XX:InitiatingHeapOccupancyPercent45堆占用达到45%开始并发标记周期-XX:G1HeapRegionSize16mRegion大小影响大对象区判定-XX:ConcGCThreads与-XX:ParallelGCThreads并发标记线程数和并行GC线程数Tomcat启动时设置JVM参数最常改的就是CATALINA_OPTS。我以JDK 11 G1为例给一个保守但稳的模板export CATALINA_OPTS -Xms4g -Xmx4g -XX:UseG1GC -XX:MaxGCPauseMillis100 -XX:InitiatingHeapOccupancyPercent45 -Xlog:gc*:file/data/logs/gc.log:time,uptime,level,tags 这里有几个细节容易被忽略。-Xms和-Xmx为什么要相等为了避免堆在运行中途反复扩容缩容这种抖动会带来额外的STWMaxGCPauseMillis是个软目标不是硬保证G1HeapRegionSize不要手动乱设默认2MB到32MB自适应一般够用除非你确认有大对象问题。5. 实战排查GC日志与线上事故速查5.1 先把现场拍下来GC日志怎么看排查GC问题第一步不是改参数而是看日志。JDK 8以前是-XX:PrintGCDetails -XX:PrintGCDateStamps -XX:PrintHeapAtGCJDK 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日志告诉你哪里有毛病再逐项去调。这套做法比一上来就堆十几个“高级参数”要稳妥得多也是我现在一直在用的工作流。