尧图精选

JVM调优核心:ParNew与CMS原理、协作与避坑指南

🕒 发布时间:2026/10/1 4:48:24 📁 来源:尧图网络
1. 垃圾回收器选型为什么ParNew与CMS曾是最佳拍档JVM调优面试里ParNew和CMS属于被问到烂但大多数人只背结论的组合。我早期也是把参数抄进启动脚本就算完事直到线上一次Full GC导致接口超时报警才被迫把这两兄弟的运行机制真正搞明白。ParNew负责年轻代的垃圾回收CMS负责老年代的垃圾回收两者以并行和低延迟为核心卖点在JDK 8时代是响应式应用的主流组合。理解它们为什么必须搭配使用关键要看清楚JVM堆内存的分代设计新对象先进年轻代用复制算法清理朝生夕死的垃圾存活够久的对象晋升到老年代用空间占比更大的堆区域做长期驻留。年轻代垃圾回收需要Stop The World也就是暂停所有业务线程ParNew能做的只是让暂停期间的多核CPU并行干活缩短停顿时长。老年代的CMS则另辟蹊径用大部分阶段并发执行的方式把停顿压缩到最小。但CMS有个与生俱来的伤疤——它依赖ParNew在年轻代的清理结果老年代对象引用变化要先记录在卡表里ParNew在年轻代移动对象时必须扫描老年代对这些对象的引用。这一设计决定了ParNewCMS是绑定关系换成Serial或者Parallel在年轻代CMS就失去引用修正的协作前提。参数上经典配置是-XX:UseConcMarkSweepGC它内部自动激活ParNew作为年轻代回收器所以其实你不需要显式写-XX:UseParNewGC。但有几个参数值得你手动调-XX:ParallelGCThreads控制ParNew的并行线程数-XX:CMSInitiatingOccupancyFraction控制CMS的触发阈值-XX:UseCMSCompactAtFullCollection控制Full GC后的碎片整理。下面你会看到这几个参数之间暗藏一套联动的三角关系配不好反而会诱发更长的停顿。我一直建议团队在JDK 8项目里保留这套组合至少一年以上核心原因有三一是CMS经过大量生产环境验证坑基本被踩平二是ParNew的年轻代清理效率在中等对象分配速率下非常稳定三是几乎任何监控工具都能一眼看懂CMS的GC日志。除非你的业务对象分配速率极高、老年代增长极快或者堆内存超过32GB否则这套组合依然是响应延迟敏感型服务的可靠选择。2. 底层算法三色标记CMS并发标记的理论基石CMS能并发回收老年代核心功臣是它的并发标记阶段采用的三色标记算法。你只有透彻理解这个算法才能解释CMS的几个经典问题为什么并发标记后还要重新标记为什么CMS会有浮动垃圾为什么Full GC期间会伴随长时间的碎片整理三色标记算法把垃圾回收过程中每个对象都涂上三种颜色含义很简单白色表示尚未被回收器访问过灰色表示已被回收器访问但它的引用字段还没全部扫描完黑色表示该对象及其所有引用字段都已被扫描完毕。初始状态下所有对象都是白色根对象GC Roots被标记为灰色。回收器从灰色对象出发依次扫描它的每一个引用字段引用的对象从白色变成灰色当前对象扫描完成之后从灰色变成黑色。当灰色队列为空时所有未被引用的白色对象就是垃圾可以回收。这个算法最大的优点是并发性标记过程不需要完全暂停业务线程回收器线程与业务线程可以同时运行。但并发也引入了致命风险——漏标。想象这样一个场景扫描器正在访问对象A它有一个引用指向对象CC目前是白色。业务线程此时把A对C的引用改成了指向对象B同时C被另一个对象D引用且D已经是黑色。等回收器扫描完A后C依然留在白色集合里但C其实还被D引用着结果C被当作垃圾回收这就会导致程序访问C时直接崩溃。CMS采用的解决方案是增量更新在并发标记过程中如果黑色对象新增了指向白色对象的引用就把这个黑色对象重新标记成灰色放入标记栈等待重新扫描。这正是CMS的重新标记阶段要做的事——STW暂停业务线程把记录下来的引用变更重新扫描一遍确保不会漏标。说到这里你可能理解了三色标记不是穿在CMS身上的外衣它就是CMS并发标记逻辑的完整骨架。从理论角度再看一遍为什么需要灰色这个中间态如果没有灰色回收器要么把所有存活对象一次性全标记要么把标记工作彻底停顿下来串行执行。灰色状态本质上是一个正在处理中的工作队列它让标记任务可以分成多个批次并发推进。这与你在项目管理里用待办-进行中-已完成三列看板管理任务是同一套逻辑只有把任务放在进行中这一列才能在团队协作时不漏项、不回退。灰色队列就是回收器的工作台白、灰、黑三色流转就是回收器的工作流。3. 从理论到参数ParNew与CMS的完整配合链路3.1 年轻代ParNew的复制清理机制ParNew用的是复制算法你只需要记住一句话把有存活对象的区域分为Eden和两块SurvivorS0、S1每次用完Eden和其中一块Survivor把存活对象复制到另一块空Survivor上然后整块清空。整个过程只需要移动指针不需要遍历所有对象再判断是否死亡所以效率极高。在ParNew执行过程中业务线程完全暂停但GC线程并行运行。-XX:ParallelGCThreads默认值在CPU核数较高时会采用8 (ncpus - 8) * 5 / 16的公式计算实际效果接近CPU核数。这里有一个很容易被忽略的关键操作ParNew在移动存活对象时需要同步修正老年代对年轻代对象的引用。老年代用一张卡表Card Table记录自己内部哪些区域存在指向年轻代的引用。ParNew扫描卡表并修正这些引用这个过程与三色标记算法的根对象枚举其实是紧密相关的——老年代中指向年轻代对象的区域可以被视为年轻代GC的隐式根如果漏掉修正年轻代对象移动后老年代引用还指向旧地址就会在后续访问时踩到错误内存。常见的一个误区是盲目调大Survivor空间。默认-XX:SurvivorRatio8意味着Eden占年轻代的80%。如果你把Survivor比例调成1:1确实能减少晋升到老年代的数量但ParNew的复制算法需要在两块Survivor之间来回倒腾空间大了扫描和复制的耗时也会上涨。更合理的思路是观察GC日志中晋升对象的体积动态调整晋升阈值-XX:MaxTenuringThreshold而不是直接动空间配比。3.2 CMS的并发回收五阶段与触发时机CMS对老年代的回收分为五个阶段初始标记STW、并发标记、并发预清理、重新标记STW、并发清除。其中初始标记和重新标记需要暂停业务线程其余阶段并发执行。整个设计的核心目标就是让最耗时的扫描阶段与业务线程并行只留下极短的两个STW窗口。初始标记只标记GC Roots的直接引用对象暂停时间极短之后进入并发标记从GC Roots出发用三色标记算法扫描整个对象图这个阶段最耗时但业务不停预清理阶段处理并发标记期间引用变更的记录重新标记阶段暂停业务线程把增量更新记录的变更对象重新扫描一遍最后的并发清除把未被标记的对象空间回收到空闲链表里供后续分配使用。CMS触发时机很讲究。默认老年代使用率达到92%时触发-XX:CMSInitiatingOccupancyFraction92但你可以手动调整。如果阈值设得太低CMS会频繁执行回收完没过多久又触发下一轮浪费CPU如果设得太高老年代空间趋于饱和时CMS还在并发清理业务线程分配对象时找不到足够连续空间触发并发模式失败退化到Serial Old做Full GC停顿秒级起步。3.3 两个回收器如何协作完成一次完整GC当你启动一个Java应用并配置ParNewCMS后一次常见的完整GC协作流程是这样的业务线程不停创建新对象Eden区被填满ParNew被触发。ParNew将Eden中存活对象复制到S0或S1年龄计数器加一。对象年龄超过晋升阈值或Survivor区放不下时对象晋升至老年代。随着晋升对象持续增多老年代使用率达到预设触发阈值CMS启动老年代回收流程。CMS初始标记定位GC Roots并发标记扫描全对象图中间记录增量更新重新标记修正最后并发清除垃圾空间。这套协作的重点在于ParNew的晋升频率直接影响CMS触发频率。如果你把晋升阈值调得太高Survivor里堆积大量年龄对象下一次ParNew就会因Survivor空间不足提前晋升——CMS触发的频率自然上升。最好的优化思路是保证ParNew回收后存活对象体积小于Survivor总量的60%左右这样晋升节奏就能平缓CMS触发间距也会拉长。我在生产环境还踩过另一个坑大对象分配。超过-XX:PretenureSizeThreshold的大对象直接进老年代如果大对象一个接一个分配老年代空间瞬间被撑满CMS刚跑完一轮又立刻触发下一轮。这会导致CPU被GC吞噬、业务RT飙升。排查时会发现GC日志中连续多次CMS间隔非常短。解决思路不是单纯调低触发阈值让CMS更激进——因为那只会让回收频率更高——而是检查代码里是否存在超大数组或大缓冲区的反复创建从根上减少大对象数量。4. 避坑实操CMS常见问题排查与参数调优记录4.1 Concurrent Mode Failure问题速查现象GC日志中出现Concurrent mode failure程序卡顿甚至出现长时间STW。成因CMS并发清除还没完成老年代空间就被业务线程耗尽JVM只能降级到Serial Old串行Full GC。排查思路先看CMS触发时老年代占用率日志确认-XX:CMSInitiatingOccupancyFraction是否设得过高再看晋升对象体积曲线确认是否存在Survivor空间不足导致的提前晋升最后检查大对象分配频率。调优实操我常用的调法是将阈值设到70-75前提是老年代增长速率相对平缓同时调大Survivor区配合-XX:MaxTenuringThreshold控制晋升节奏。但如果你的老年代增长速率本身很快调阈值只是延缓问题最终仍需把目标对象的生命周期梳理清楚从源头减少晋升量。4.2 CMS的浮动垃圾如何理解并发清除阶段业务线程还在运行理论上仍会产生新垃圾。这些在并发标记结束后才死亡的对象无法被本轮CMS回收只能留到下一轮。CMS允许老年代有一定闲置空间来容纳浮动垃圾这也是它不能把触发阈值设得太高的原因之一——超过了临界点浮动垃圾会把老年代塞满引发Concurrent Mode Failure。浮动垃圾量级很难精确估。我的经验是给老年代流出至少15%-20%的空余空间这也是我很少把CMSInitiatingOccupancyFraction调到80以上的原因。你还需要理解一点浮动垃圾是并发回收固有的代价只要业务线程还在分配对象就没有任何并发回收器能保证完全消除浮动垃圾。G1的并发标记后同样存在这个问题只是G1把堆切成Region允许部分Region回收集只收集垃圾占比高的区域从而弱化了浮东垃圾对整体GC的影响。4.3 CMS碎片化与Full GC的连续触发CMS使用自由链表管理空闲空间回收后碎片化程度会逐渐加重。当业务线程需要分配一个大对象时老年代虽有足够总空闲空间但找不到连续区块就会触发Full GC。更糟的是Full GC执行时CMS会尝试做碎片整理这个操作需要移动存活对象停顿极长。我在一个支付核心服务上遇到过连续三次Full GC每次停顿4秒以上的惨状。当时Monitor显示老年代空间总量充足但分配成功率只有70%多典型的碎片化症状。加-XX:UseCMSCompactAtFullCollection配合-XX:CMSFullGCsBeforeCompaction0可以强制Full GC执行碎片整理但也意味着每次Full GC都变慢所以这只是兜底手段。要想减少Full GC频率根本思路依然是控制晋升节奏、减少大对象分配、保证CMS能按计划触发。另外要提一下-XX:CMSMaxAbortablePrecleanTime这个参数在并发预清理阶段超时时会强制进入重新标记。如果预清理耗时长就会出现并发标记结束马上STW的现象。日志特征是在两个停顿之间有一段很长的并发执行期但你业务实际的GC停顿却比预期长很多——因为预清理期间的长停顿会被某些监控汇总进GC暂停时间里。4.4 并发标记阶段的CPU与线程开销CMS会对业务线程有实际资源消耗。并发标记阶段多线程扫描对象图吞吐量会下降约5%-10%。如果你的应用CPU已经打满CMS的并发阶段会挤占业务线程的CPU反过来导致业务更慢分配对象更快老年代增长更快形成恶性循环。解决方向不是把GC线程数降成1那样并发标记速度太慢而是给JVM所在的容器预留足够的CPU余量。线上的经验做法是把容器CPU配额设为峰值的1.5倍左右确保GC并发阶段有富余CPU可用。应用层加上线程池的拒绝策略与限流不让业务流量在GC高峰期叠加增压是更长效的治理方案。5. 是否应该转向G1ParNewCMS的今天与明天JDK 8之后CMS被标记为废弃JDK 14正式移除。很多团队面对G1有过疑虑G1到底比CMS强在哪它有什么用起来和CMS不同的地方如果你现在还在维护JDK 8项目且系统稳定可以继续沿用ParNewCMS但新项目我建议直接用G1或者干脆升级到更高JDK用ZGC。G1把堆划分为多个Region新生代和老年代不再是物理连续区域而是一组Region的动态集合。年轻代回收依然有STW但老年代回收不再依赖CMS那种全堆并发清除的模式而是通过追踪每个Region的回收价值优先回收垃圾最多的Region。G1同样使用三色标记算法的变体——SATBSnapshot At The Beginning它的增量更新策略与CMS略有不同G1在并发标记开始时为对象图拍一个快照之后发生的引用变更通过写屏障记录重新标记时处理这些变更保证标记结果至少与开始时的快照一致。如果你问现在选G1还是CMS我的经验有两句话对象分配速率低、老年代增长慢、堆内存8GB以下的中小服务CMS足够稳定不必强行迁移堆内存大、追求可预期延迟、GC频率高的服务G1通常能带来更好的表现。从实践角度看ParNewCMS这套组合依然是理解主流垃圾回收器的很好底座。理解了CMS的并发标记、浮动垃圾、碎片化问题再去看G1的SATB模型和Region回收策略会明显感觉到水到渠成——很多机制都是从CMS的历史问题出发打补丁演化出来的。比如G1的RSet设计本质就是卡表的增强版只是把记录老年代指向年轻代的引用扩展成记录任意Region之间跨区域引用。所以别觉得反正CMS要退场了就不用学了底层逻辑仍然贯穿几乎所有现代GC。6. GC日志分析从一行日志推断堆状态日志分析是排查GC问题的一项必修课。ParNew的日志长这样[GC (Allocation Failure) [ParNew: 174720K-2176K(196608K), 0.0134526 secs] 174720K-2176K(626688K), 0.0138456 secs]174720K-2176K(196608K)表示ParNew回收前后年轻代占用以及年轻代总容量括号外的174720K-2176K(626688K)是堆整体回收前后的占用和总容量最后是耗时。看到年轻代回收后占用远小于Survivor总量时说明晋升平缓如果回收后年轻代占用还是很高说明大量对象存活晋升风暴可能正在酝酿。CMS的日志重点看这几行[GC [CMS: 403202K-402118K(524288K), 0.5736612 secs] 403202K-402118K(6815744K), [Metaspace: ...]老年代回收前后从403202K变成402118K回收量相当有限基本可以判断这批对象全是浮动垃圾或者长期存活对象。真正健康的CMS回收应该能看到明显下降的数值比如403202K-200118K。如果连续几轮CMS清理量都很小就意味着老年代里塞满了实质上的长期存活对象CMS已经失去清理意义顶多就是在原地搬运碎片。日志里还有一个细节值得注意[CMS: 403202K-402118K(524288K), 0.5736612 secs]这个0.57秒看起来不长但CMS在这段时间是并发执行和业务线程并行的真正STW停顿要看日志里[GC [CMS: ...]前后是否出现[GC [ParNew: ...]。如果每次CMS前后都跟着强制ParNew停顿说明ParNew与CMS的某种竞争关系被触发老年代回收引发的卡片表修正让ParNew暂停变长。建议日常监控至少记录这几项指标GC次数、GC平均停顿、GC最大停顿、老年代使用率变化曲线、晋升速率。晋升速率可以通过(老年代回收前占用 - 上次回收后占用) / 时间间隔来估算它比单纯看GC频率更能反映业务对象分配结构的问题。如果你发现晋升速率在某次发布后翻倍先排查是不是缓存对象没有及时失效或者请求线程池增大导致ThreadLocal里挂了太多数据。7. 一些压箱底的经验关于ParNewCMS的调优心法先讲一个结论调优前没有监控数据任何参数建议都是猜。我先在测试环境持续压测两周观察不同流量下的GC日志、对象晋升速率和STW分布再上生产小流量验证,确认效果后放量。这比照抄任何博客参数都可靠。团队里一个经典案例是某个广告检索服务堆内存12GB老年代8GBCMS触发阈值默认92%平时运行平稳。某天推送活动上线GC日志开始频繁出现CMS活动峰值时老年代使用率冲到89%但CMS迟迟未触发结果在流量继续上涨的瞬间老年代达到92%CMS刚进入并发标记业务线程分配大对象失败触发Concurrent Mode Failure。最后采取的调整组合是-XX:CMSInitiatingOccupancyFraction75-XX:UseCMSInitiatingOccupancyOnly强制CMS只用设定阈值并且把-XX:MaxTenuringThreshold调到6减少长期存活对象在Survivor的来回倒腾。上线后活动期间没有再出现Concurrent Mode Failure。还有一个操作细节-XX:UseCMSInitiatingOccupancyOnly这个参数一定记得加。如果不加JVM会根据历史GC情况动态调整触发阈值你设的75可能被JVM自适应调高到80甚至85生产实际行为和你预期完全对不上。这个参数的作用就是告诉JVM别动态调整听我的。并发标记线程数-XX:ConcGCThreads默认约为(ParallelGCThreads 3) / 4如果服务器CPU核数多但不希望GC占用太多CPU可以手动调小如果老年代对象图很庞大且并发标记时间过长可以适度调大。我一般是把ConcGCThreads设成ParallelGCThreads的四分之一左右在此基础上再增减1-2个线程做验证。另外说一个容易被忽略但很实用的技巧让CMS的预处理阶段更短。-XX:CMSScavengeBeforeRemark会在重新标记之前主动触发一次ParNew把年轻代清干净这样重新标记阶段只需要处理老年代的引用变更速度会快很多。代价是多一次年轻代GC如果年轻代对象存活率不高这个参数非常值。我在日志对比中看到过开启后重新标记阶段停顿时间从500毫秒降到200毫秒整体收益大于多出的ParNew开销。最后提醒一个关于大页的问题有些团队为了降低TLB miss开启-XX:UseLargePages但CMS在某些环境下对大页支持并不好可能导致老年代分配性能下降。如果你开了大页同时GC日志有异常的老年代分配等待可以A/B对比关闭大页后观察。GC调优就是这样没有绝对最优配置能解释清楚每一步代价和收益已经是很大的进步。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →