资讯详情

资讯详情

JDK17 G1垃圾回收器调优实战:核心参数拆解与线上案例

前阵子组里有同事跑过来问我JDK17的GC参数怎么调为什么自己照着网上的配置抄了一遍启动倒是正常压测一上来反而频繁Full GC。这个问题我遇到过太多次了。JDK17作为现在生产环境的主力版本默认垃圾回收器是G1很多人从JDK8切过来之后还带着老一套CMS时代的调优思维结果越调越乱最后干脆把参数全删了。这其实不是参数的问题而是很多人没有把G1的运行逻辑吃透。GC调优从来不是玄学它有明确的目标、有可观测的指标、有可以复现的调试路径。这篇文章我从JDK17 G1的设计思路讲起把常用参数逐个拆开讲清楚再配合两个真实场景的调优过程最后聊几个让我记忆深刻的线上故障排查记录。不管你是刚接触G1的初学者还是从JDK8迁移过来的老开发文章里都有可以直接抄的作业。1. JDK17的GC引擎选型为什么只说G1还不够1.1 一张图看懂JVM垃圾回收器的演进逻辑很多人在JDK8上用的是Parallel GC搭配ParNewCMS的组合或者干脆直接默认的Parallel ScavengeParallel Old。JDK9之后Oracle把默认回收器换成了G1到了JDK17G1已经是绝对主流。理解这个演进过程有助于我们搞清楚为什么老一套经验会失灵。从设计目标看CMS的初衷是降低老年代回收的停顿时间但它有两个致命的先天缺陷一是内存碎片化严重二是并发收集阶段对CPU资源非常敏感。G1在设计上完全换了一套思路——它不再追求某一次GC的“绝对最短停顿”而是把整个堆切分成很多个小块Region然后维护一个全局的优先级列表每次回收时优先处理垃圾占比最高的那些Region。G1给用户一个停顿时间软目标比如200ms然后在这个目标约束下尽可能多地回收垃圾这就是G1名字里“Garbage First”的由来。JDK17里还有ZGC和Shenandoah这类追求超低停顿的回收器但绝大多数应用默认用的还是G1。ZGC的停顿可以做到纳秒或微秒级但它的内存占用和CPU开销也不小不是所有业务场景都划算。我个人的建议是除非你的业务对延迟极其敏感比如高频交易、实时音视频服务并且你已经有足够的监控和压测数据支撑否则先别急着上ZGC把G1调好往往已经能解决90%的问题。1.2 调优前必须理解的三层底层逻辑GC调优本质上是在回答三个问题对象在新生代存活多久、什么条件下升入老年代、老年代什么时候被回收。这三个问题对应三个核心指标分配速率Allocation Rate、提升速率Promotion Rate、停顿时间Pause Time。分配速率指的是单位时间内新生代分配对象的内存总量这个数字如果持续走高说明业务代码正在疯狂创建短生命周期对象。提升速率描述的是对象从新生代晋升到老年代的速率它直接决定了老年代什么时候满。停顿时间则是每次GC时应用线程被暂停的时长这是业务方最直观能感知到的指标也是调优的最终落脚点。这三者互相牵制。举个例子你把堆调大了新生代也跟着变大Young GC次数变少但每次Young GC要复制的存活对象可能变多单次停顿时间反而拉长。你把-XX:MaxGCPauseMillis从200ms调到50msG1为了让停顿达标会缩小每次回收的Region数量结果就是GC周期变频繁系统吞吐量下降。所以调优不是单独改一个参数就能解决问题而是要基于观测数据做组合调整。我建议大家调优之前先立三条规矩第一99%的应用根本不需要调整GC参数默认配置已经能满足大部分场景第二如果真要调必须有GC日志和监控数据支撑不许凭感觉猜第三每次只能改一组参数改完要压测对比不许一次动好几个开关不然出了问题根本没法定位。2. 核心参数拆解每一个调优参数背后的逻辑2.1 新手必看JDK17 G1常用参数速查表先从我日常排查和优化时最常用到的一组参数说起。这张表里的参数是我每次拿到一台新服务都会先看一眼的“体检项”。参数默认值作用什么时候需要调整-Xms/-Xmx物理内存的1/4初始堆大小和最大堆大小生产环境务必设成相同值避免运行时扩容抖动-XX:MaxGCPauseMillis200msG1停顿时间软目标业务对延迟敏感时适当调低但不要低于100ms-XX:G1NewSizePercent5%新生代初始占比短生命周期对象多时适当提高-XX:G1MaxNewSizePercent60%新生代最大占比避免新生代太大导致老年代回收被延迟-XX:G1HeapRegionSize自动计算Region大小大对象较多时手动指定避免Humongous GC-XX:InitiatingHeapOccupancyPercent45%触发并发标记周期的堆占用阈值老年代增长快时适当降低频繁并发标记时调高-XX:ConcGCThreads自动计算并发标记线程数频繁并发标记且CPU有富余时增加-XX:G1ReservePercent10%预留空间占比晋升对象较多时增大避免to-space溢出-XX:ParallelRefProcEnabledJDK17已默认开启并行处理Reference对象大量使用SoftReference/WeakReference时值得关注-XX:HeapDumpOnOutOfMemoryError默认关闭OOM时自动导出堆快照生产环境强烈建议开启这里面最容易被误解的就是-XX:MaxGCPauseMillis。它只是一个软目标不是硬性保证。G1会尽量把每次停顿控制在这个值以内但如果堆内存接近饱和或者晋升对象太多G1也会放弃这个目标优先保证回收完成。所以不要把这个参数当成一个“配置上去就生效”的开关它更像一个给G1的“参考基准”。2.2 重点参数深度解析G1如何做出回收决策堆大小与Region划分。G1的整个堆被平均分成一个一个小块Region每个Region的大小从1MB到32MB不等目标窗口是堆的2048等份左右。举个例子一个4GB的堆每个Region大约2MB。Region太小大对象就要跨多个Region存储G1处理大对象时会有额外的开销Region太大Region数量太少G1的“按垃圾密度排序回收”策略就发挥不出来。如果你的应用里经常分配大数组、大缓存可以观察GC日志里“Humongous”相关的记录如果这种大对象GC频繁出现手动指定-XX:G1HeapRegionSize16m或32m通常能明显减少停顿。停顿目标与新生代大小的拉扯。G1的默认新生代占比是5%到60%这个浮动区间由两个参数控制-XX:G1NewSizePercent和-XX:G1MaxNewSizePercent。G1会在每次GC结束后根据当前的停顿时间和分配速率动态调整新生代大小。如果停顿时间超标了它就缩小新生代如果停顿时间很富余它就试着扩大新生代减少Young GC的触发频率。这就是为什么我们一般不建议在G1上手动指定-Xmn——一旦你手动锁死新生代大小就相当于剥夺了G1最核心的动态调节能力。老年代回收的启动时机。G1维护着一个全局的并发标记周期它由-XX:InitiatingHeapOccupancyPercent控制默认是45%。意思是整个堆的内存占用达到45%时G1会启动并发标记标记出老年代中垃圾占比高的Region随后在Mixed GC中优先回收这些Region。很多线上问题就出在这个参数上堆占用长期在50%附近徘徊导致G1频繁启动并发标记标记线程占用CPU应用整体吞吐量下降。我遇到过好几个案例把IHOP从45%调到60%甚至65%GC线程的CPU占用立刻降下来业务响应时间也平稳了。ReservePercent为什么重要。这个参数可能很多同学没注意过它默认是10%表示G1会预留10%的堆空间用于应对晋升对象导致的突然内存压力。这有点像物流仓库预留的应急通道平时看是空着的但一旦大批货物对象同时涌进来这个通道能防止仓库堵死。如果你的监控里经常出现to-space exhausted或者G1 Evacuation Failure相关的日志说明10%的预留可能不够需要调大这个参数。3. 实战案例一批量任务应用的G1调优全过程3.1 问题现象与第一轮诊断有一个做批量导入和报表生成的Java服务业务特征很典型白天运行平稳每天凌晨和整点会有大批量任务需要同时处理大量临时文件和中间计算结果。服务用的是JDK17堆配置4GB默认G1参数。线上反馈是每到整点任务高峰期应用会出现明显的卡顿部分任务排队等待时间超过5秒监控面板上能看到Full GC次数飙升。我先用jcmd pid GC.heap_info看了一眼堆内存分布发现老年代占用已经到3.2GB而新生代只剩不到200MB这说明对象晋升速率非常高老年代空间很快就满了。紧接着打开了GC日志发现一个高频出现的现象G1 Evacuation Pause (mixed)频繁触发而且紧跟着就是Full GC。这其实暴露了两个问题第一老年代空间真的不够用第二G1的Mixed GC回收速度赶不上对象晋升速度导致最终只能靠Full GC兜底。当时第一反应是“堆太小了加内存”但看了dump文件后我发现自己错了。堆里躺着大量重复的byte[]和HashMap$Node仔细追踪引用链发现业务代码里有一个非常经典的错误把所有中间计算结果都缓存在内存里批量任务之间还共享了同一个“全局结果集”对象。这批对象既不会被快速回收又不在常驻业务实体范围内纯粹是代码设计问题。这里特别想提醒大家调GC参数前一定要先排除代码层面的问题。不然就算你把堆加到8GB也只是推迟Full GC发生的时间垃圾总量该多大还是多大。3.2 改造方案与参数组合调整第一轮先做代码层面的止血把全局缓存改成任务级别的局部变量批量处理改成按批次提交一批最多1000条记录处理完就释放引用。这个改动做完同样的压测场景下老年代占用从3.2GB降到了1.8GBFull GC基本消失。然后才是参数层面的细调。我给这台服务设置的最终G1参数模板长这样-Xms4g -Xmx4g -XX:MaxGCPauseMillis150 -XX:G1HeapRegionSize16m -XX:InitiatingHeapOccupancyPercent60 -XX:ConcGCThreads6 -XX:G1ReservePercent15 -XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/data/logs/dump -Xlog:gc*:file/data/logs/gc.log:time,uptime,level,tags这里每个参数都是有讲究的。-Xmx4g保持和-Xms一致避免运行时堆扩容抖动这是生产环境的底线要求。-XX:MaxGCPauseMillis150我没有用默认的200也没敢调到50因为批量任务虽然对延迟有要求但更看重整体吞吐太过激进的停顿目标反而会让GC周期变长。-XX:G1HeapRegionSize16m是我观察到一个特殊现象后加的。这个应用的中间结果里有很多几十KB到几MB的临时数组如果Region太小这些对象会横跨多个Region存储导致Humongous GC额外开销。手动指定16MB后大部分临时数组都能装进单个RegionHumongous相关的GC记录明显减少。-XX:InitiatingHeapOccupancyPercent60是我综合业务特征做的调整。这个服务的老年代占用率在日常运行中稳定在40%到55%之间如果保持默认的45%G1就会频繁启动并发标记周期白白消耗CPU。提高到60%之后并发标记周期从每几分钟一次降到每半小时一次GC线程的CPU占用率下降了40%以上。调整后我压测了整整一个完整的高峰周期效果对比非常直观指标调整前调整后Full GC次数/小时8~10次0次Mixed GC频率几乎连续触发半小时左右一次Young GC平均停顿90ms65ms任务排队延迟P994.8秒1.2秒GC线程CPU占用12%5%从这个案例里我可以总结一个经验GC参数调整一定是在代码问题清理完之后做的最后一步而不是第一步。代码层面的低效设计会从根本上放大GC压力参数再怎么调都是治标不治本。4. 实战案例二高并发接口的GC优化对比4.1 核心接口的停顿目标该设多少另一个值得分享的场景是用户中心的高并发查询接口QPS大概在3000左右接口内部会频繁读取Redis和本地缓存构造统一响应对象。这个服务的核心痛点是接口P99延迟经常出现尖刺用户反馈时快时慢而G1日志显示Young GC的频率不算高但P99抖动非常严重。这个案例的调优空间和上一个完全不同。批量任务场景追求的是吞吐优先而这里追求的是稳定延迟。我的第一反应是检查停顿目标参数。默认的-XX:MaxGCPauseMillis200对这个接口来说太宽松了G1可能会放任单次停顿到接近200ms才收紧新生代这个级别的抖动直接反映到用户端就是卡顿。我把参数调整成了100ms同时开了-XX:ParallelRefProcEnabled因为接口里用了大量WeakReference做本地缓存并行处理Reference对象可以缩短Root扫描阶段的时间。调整后的第一个版本Young GC的平均停顿确实从120ms降到了80ms但总体吞吐量下降了不少GC频率飙到了原来的1.8倍。这里就体现出了G1停顿目标和吞吐量之间的天然对立。我后来妥协了一下把停顿目标设回150ms同时用-XX:G1NewSizePercent10让新生代初始占比更高一点让对象在新生代多待一会儿减少直接晋升到老年代的比例。这一版的效果最好P99尖刺消失了吞吐量也保持在可接受范围内。4.2 从CMS迁移过来的参数对照与踩坑记录这个项目的部署历史很有意思最早在JDK8上跑用的是CMS。迁移到JDK17后团队里有人图省事把CMS时代的参数原封不动地搬了过来结果启动直接报错——CMS相关的参数在JDK14之后就已经被彻底移除了。我整理了一张两张时代的参数对照表帮大家避坑JDK8 CMS时代JDK17 G1时代说明-XX:UseConcMarkSweepGC-XX:UseG1GCG1在JDK9后已是默认不需要显式指定-XX:CMSInitiatingOccupancyFraction70-XX:InitiatingHeapOccupancyPercent60两者都控制老年代回收触发时机但G1的判定单位是Region-XX:UseCMSCompactAtFullCollection不需要G1的Full GC不再靠CMS压缩来整理碎片-Xmn512m不建议设置G1需要动态调节新生代手动锁定会削弱自适应能力-XX:MaxTenuringThreshold6-XX:MaxTenuringThreshold15G1的晋升判定更依赖Region复制统计手动设置意义不大这个对照表想表达的核心思想是G1和CMS的调优哲学完全不同。CMS的核心是一个老年代空间所有对象挤在一起G1的核心是几十个甚至上百个Region分散地分布在新生代和老年代之间。CMS调优靠“加内存、调阈值”G1调优靠“理解Region变化规律、控制回收目标”。如果你还在用CMS时代的参数惯性来调G1方向就已经偏了。5. GC日志解读与JVM诊断工具的正确打开方式5.1 三件套组合jcmd、jstat和GC日志好多同学问我线上排查GC问题到底用什么工具我的答案永远是三件套jcmd、jstat、GC日志。这三样搭配起来可以覆盖90%的GC故障排查场景。jstat是快速体检工具适合看趋势。输入jstat -gcutil pid 1000 10可以看到每秒输出一次的新生代、老年代、元空间占用百分比以及YGC和FGC的次数。我一般先扫这一眼确认问题出在新生代还是老年代再决定下一步操作。jcmd是JDK17里功能最全的诊断命令比如jcmd pid GC.heap_info可以查看G1的Region划分和每个Region的状态jcmd pid GC.class_histogram可以输出当前JVM中各个类的实例数量排查对象泄漏非常有用。有个细节要注意的是JMAP在JDK17里已经进入了维护模式很多功能逐步收敛到了jcmd和jhsdb上大家不要再死记老命令jcmd才是现在的主入口。GC日志则是定位问题的最关键依据。JDK17的日志体系经过了统一改造用-Xlog参数控制。我常用的开关写法是-Xlog:gc*:file/data/logs/gc.log:time,uptime,level,tags这条命令表示输出所有GC相关日志写入到指定文件并附带时间戳和JVM运行时长。生产环境建议就开这个级别不要开debug不然一天能写好几个GB日志。5.2 三段典型GC日志手把手教你读日志拿到手之后重点看三类记录。第一类是Young GC的普通暂停日志大致长这样[0.328s][info][gc,start] GC(0) Pause Young (Normal) (G1 Evacuation Pause) [0.328s][info][gc,phases] GC(0) Evacuation Pause: 2.130ms [0.330s][info][gc,phases] GC(0) Pre Evacuate Collection Set: 0.062ms [0.330s][info][gc,phases] GC(0) Merge Heap Roots: 0.567ms [0.331s][info][gc,phases] GC(0) Evacuate Collection Set: 5.201ms [0.331s][info][gc,heap] GC(0) Eden: 320.0M(320.0M)-0.0B(256.0M) [0.331s][info][gc,heap] GC(0) Survivors: 16.0M-32.0M [0.331s][info][gc,heap] GC(0) Heap: 512.0M(1024.0M)-184.0M(1024.0M)这里最有价值的信息在最后几行Eden从320MB清空到0说明这次GC把整个Eden都回收了Survivors从16MB变成32MB说明有一部分对象存活下来并晋升到了Survivor区堆从512MB降到184MB说明回收了328MB垃圾。如果这类日志频繁出现而且每次回收量很小说明新生代大小定得不合适对象被频繁复制多次需要调整G1NewSizePercent。第二类是并发标记周期日志里会有Concurrent Mark标识。重点看标记耗时和停顿时间如果这个阶段频繁出现并且耗时很长多半是IHOP设置偏低标记线程被白白拉起却只回收了很少的垃圾。第三类是Full GC这条日志出现基本就能宣告本轮调优失败了。Full GC在G1里是串行回收停顿时间可能以秒为单位对在线业务来说是灾难性的。一旦看到Full GC先看是不是老年代真的满了如果是再检查代码层是不是有对象泄漏不要急着改参数原因多半在堆外。6. 真实故障排查实录MetaSpace膨胀与GC线程CPU飙升6.1 MetaSpace持续增长最后OOM有个运行了三个月的应用突然开始频繁Full GC日志里反复出现OutOfMemoryError: Metaspace。最开始大家以为是元空间默认值太小直接调大了-XX:MaxMetaspaceSize但问题只缓解了两天Full GC又回来了而且这次OOM得更凶。这个案例的真正根因是类加载器泄漏。应用里有一段动态生成类的代码每处理一条数据就生成一个新的类加载器而处理完的数据被长期持有引用导致这些类加载器无法被回收。JDK的动态代理、Groovy脚本引擎、反射生成的内部类都容易出现这类问题。元空间的占用会随着动态生成的类数量持续累积-XX:MaxMetaspaceSize只是设了一个天花板它不能阻止类加载器泄漏只能让应用在触顶时快速暴露问题。我的处理思路是先用jcmd pid GC.class_histogram查看类加载器的实例数量对比正常基线和当前值确认是否持续上升再开-Xlog:classloaddebug追踪类的加载来源最后在代码层面修复引用持有问题。这里想特别说一句-XX:MetaspaceSize并不是元空间的初始大小它只是一个触发类卸载检查的阈值把它调大并不会让元空间“变大”很多文章把这个概念讲混了大家一定要分清楚。6.2 GC线程CPU飙升但YGC频率正常另一个典型的线上事故是应用响应慢CPU飙到90%以上top -Hp一看大量名为G1 Concurrent Mark GC Thread的线程在占比排行的前列。奇怪的是jstat -gcutil显示YGC频率并没有异常增高老年代占用也只有35%。我翻了GC日志发现了端倪并发标记周期启动得非常频繁几乎每两分钟就启动一轮但每轮回收的垃圾都非常少。这说明-XX:InitiatingHeapOccupancyPercent的默认值45%对这个应用来说太低了老年代刚堆到45%就触发并发标记而实际垃圾密度并不高标记线程空转白白吃掉CPU。这个问题的解法很简单把IHOP从45%调到65%并发标记周期从每两分钟一次降到了每二十分钟一次CPU占用立刻降下来应用响应恢复正常。这类问题的难点在于定位系统表面上看是CPU问题实际根源是GC并发线程在“摸鱼”。如果你没有养成看GC日志的习惯很容易在CPU层面绕来绕去找不到方向。6.3 高频Full GC竟然是手抖写错了对象引用最后一个案例是最让人哭笑不得的一个支付回调服务上线后第三天开始频繁Full GC每次停顿超过3秒。排查了堆内存、GC日志、线程栈都没有发现明显异常。最后我用jcmd pid GC.class_histogram对比了两次堆快照发现一个业务类OrderContext的实例数量从几十个涨到了几百万个。打开堆dump一看这些OrderContext对象全部挂在一个静态Map的Value上。代码里有一个全局的“支付回调上下文注册表”本来应该在回调完成后移除条目但有人把remove(key, value)写成了remove(key)而key还是一个每次请求都会重新生成的对象导致Map永远删不掉对应条目。这是一个纯粹的代码BugGC参数再优化也救不了这种问题。这个案例用来提醒大家GC调优的尽头是代码审查。当你发现不论怎么调参数问题都无法根治时回头审视代码里的静态缓存、全局集合、线程局部变量往往会有惊人大发现。7. 常见误区与我的实操建议7.1 这些坑我全都踩过希望你别再踩结合我自己的经历和组里其他同学踩过的坑我整理了一份GC调优误区清单每一条背后都有真实的故障复盘。误区一把线上的GC参数从别的项目直接拷过来。这是最省事但也最危险的做法。每个应用的分配速率、对象生命周期、堆使用模式完全不同A项目合适的参数放到B项目上可能正好触发GC抖动。参数必须基于自己应用的数据来定别迷信“别人调好的模板”。误区二把-XX:MaxGCPauseMillis设成几十毫秒。这个参数是软目标不是硬指标。调得太低G1为了让停顿时间达标会频繁发起GC周期系统吞吐量全面下降应用反而更卡。我的经验是大部分业务设在100ms到200ms之间是合理区间特别追求低延迟的场景建议先上监控和压测数据用数据说话。误区三让堆内存无限膨胀。很多人的第一反应是“Full GC了加内存”。堆内存变大确实能减少GC频率但也会拉长每次GC的停顿时间因为要复制的存活对象更多了要扫描的Root区域也变大了。而且大堆意味着GC后内存回收周期长问题会被掩盖而不是解决。误区四忽略代码层面的垃圾生产源。GC调优有一个朴素的真理垃圾产生量下降GC自然就快了。你可以花一周调参数也可以花一小时review代码后者的收益往往更大。误区五看到OOM就调大堆不开堆转储。生产环境一定要开-XX:HeapDumpOnOutOfMemoryError这是定位OOM的第一道防线。没有堆快照一切排查都是盲人摸象。7.2 一套通用的GC调优实操流程最后分享一套我每次做GC调优都会走的流程算是这些年踩坑总结出的“标准动作”。第一步先把-Xms和-Xmx设为相同值打开GC日志观察几天线上运行数据形成基线。第二步根据业务指标判断调优目标如果延迟高关注停顿时间如果吞吐量低关注GC频率和总耗时。第三步结合jstat和GC日志定位大头是新生存代频繁Young GC还是老年代并发标记频繁还是Full GC频繁。第四步针对具体问题选择一组参数调整一次只动一个维度压测对比。第五步回归观察如果效果不明显回滚参数继续排查代码而不是继续盲目调参。这五步走下来绝大多数GC问题都能被系统性解决。我个人在实际操作中最大的体会是GC调优是一个“观测驱动”的过程不是“配置驱动”的过程。每一次参数调整都要有日志和数据支撑不然就是在碰运气。还有一个小技巧想分享给你GC日志文件一定要配置日志轮转用一个定时任务定期做归档清理否则线上跑个三五个月日志文件能把磁盘打满这又是一个看似和GC无关但实际非常伤的生产事故。调优这件事耐心比技术更重要希望你别嫌麻烦把每一步都走扎实。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →