1. 从一次线上事故说起:CMS垃圾回收器的定位和应用场景
上个月帮朋友排查一个订单服务的频繁卡顿问题,他用的JDK 8,JVM参数里明确写着-XX:+UseConcMarkSweepGC,用的就是CMS垃圾回收器。日志里每隔几分钟就提示一次Concurrent Mode Failure,紧接着就是几秒钟的Full GC停顿。他一脸无奈地说,自己完全是照着网上所谓的“最佳实践”配的参数,为什么还是被GC拖垮了?这个问题恰恰说明,很多人对CMS的理解只停留在“加上参数就低延迟”这一层,并没有真正弄懂并发标记清除背后的机制和代价。
先说明一下,这里聊的CMS是JVM里的Concurrent Mark Sweep,也就是“并发标记清除”垃圾回收器,不是内容管理系统。CMS在JDK 8及之前的老年代回收场景里,几乎代表了低延迟GC的巅峰。它要解决的核心问题非常明确:老年代空间快满时,怎么才能在不长时间冻结业务线程的前提下,把不再使用的对象清掉。ParallelOldGC虽然吞吐量高,但每次Full GC都要整个世界停顿,队列堆积、接口超时是家常便饭;CMS则把标记和清除的大部分工作放到后台线程跟业务线程一起跑,把STW(Stop The World)时间压缩到两次很短的暂停上。如果你的系统对接口响应时间敏感,比如交易、支付、实时推荐,CMS在当年就是最稳妥的低延迟方案。
这篇文章适合三类人:一是正在维护JDK 8存量应用、每天面对CMS日志却看不懂的人;二是准备从CMS迁移到G1或ZGC,想先把CMS原理彻底搞明白的开发者;三是刚接触JVM调优不久,想借助一个具体回收器把GC机制吃透的新人。看完你会明白,CMS为什么曾经那么流行,又为什么被JVM官方放弃,更重要的是,你会掌握一套排查线上GC问题的可行方法,而不是继续在网上抄一堆配置了事。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心机制拆解:并发标记清除四个阶段到底在做什么
2.1 CMS的工作范围:老年代专属回收器
要理解CMS,先要重新回到Java堆的分代模型。新生代里存放的是“短命对象”,比如一次请求里创建的临时对象;老年代里存放的是“长命对象”,比如缓存、单例、静态变量引用的对象,以及一次分配超过阈值的大对象。对象在新生代经历多次Minor GC之后依然存活,就会被晋升到老年代。CMS是一个只处理老年代的回收器,它不管新生代的事,所以生产环境里几乎总是把CMS和ParNew搭配使用——ParNew负责年轻代回收,CMS负责老年代回收。这就是为什么你会看到-XX:+UseConcMarkSweepGC和-XX:+UseParNewGC经常成对出现。
这里有个容易忽略但很重要的点:CMS在主流程之外,如果老年代实在没有空间承载新晋升对象,也会退化成传统的Full GC,实际上会触发Serial Old算法来兜底。这个“兜底逻辑”不是CMS的正常路径,而是算法本身碰到极端情况的保底措施。理解这一点,后面排查Concurrent Mode Failure心里就有谱了,你不是在跟CMS的主流程较劲,而是在跟它的兜底策略抢时间。
2.2 四阶段流程:两次STW还是三次STW
CMS一个完整的回收周期分成四个阶段:
- 初始标记(Initial Mark):这个阶段有STW,但停顿很短。它只标记从GC Roots直接可达的对象,以及老年代里被新生代对象引用的那些对象。因为只需要扫“第一层引用”,耗时一般在几毫秒到几十毫秒。
- 并发标记(Concurrent Mark):这个阶段没有STW,业务线程照常运行。CMS的GC线程从初始标记得到的根对象出发,顺着引用链把整个对象图遍历一遍,给所有还活着的对象打上标记。这个阶段是CMS四个阶段中最耗时的,但因为跟业务线程并发执行,用户几乎感知不到。
- 重新标记(Remark):这个阶段又会STW。为什么需要第二次暂停?因为并发标记期间业务线程一直在改引用关系:有些对象在并发标记启动时已经死掉了,但又被别的线程引用回来;有些对象在并发标记启动时是活的,实际运行中已经被释放。重新标记做的就是修正并发期间产生的新变化,确保最终标记结果是准确的。
- 并发清除(Concurrent Sweep):没有STW。GC线程把所有未被标记为存活的对象从堆中清掉,把空闲内存还给自由列表,然后结束这一个周期。
所以准确的表述是:CMS的一次完整周期里,只有初始标记和重新标记两个阶段需要暂停业务线程,并发标记和并发清除都是并行的。相比Serial Old那种“全堆扫描+全堆压缩”的粗暴方式,CMS把能拆分给后台线程的活全拆了出去,单次停顿自然短很多,这也是它名字里“并发”两个字的真正含义。
2.3 三色标记法:CMS为什么能在并发中不犯错
并发标记最害怕的就是“一边扫、一边改”,稍不留神就会把仍存活的对象当成垃圾清掉。CMS为了规避这个风险,采用三色标记法来管理对象状态。简单说,把遍历过程中的对象分成三类:白色代表还没访问过,灰色代表自己已被访问但它的引用还没处理完,黑色代表自己已被访问且所有引用都已经处理完。GC线程在并发标记时只处理灰色对象,已经变黑的不会再重复扫描。假如业务线程把一个新对象引用塞给一个黑色对象,而GC线程早就扫过这个黑色对象了,这个新对象就可能被漏标,最终被错误回收。
CMS应对漏标问题的补救措施是写屏障,在引用发生变化的时候把关键信息记录下来,留到重新标记阶段再处理。这种设计换来了并发能力,也付出了代价:写屏障本身有额外开销,重新标记阶段的耗时也可能因为并发期引用变化太多而变长。所以说CMS的“并发”其实是一场现实交易,拿一部分吞吐量换更低的延迟,值不值,取决于你的业务模型。
3. 实操与调优:CMS相关JVM参数配置心法
3.1 一套可直接套用的基础参数模板
先说一套我在多个项目里验证过的、适合JDK 8且堆大小在8GB以内的CMS参数模板。直接抄可以,但抄完一定要看下面的解释,参数背后的逻辑才是关键。
bash复制-Xms4g -Xmx4g
-Xmn1g
-XX:+UseConcMarkSweepGC
-XX:+UseParNewGC
-XX:CMSInitiatingOccupancyFraction=70
-XX:+UseCMSInitiatingOccupancyOnly
-XX:+CMSParallelRemarkEnabled
-XX:+CMSScavengeBeforeRemark
-XX:+UseCMSCompactAtFullCollection
-XX:CMSFullGCsBeforeCompaction=0
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:/data/logs/gc.log
-Xloggc参数在JDK 11之后写法有变化,这是JDK 8时代的标准写法。
逐个说明一下:
- Xms和Xmx设置成相同值,避免堆自动扩展带来的性能抖动。
- Xmn指定新生代大小为1GB。新生代太小会导致对象频繁晋升老年代,加快老年代触发CMS;新生代太大又不给老年代留空间,也会让CMS提前启动。需要根据对象分配速率调整。
- CMSInitiatingOccupancyFraction=70表示老年代使用率达到70%时启动CMS。
- UseCMSInitiatingOccupancyOnly必须配合上一个参数使用,不加这个JVM会依据历史数据修改触发阈值,你辛辛苦苦调出来的70%根本不会生效。
- CMSParallelRemarkEnabled让重新标记阶段多线程并行执行,缩短第二次STW。
- CMSScavengeBeforeRemark的含义是重新标记前强制触发一次Young GC,把新生代里大量短命对象提前清掉,这样重新标记时需要处理的对象就少很多,停顿时间自然下降。
- UseCMSCompactAtFullCollection加上CMSFullGCsBeforeCompaction=0,让Full GC时同时做老年代碎片整理,这是缓解碎片化问题的兜底手段。
这套参数不是万能药,但能覆盖绝大多数存量CMS应用的常见问题。如果你发现日志里仍然有较长的停顿,继续往下看。
3.2 CMS线程数、触发时机与预留空间的计算逻辑
CMS的并发线程数由ConcGCThreads控制,默认值大约是(3+ParallelGCThreads)/4。以一台四核机器为例,ParallelGCThreads默认可能只有2-4,那么CMS并发线程通常在1-2个;如果是32核的机器,并发线程数会自动涨上去。并发线程越多,并发标记阶段越快,但也会占用更多CPU,和业务线程抢资源。我建议在核数不超过8的机器上保持默认值,如果并发标记阶段频繁超过5秒,再考虑手动调高。
再看触发时机。CMS的启动依据是老年代占用率,也就是CMSInitiatingOccupancyFraction。假设老年代大小2GB、阈值70%,那么老年代占用到1.4GB左右,CMS就开始干活。为什么老年代不能满了再回收?因为并发标记和并发清除阶段,业务线程并没有停,新对象还在不断晋升到老年代。如果等到90%才启动,GC线程清着清着就可能发现空间不够,最终触发Concurrent Mode Failure,退化成长停顿的Full GC。我的经验是:老年代空间不足2GB时,阈值不要超过70%;老年代达到4GB以上,可以放宽到75%-80%,但一定要结合GC日志观察,不能拍脑袋定。
有朋友会问,阈值设置得低一点不是更安全吗?也不是。阈值太低,CMS回收频率变高,并发标记线程频繁占CPU,业务吞吐量反而不划算。所以在70%附近微调,找一个“不频繁触发、又不会等到内存耗尽”的平衡点,才是最优解。
3.3 三条最实用的CMS调优心得
第一,盯住“CMS周期总耗时”。这条信息可以从GC日志里看出来,如果一次CMS从初始化标记到并发清除完成经常超过5秒,说明并发标记压力太大,你需要考虑调高ConcGCThreads,或者干脆评估迁移G1。
第二,重点排查“promotion failed”。日志里出现这个关键词,说明新生代对象在晋升的时候,老年代找不到足够的连续空间。这是老年代碎片化的直接信号。CMS清除后留下的是一段段不连续空间,不是规整的连续块。解决方法通常是调大队列或者降低GC触发阈值,但碎片化严重时要靠压缩来收拾残局。
第三,不要把MaxGCPauseMillis错用在CMS上。这是G1的参数,设置它不会影响CMS行为。见过不少同事从G1调优文章里抄参数,最后发现CMS毫无反应,还怀疑是自己参数写错了。不同回收器有不同专属参数,CMS的核心调优点集中在触发阈值、并发线程数和压缩策略这三个方向,方向错了越调越乱。
4. 踩坑记录:并发模式失败与碎片化的排查实录
4.1 Concurrent Mode Failure到底怎么发生的
有一次线上服务高峰期连续报了两次Concurrent Mode Failure,我当时的做法是先把GC日志导出来用GCViewer打开。日志里看得非常清楚:CMS在并发清除阶段,业务线程继续分配对象,结果老年代空间不足,直接触发Full GC。你可以把这个过程想象成房间保洁工作做到一半,又不断有人往里面搬家具,最后连下脚的地方都没有了。
Concurrent Mode Failure的常见诱因有三类:第一,CMSInitiatingOccupancyFraction设得过高,导致CMS启动太晚;第二,老年代预留空间不足,业务高峰期晋升速率太快;第三,碎片化严重时,总空间看起来够用,但找不到连续空间承载大对象。我处理这类问题的顺序是:先降触发阈值到70%甚至更保守,观察一个业务周期;再适当增大老年代空间;最后才考虑堆压缩和优化。很多人一看到Concurrent Mode Failure就急着增大Xmx,如果不解决碎片化,堆内存再大也只是把问题往后推迟。
| 日志关键字 | 出现场景 | 典型原因 | 推荐处理 |
|---|---|---|---|
| Concurrent Mode Failure | 并发清除期间老年代空间耗尽 | 触发阈值过高、预留不足 | 降低CMSInitiatingOccupancyFraction,增大老年代 |
| promotion failed | 新生代晋升对象时无连续空间 | 老年代碎片化 | 开启Full GC压缩,调大老年代 |
| Full GC (System.gc()) | 代码显式触发或JVM兜底 | 显式调用、CMS退化 | 排查代码,调整GC策略 |
| CMS: Remark pause 过长 | 重新标记阶段停顿变长 | 新生代对象引用多、并发期间变更多 | 开启CMSScavengeBeforeRemark,调大新生代 |
4.2 老年代碎片化:一个容易被忽视的“隐形杀手”
CMS基于自由列表管理空间,它清除对象后留下的空间天然是零散的,不做压缩。短时间看起来老年代剩余空间足够,但真正要分配一个较大的对象时,却找不到一段连续地址。晋升失败、并发模式失败,很多都是由碎片化这个“隐形杀手”间接导致的。
我见过一个极端案例:一个服务堆内存8GB,老年代占用还不到60%,却频繁报promotion failed。后来分析发现,就是因为老年代空间被切成了一片片很小的碎片,大对象一进来就找不到连续空间。最有效的预防手段是让Full GC时同步做压缩,也就是UseCMSCompactAtFullCollection加上CMSFullGCsBeforeCompaction=0。注意,CMSFullGCsBeforeCompaction=0的意思是每次Full GC都压缩一次,不是压缩0次。JDK 8里这个参数默认就是0,大多数情况下你不用额外写,但如果你是从老版本JVM升级上来的,最好确认一下。
压缩会带来更长的停顿,所以在碎片化不明显的时候,没有必要频繁压缩。我的原则是:如果CMS回收周期一直很稳定,就让压缩频率保持在较低状态;一旦日志里出现promotion failed,就尽快把压缩频率调高,同时观察接口RT有没有大幅波动。碎片化和停顿之间永远有一个取舍,你需要替业务选一个能接受的平衡点。
4.3 如何通过GC日志快速定位CMS问题
排查CMS问题,GC日志是唯一的真相来源。建议在JDK 8下至少配置这两组参数:
bash复制-Xloggc:/data/logs/gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCApplicationStoppedTime
PrintGCApplicationStoppedTime会打印每一次STW的时长,初始标记、重新标记、Full GC都会记录,有了它你就能精确判断停顿是发生在CMS哪个阶段。拿到日志后,我一般按三步排查:
- 先按时间排序找Full GC事件,看出现频率和具体时刻。
- 再搜Concurrent Mode Failure和promotion failed这两个关键字,定位失败的具体场景。
- 最后把日志导入GCViewer或GCEasy,看CMS各阶段的时间分布,判断瓶颈是在并发标记还是在重新标记。
有一个低效做法是只统计GC行数,不看阶段分布,也不看业务时间点,结果排错排到天亮还在猜。可视化工具能一次性把几十MB日志里的规律展示出来,排查效率高一个量级。
5. 告别了吗?CMS的现状与迁移建议
5.1 CMS为什么在JDK 9被标记废弃、JDK 14被正式移除
一部分人觉得CMS被废弃是因为性能不好,其实不完全是。CMS的问题更多出在工程复杂度和设计天花板。为了做到并发标记,CMS引入了一堆复杂逻辑和分支策略,代码维护成本极高;同时,不压缩带来的碎片化问题像顽疾一样无法根治,始终要靠Full GC压缩来兜底,而压缩必然带来长停顿,这又跟低延迟的初衷互相矛盾。
随着硬件配置越来越高,堆内存动辄几十GB,CMS在超大堆上的表现越来越不稳定。G1从JDK 9起被指定为默认垃圾回收器,JDK 14直接把CMS代码移除,说明官方已经下定决心让位给新一代回收器。对存量应用来说,CMS并不会一夜消失,但新项目确实没必要再从CMS起步了。
5.2 从CMS迁移到G1,你需要提前做的准备
如果你正在维护CMS应用,并且考虑向G1迁移,我有几个实操层面的建议:
- 先升级JDK版本。G1在JDK 8里也能用,但还需要手动指定-XX:+UseG1GC,而且JDK 8里的G1属于早期版本,调优参数的表现跟JDK 11以后差别不小。最好在迁移的同时把JDK升到17或21,一步到位。
- 清理掉所有CMS专属参数。UseConcMarkSweepGC、CMSInitiatingOccupancyFraction、CMSParallelRemarkEnabled这些在G1下都没有意义,保留反而会误导后续排查。
- G1的核心参数就那么几个:UseG1GC、MaxGCPauseMillis、G1HeapRegionSize、ConcGCThreads。最常用的调节手段是设置目标停顿时间,可以把MaxGCPauseMillis先设为100-200ms,然后依靠GC日志观察回收节奏,慢慢修正。
- 上线前一定要做全链路压测。G1的混合回收周期比CMS复杂得多,对内存分配速率的响应方式也完全不同。直接线上切换,很容易出现“小流量时一切正常、高峰时段被打回原形”的尴尬局面。
5.3 哪些场景其实还可以继续用CMS
虽然CMS已经退出历史舞台,但存量系统里仍有大量JDK 8 + CMS组合长期稳定运行。如果项目堆内存不超过6GB、单次STW要求又没有极端苛刻、团队暂时没有精力做全面升级,那么把CMS参数调好,它依然能继续稳定服务。调优的第一原则永远是“不破坏稳定,再谈性能”,为了追求新回收器而仓促升级,反而可能引入新的不稳定因素。
我对CMS最深刻的体会,是一次金融项目大促前连续出现promotion failed。当时通过降低触发阈值、适当调小新生代、开启压缩,把问题稳定住了。那段时间我每天对着GC日志看,从几百MB的日志里一点点找规律,最后发现真正的问题出在一个定时任务:它一次性生成了大量存活对象,导致老年代晋升速率突然飙升。这次经历让我明白,GC调优从来不是单纯调JVM参数,而是把业务分配模式、对象生命周期和回收器行为放在一起整体观察。CMS教会我的这种全局视野,远比某一个参数数值更值钱。如果你也正在跟CMS的GC日志较劲,不妨先跳出参数本身,看看你服务的对象分配规律,答案往往就藏在业务代码里。
