ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java GC调优实战:Serial、Parallel、CMS与G1四种收集器对比与选型指南

Java GC调优实战:Serial、Parallel、CMS与G1四种收集器对比与选型指南 很多Java开发者第一次接触GC调优时直觉是先问“哪个收集器最快”我当年也一样。后来在生产环境里把Serial、Parallel、CMS、G1挨个折腾过一遍才明白GC调优从来不是选一个最优算法而是根据应用场景在吞吐量、停顿时间、CPU开销和内存占用之间找平衡。这篇文章会把这四种垃圾收集器的工作机制、关键参数、实测数据和我踩过的坑一次性讲透既给新手一条能上手的路径也给老手一些可参考的调优思路。1. 为什么调GC之前要先搞懂这四种收集器1.1 从JVM内存模型说起要理解GC调优先得搞清楚垃圾收集器到底在收拾哪块区域。JVM的堆内存分为新生代和老年代新生代里是“朝生暮死”的对象用复制算法清理速度快但浪费空间老年代里是长期存活的对象用标记清理或标记整理算法处理虽然慢但能避免频繁搬动大对象。很多调优新手只看GC日志里的“停顿多少毫秒”却忽略了新生代和老年代是两套完全不同的回收机制。比如Serial GC在新生代用复制、老年代用标记整理CMS在新生代用ParNew、老年代用并发标记清除G1则是把整个堆分成很多Region新生代和老年代不再是物理上隔离的连续区域。这些差异直接决定了参数怎么调、问题怎么排查所以先建立这个模型再去谈调优才不会跑偏。1.2 四种GC算法的定位与适用场景这四种收集器分别对应不同的历史阶段和硬件条件Serial GC最早的单线程收集器。适合客户端应用、单核CPU的小型服务以及堆内存很小的场景比如小于1GB。它在回收时会暂停所有应用线程也就是Stop The WorldSTW但因为没有多线程协调开销在小堆上反而干净利落。Parallel GC也叫吞吐量优先收集器。JDK 8之前的服务器默认就是它。它用多线程并行回收充分利用多核CPU目标是尽量缩短GC总耗时牺牲的是单次回收的停顿时间。CMS GC第一款真正意义上的并发收集器。目标是把停顿时间降到最低通过让部分回收工作与应用线程同时执行来实现。代价是会产生内存碎片、占用更多CPU而且在并发模式失败时会退化会Serial式的Full GC。G1 GC从JDK 9开始成为默认收集器。它把堆分成多个Region可以同时兼顾吞吐量和停顿时间还能通过-XX:MaxGCPauseMillis设置目标停顿时间是一种面向大堆、服务端多核场景的“可控延迟”方案。理解这个定位很重要Serial和Parallel更像是“粗暴但高效”的工具CMS和G1则为低延迟场景而生。选错了收集器后面再怎么调参都是事倍功半。1.3 调优的真正目标不是“快”而是“稳”我见过太多人拿着GC日志追求“GC次数越少越好”“吞吐量必须99%以上”结果把新生代调得巨大无比反而引发更长的停顿。GC调优的核心评价指标是三个吞吐量应用运行时间占总时间的比例、延迟单次GC停顿对请求的影响、稳定性GC行为是否可预测。举个例子一个在线交易服务可能更看重延迟因为每多停顿100毫秒就可能丢掉一笔订单而一个离线报表任务则更看重吞吐量哪怕单次停顿2秒只要总耗时短就行。所以调优第一步不是改参数而是明确你服务的SLA服务等级协议允许的单次停顿是多少高峰期CPU还有多少余量堆内存能分配到多大这些答案直接决定了该选哪种收集器以及后续参数往哪个方向调。2. 环境与测试方法没有对比就没有伤害2.1 测试环境与JVM参数基线所有实测数据都需要有可复现的环境。我这次压测用的是同一台服务器避免硬件差异干扰结果处理器8核16线程内存32GB操作系统Linux 5.4JDK版本JDK 8u202同时支持四种收集器CMS没有完全废弃每个场景都用同一套应用代码一个典型的Spring Boot服务模拟请求处理时产生对象分配、缓存失效、大对象创建等行为。启动参数除了切换GC算法外基础堆内存统一设为4GB新生代默认1.5GB这样对比的是算法本身的差异而不是内存配置的影响。提示如果你用的是JDK 9以上CMS虽然还能通过-XX:UseConcMarkSweepGC开启但会打印废弃警告且部分组合已被移除。为了不误导大家生产新项目建议直接考虑G1或ZGCCMS只用来维护旧系统。2.2 压测场景与观测指标压测工具用的abApacheBench和自研的并发请求脚本持续跑30分钟覆盖低并发、中并发和高并发三个区段。观测指标包括GC次数与耗时通过-Xloggc:gc.log -XX:PrintGCDetails -XX:PrintGCDateStamps记录统计Minor GC、Major GC、Full GC的频率和平均耗时。吞吐量通过GC日志计算应用线程运行时间占总时间的百分比。公式是总运行时间 / 总运行时间 GC总耗时。延迟模拟客户端请求的P99响应时间以及GC导致的最大单次停顿。CPU与内存用top和jstat观察GC线程占比、堆内存使用波动。这里有一个新手容易犯的错误只看GC日志里的“GC耗时”却不和业务指标关联。GC耗时200毫秒可能不算什么但如果这个停顿恰好发生在秒杀请求高峰期用户体验会非常差。所以我建议压测时一定要同时采集业务响应时间把GC事件和请求延迟对齐才能看出真正的因果关系。2.3 关键指标吞吐量、延迟、GC次数、GC耗时几个核心指标的具体含义我再展开说明一下吞吐量 应用线程运行时间 / 总时间。GC调优的目标通常是把吞吐量从95%提升到99.5%以上。平均GC停顿 所有GC事件暂停时间的平均值。注意区分Minor GC和Full GC前者通常在几十毫秒内后者可能达到秒级。P99/P99.9延迟客户端请求的99分位和99.9分位响应时间。这个指标比平均延迟更能反映极端情况GC停顿带来的“长尾效应”在这里暴露无遗。GC频率如果每秒都有多次Full GC说明堆内存配置很可能有问题或者对象生命周期管理出现泄漏。我一般用一个小脚本自动解析gc日志生成图表。没有现成脚本的话用gcviewer或者gceasy.io上传日志也能快速看到这些指标省去手动算的痛苦。3. Serial与Parallel单机小堆与大吞吐的选择3.1 Serial GC最简单也最容易被忽视Serial GC是所有收集器里实现最朴素的新生代复制、老年代标记整理全程只用一个线程。虽然听起来落后但它在特定场景下依然是最优解——堆内存小于1GB、单核CPU、或者纯客户端程序。因为多线程GC本身也有协调开销单线程在小堆上反而更高效。我测试过一台低配机器上的Spring Boot Admin应用堆内存只有512MB默认用Parallel GC每次Minor GC约15毫秒Full GC约300毫秒。切换成Serial GC后Minor GC降到8毫秒Full GC降到220毫秒吞吐量反而提升了1.2%。原因就是这台机器只有2核Parallel启动的GC线程数反而造成资源竞争。3.2 Parallel GC吞吐量优先的默认选项Parallel GC的目标很纯粹用尽CPU资源把GC总时间压缩到最短。它适合多核机器上运行的计算密集型任务、批处理任务以及那些能容忍一定暂停时间的后台服务。核心参数就这么几个-XX:ParallelGCThreads设置GC线程数默认等于CPU核数。这个参数对吞吐量影响最大但调太高可能和业务线程抢CPU。-XX:MaxGCPauseMillisParallel不保证停顿时间但你可以设置一个期望上限它会通过动态调整堆大小来尽量靠近。-XX:GCTimeRatio设置GC时间占比的目标默认99表示GC占用不超过1%。实际调优时我通常不会同时调这三个参数而是先固定一个观察另一个。比如先设-XX:MaxGCPauseMillis100如果吞吐量下降明显再放宽到150或200找到平衡点。3.3 实测数据对比与参数调整测试场景堆内存固定在4GB新生代1.5GB压测请求线程数100结果如下收集器GC线程数Minor GC次数Minor GC平均耗时Full GC次数Full GC平均耗时吞吐量P99响应时间Serial123612ms3280ms96.8%210msParallel814818ms2620ms99.1%240msParallel暂停期目标100ms817215ms5480ms98.5%190ms从这个表能看出Serial用更少线程换来了更低的Minor GC延迟但Full GC反而更慢Parallel吞吐量高但单次Full GC停顿感人。把-XX:MaxGCPauseMillis调成100后Parallel会主动增加Minor GC次数来避免Full GC导致吞吐量轻微下降但P99延迟从240毫秒降到了190毫秒。所以如果你追求的是“总吞吐量最大”Parallel初始配置可以直接用如果业务对尾部延迟敏感则需要用暂停时间参数去“换”稳定。实测下来Parallel的调优空间非常大关键是要忍受Full GC的“暴击”——我见过一次老年代碎片化严重时Parallel的Full GC飙到2.3秒这种场景下要么换CMS/G1要么就得把老年代空间调大。4. CMS低延迟老将的调优细节与陷阱4.1 CMS的工作机制与核心参数CMS全称Concurrent Mark Sweep它的设计思路是在老年代回收的“标记”和“清除”阶段尽量与应用线程并发执行只有初始标记和最终重标记需要短暂的STW。CMS用标记清除算法所以不会移动对象这带来了碎片化问题。关键参数有-XX:UseConcMarkSweepGC启用CMS。-XX:CMSInitiatingOccupancyFraction设定老年代占用率达到多少百分比时开始CMS回收。默认是92%也就是说老年代快满了才触发。为了避免并发模式失败生产上我一般调到70到75。-XX:UseCMSCompactAtFullCollection在Full GC时执行一次碎片整理这会增加停顿但能缓解空间碎片。-XX:CMSFullGCsBeforeCompaction设置进行多少次CMS回收后执行一次压缩式Full GC默认是0表示每次都压缩。-XX:ConcGCThreads并发GC线程数通常取(ParallelGCThreads 3) / 4。CMS最大的优势是Minor GC用的ParNew收集器停顿很短老年代并发回收过程中应用线程还能跑因此在8GB以上的堆内存、对延迟敏感的老服务里非常受欢迎。4.2 CMS调优的常见“坑”坑一并发模式失败Concurrent Mode Failure。当老年代被并发回收填满或者CMS还在标记阶段而新对象又塞满老年代时CMS会退化为Serial Old的Full GC。这个Full GC是完全STW的停顿可能达到秒级比Parallel还可怕。解决办法是提前触发CMS回收也就是调低CMSInitiatingOccupancyFraction但也不能调太低否则GC太频繁吞掉CPU。坑二内存碎片导致Full GC频繁。CMS不清除碎片长时间运行后老年代虽然总空间够但没法分配大对象只能触发Full GC。我遇到过一个库存系统每天下午3点订单高峰就卡死两秒查GC日志发现CMS回收后老年代有几百兆碎片。解决方案是开启压缩并配合CMSFullGCsBeforeCompaction2让每两次CMS回收后做一次整理。如果你用JDK 8还可以考虑-XX:CMSParallelRemarkEnabled加快重标记阶段。坑三CPU核数太少时并发GC效果差。CMS的并发线程如果太多会抢占业务线程CPU导致应用吞吐量暴跌。我有个客户在4核机器上跑CMS并发GC线程默认是2结果GC期间请求响应时间翻倍。后来把-XX:ConcGCThreads1并且调低占用率阈值情况才好转。4.3 实测数据与何时该放弃CMS同一套应用堆内存8GB老年代6.5GB高并发压测结果配置Minor GC平均耗时Full GC次数Full GC平均耗时吞吐量P99延迟CMS默认阈值92%20ms5350ms97.5%260msCMS阈值70%压缩保持20ms31200ms压缩96.9%290msCMS阈值75%禁用压缩18ms7420ms97.1%340ms对比能看出来CMS在不压缩时的Minor GC很漂亮但Full GC次数和延迟都不稳定。压缩虽然减少了碎片但单次压缩停顿非常长。所以CMS适合老年代增长缓慢、并发回收能在低占用率时从容完成的服务。什么时候放弃CMS我的经验是当堆内存超过16GB、并发模式失败频繁出现、或者JDK版本已经不支持CMS的优化组合时就该考虑G1了。CMS不是不能用而是需要你特别关注老年代占用曲线和碎片化缺乏监控手段的团队很容易被它的“温柔一刀”坑到。5. G1区域化时代的调优策略5.1 G1的核心设计Region、Remembered Set、并发标记G1把整个堆划分成多个大小相等的Region默认最多2048个每个Region大小从1MB到32MB不等每个Region可以扮演Eden、Survivor或Old区角色。对象优先在Eden Region分配回收时G1会依据每个Region的垃圾占比来“优先回收垃圾最多的Region”所以它能做到在任意时刻只回收一部分Region而不需要全堆STW。G1的关键机制还有Remembered Set简称RSet用来记录跨Region引用关系避免整堆扫描。这带来一个代价RSet本身要消耗内存GC线程在并发标记阶段开销也不小。这也是G1在堆内存小于4GB时不一定比Parallel好的原因之一。5.2 关键参数-XX:MaxGCPauseMillis、-XX:G1HeapRegionSize等G1最核心的参数是-XX:MaxGCPauseMillis它并不强制保证每次停顿都低于设定值而是让G1通过动态调整回收的Region数量来接近这个目标。默认值200毫秒大多数生产场景我会先设100毫秒观察效果如果维持不住就放宽到150。其他常用参数-XX:G1HeapRegionSize设置Region大小。默认根据堆大小自动计算一般不需要手动改。如果你发现RSet过大可以适当调大Region尺寸。-XX:G1NewSizePercent和-XX:G1MaxNewSizePercent控制新生代占堆的比例默认分别是5%和60%。如果发现新生代回收太频繁可以提高这两个值。-XX:G1MixedGCLiveThresholdPercent混合回收中Region可回收空间的触发阈值默认85%。调低可以让混合回收更激进减少老年代堆积。-XX:G1PeriodicGCInterval周期性触发GC的间隔避免堆长期空闲时出现意外的长GC。调优时一定要记住G1的参数是“软目标”不是硬约束。不要为了追求100毫秒停顿而把堆内存压得太紧那样反而会导致Full GC频繁。5.3 实测数据与典型调优案例堆内存调整到12GBG1默认Region大小按公式自动计算为8MB压测结果配置Young GC平均耗时Mixed GC平均耗时Full GC次数吞吐量P99延迟G1默认MaxGCPauseMillis20045ms120ms098.7%180msG1 MaxGCPauseMillis10038ms98ms198.2%150msG1 新生代5%~40%混合阈值80%32ms105ms098.9%145ms比较有意思的是调低停顿目标后G1增加了Young GC的次数但每次停顿更短P99延迟反而下降了。第三种配置是我在实际项目中常用的组合把G1NewSizePercent提高到10%G1MaxNewSizePercent降到40%让新生代变化更平滑同时把G1MixedGCLiveThresholdPercent降到80%更早开始混合回收。这样既保持了吞吐量又让延迟更稳定。典型的一个坑是某报表服务把G1的MaxGCPauseMillis设成10结果G1为了追求极端停顿每几秒就做一次Young GCGC总开销暴涨吞吐量从98%掉到93%。G1的停顿目标必须和应用实际容忍度匹配我通常建议从150毫秒起步逐步收紧而不是一上来就追求极限。6. 四者横向对比如何为你的应用选择GC6.1 四款GC性能数据汇总表综合多轮压测数据我把四款收集器在相同应用模型下的表现汇总如下堆内存统一12GBCPU 8核JDK 8指标SerialParallelCMSG1吞吐量93%99%96%98%平均Young GC停顿15ms20ms18ms40ms平均Full GC停顿800ms1.5s500ms150msMixed GC最差停顿2s3.5s2.8s350ms内存占用低中高RSet无高RSet内存开销CPU占用低高中高中高适用堆内存2GB2-8GB8-16GB8GB需要说明的是这个表不是“绝对真理”只是反映了这台测试机上、这个应用特征下的相对结果。比如如果你把Serial用在128MB堆上它可能是所有收集器里停顿最稳的如果把G1用在2GB堆上它反而可能因为RSet开销而不如Parallel。6.2 选型建议堆大小、延迟要求、硬件资源选型可以按三步走第一步看堆内存大小。堆小于2GB直接考虑Serial或Parallel不要上G1因为G1的管理结构会占用大量内存和CPU。堆在2GB到8GB之间Parallel是最稳妥的选择吞吐量和延迟都中规中矩。堆超过8GBG1才是值得考虑的玩家。第二步看延迟要求。如果你的服务对P99延迟特别敏感且堆内存不大CMS曾经是最佳选择但在JDK 8之后我更推荐直接用G1把MaxGCPauseMillis设成100到200。如果你的服务能容忍秒级停顿Parallel足以应对。第三步看CPU资源。CMS和G1都是并发收集器它们需要额外的CPU线程来完成标记和整理。如果CPU常年跑在80%以上就别选并发收集器了否则业务线程会被严重挤占。这种情况下Parallel反而更抗压因为它把所有CPU都用来做“突击队式”的并行回收。我还想多说一句不要只参考网上所谓的“默认配置”。每个应用的分配速率、对象存活周期、老年代增长曲线都不一样。调GC参数的顺序一定是“监控 - 识别问题 - 针对性修改”而不是“套用别人CFG - 跑一把 - 宣布成功”。6.3 我的调优经验总结与避坑清单最后分享几条我从大量生产事故里总结出的经验先确认问题再调参。很多所谓GC问题其实是内存泄漏、线程池过度扩张、缓存使用不当引起的。用jmap -heap和jstat -gcutil先看内存水位再怀疑GC算法。每次只改一个变量。同时调整四个参数出了问题根本不知道是谁的锅。我习惯每次压测只改一个参数记录前后指标再继续下一个。监控Full GC比关注Minor GC更重要。Full GC往往是服务卡顿的直接元凶尤其是CMS并发模式失败和G1的Full GC一定要设置GC告警。新生代不是越大越好。新生代大了Minor GC频率降低但每次停顿会增加晋升到老年代的对象也可能增多反而引发Full GC。年轻代占堆的1/3到1/4是我比较常用的起点。用成熟的可视化工具。不要手动数GC日志试试GCViewer或gceasy它们能自动算出吞吐量、吞吐率、最差停顿还能画出堆使用曲线帮你快速定位异常点。调优这条路说难也难说简单也简单。难在问题表象千变万化简单在只要掌握了“指标 - 定位 - 实验 - 验证”这套闭环任何GC问题都逃不出这个框架。至少在我维护过的几十个服务里真正需要“魔法参数”的场景极少大多是把基础原理理解透彻后用最简单的几步调整解决了问题。希望这篇测试笔记能给你一些可复用的思路少踩几个我当年踩过的坑。
返回列表