多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Java GC优化实战:从JVM内存原理到线上Full GC排查与调优

Java GC优化实战:从JVM内存原理到线上Full GC排查与调优 跑Java的同学几乎没人能躲开GC问题。无论是线上接口突然卡顿、CPU飙升还是半夜被OOM报警吵醒最后大多都会落到一句“是不是该做GC优化了”但“GC优化”这四个字能承载的内容太多有人以为是调大堆内存有人以为要换回收器还有人干脆一键关闭某些检查。其实这些做法大部分时候都不解决问题甚至会引入新问题。这篇文章我打算从判断思路讲起把JVM内存结构、垃圾回收器选型、GC日志分析、线上调优案例和常见坑完整梳理一遍。它不是那种“参数背诵手册”而是我在真实业务里反复用过的排查路径。适合正在被GC问题折磨的同学也适合刚接触JVM调优、想建立系统思路的Java开发。1. 先把GC优化这件事想明白目标、取舍和常见误区1.1 GC优化到底在解决什么问题GCGarbage Collection垃圾回收。JVM的GC机制负责自动管理堆内存找到那些已经不被引用的对象回收它们占用的空间再给新对象腾地方。听起来很省心但代价是GC运行时业务线程有可能需要暂停也就是常说的Stop The WorldSTW停顿。GC优化要解决的核心问题归结起来是三个词停顿、频率、低效。停顿指单次GC让业务停滞的时间频率指GC多久触发一次低效指GC明明跑了却回收不了多少可用的连续内存导致后续分配和新对象晋升互相拖累。不同场景对这三点容忍度完全不一样。比如一个高频交易接口停顿50ms就可能造成大量超时失败但一个夜间批量跑数的报表任务哪怕Full GC停顿2秒也无所谓只要总时长能压下来就行。问题定义不清楚后面所有调优动作都容易跑偏。1.2 延迟、吞吐量、堆占用先想清楚要哪个GC优化本质上是个多目标权衡问题三个核心指标互相制约延迟Latency、吞吐量Throughput、堆占用Footprint。延迟通常体现在单次GC停顿时间、接口P99等数据上。你服务的业务类型决定了延迟敏感度。网关、风控、交易链路这类高并发、强实时业务GC停顿越短越好哪怕为了缩短停顿牺牲一些总吞吐量也值得。对这类系统我会优先接受一个偏大的Survivor空间和更频繁的Minor GC换来单次GC不把请求打断。吞吐量指的是应用运行时间占总时间的比例。比如应用跑了100秒GC占了5秒那吞吐量就是95%。离线计算、批处理任务、数仓同步这些场景总运行时长才是硬指标多发生几次GC没问题只要GC总耗时占比不高就行。这种场景下Parallel收集器往往还是最好的选择。堆占用则是另一个维度。容器化部署和云原生环境里Pod内存上限已经由平台定死JVM堆设置得越大GC一次扫描的活对象就越多停顿也会越久。堆占用和停顿时间就像跷跷板。所以做GC优化前先把业务的SLA目标写下来优先级排出来再动手调参数否则很可能今天调完吞吐量明天被延迟告警打脸。1.3 很多OOM其实不是GC的锅先正确定位问题GC调优最大的误区之一是遇到OutOfMemoryError就调堆参数。我见过不少同学看到OOM第一反应是-Xmx4g改成-Xmx8g结果内存加了一倍问题依旧最后才发现是代码把数据全加载到了内存里。OOM有好几种来源并不都是堆内存不足堆内存耗尽java.lang.OutOfMemoryError: Java heap space真正跟GC相关但多数意味着存在内存泄漏或对象生命周期过长。Metaspace溢出Metaspace动态生成类太多比如CGLIB代理滥用、反射类加载过多。线程栈溢出unable to create native thread操作系统线程创建不出来通常和堆大小无关和进程线程数限制有关。直接内存溢出OutOfMemoryError: Direct buffer memoryNetty等框架使用ByteBuffer分配堆外内存超限也会报。所以GC优化的第一步永远是定位问题。先看Full GC频率、GC停顿时间、堆内存曲线确认问题确实出在GC层。如果堆内存水位一直很低Full GC却频繁发生那就要换个方向查比如是不是有System.gc()被人调用或者JNI层触发了某些操作。2. 内存结构和垃圾回收器选型懂原理才能选对方案2.1 分代堆和对象的一生JVM堆为什么要分代因为绝大多数Java对象的生命周期都很短。创建出来用一下后续都不再被引用。对这种“朝生暮死”的对象最自然的设计是所有新对象先放进一个区域这个区域满了就只回收它存活对象挪到另一个区域而不是每次都对整个堆做全量扫描。JVM堆内存按代际划分为三块新生代Young Generation新对象主要在这里分配内部又分成Eden区和两个Survivor区S0、S1。Eden区用来放刚new出来的对象S0和S1用来存放Minor GC之后活下来的对象两者互相复制交换。老年代Old Generation在新生代经历了多次Minor GC仍然存活的对象会晋升到老年代另外有些大对象可能直接在老年代分配。元空间Metaspace从JDK 8开始类的元数据不再放入堆内存而是使用本地内存默认可以动态扩展。每次Minor GC发生幸存对象会在Eden和Survivor之间搬移年龄计数器加1超过阈值默认15后晋升到老年代。分代设计的收益非常明显大部分对象在Eden区就能被高效回收不需要扫描老年代GC成本被大大摊薄。还有个容易被忽略的点是TLABThread Local Allocation Buffer。每个线程在Eden区里有一小块私有分配缓冲对象分配时优先在TLAB里划分避免多线程抢同一片内存导致锁竞争。调优时如果发现对象分配速率极高先别急着改堆大小可以看是不是TLAB配置不合理。2.2 主流垃圾回收器怎么选垃圾回收器一直是JVM里迭代最频繁的部分之一这里把市面上常见的几类说清楚。Serial与Serial Old是最古老的单线程回收器。Serial在新生代用复制算法Serial Old在老年代用标记-整理算法GC时只有一条线程工作一旦发生GC其他线程全部暂停。它适合单核CPU、内存极小、对吞吐量没要求的客户端程序生产环境基本没人用了但理解它能帮你理解后续所有回收器的第一步。Parallel Scavenge与Parallel Old是JDK 8默认组合也叫“吞吐量优先收集器”。它把“让GC总耗时占比尽量低”作为核心目标能利用多核CPU并行执行GC适合批处理和后台计算任务。如果你的服务在JDK 8上运行且没有明显延迟问题用默认的Parallel是不错的选择没必要盲目换G1。CMS曾经是低延迟场景的明星它的设计思路是让GC线程和业务线程尽量并发执行减少STW。但它有著名的缺陷采用标记-清除算法老年代会产生内存碎片碎片化严重后只能退化成Full GC且并发标记阶段产生的浮动垃圾需要留出空间容忍所以CMS的触发阈值往往设置得偏高。JDK 9开始标记废弃JDK 14正式移除。G1Garbage First是JDK 9之后的默认回收器也是目前最主流的方案。它把整个堆分成若干个大小相等的Region新生代和老年代不再是物理连续的内存块而是一系列Region的逻辑组合。G1会记录每个Region的回收成本和回收收益优先回收“垃圾最多、回收代价最小”的Region用这种方式控制STW时间。ZGC和Shenandoah则把低延迟做到极致目标是把停顿时间控制在亚毫秒级别。ZGC通过染色指针、读屏障等机制把标记、转移、重映射等领域做成并发的停顿时间几乎不随堆大小增长。它适合堆内存特别大、业务对停顿极度敏感的场景比如超大缓存服务和实时推荐系统。回收器算法特点核心优势典型适用场景Serial复制 标记-整理简单直接单核客户端、小内存Parallel复制 标记-整理高吞吐量JDK 8默认批处理任务CMS标记-清除低延迟并发收集存量JDK 8服务已废弃但存量多G1Region化可预测停顿平衡吞吐与延迟JDK 9默认服务器常用ZGC并发标记-整理亚毫秒停顿超大堆、超低延迟场景2.3 从CMS到G1再到ZGC回收器演进的逻辑从CMS到G1再到ZGC的演进本质上是“如何减少STW时间”这条主线的升级。CMS当年最大的进步是让标记阶段尽量并发但它有一个结构性缺陷并发标记期间和并发清理期间业务线程还在产生新垃圾这导致“浮动垃圾”必须在下一次GC才能回收同时它不用整理压缩内存全是碎片时间一长老年代分配不了连续空间就得触发Full GC做串行压缩那个停顿非常吓人。G1的突破在于“分Region管理”和“有目标地控制停顿”。它把堆切分之后每个Region都可以独立回收根据每个Region的垃圾比例动态调整优先级。通过-XX:MaxGCPauseMillis让用户显式给出目标停顿时间G1会在保证回收效率的前提下尽量匹配目标。当然G1也有软肋大对象分配Humongous对象会占用连续多个Region回收方式比较粗糙如果应用频繁创建大数组或大缓存G1的Humongous分配也能成为新瓶颈。ZGC则通过“读屏障”和“染色指针”把几乎所有阶段都并发化了把停顿时间压得极低。不过他带来的是CPU开销上升如果机器核数少、CPU资源紧张ZGC的读屏障开销甚至可能超过收益。所以选型不要只看谁最新而要结合现有JDK版本、机器规格和业务目标做最小成本变更。3. GC日志与工具链让数据开口说话3.1 GC日志参数怎么配JDK 8和JDK 11两套配置没有日志调优就是盲人摸象。压测之前先把GC日志打开是成本最低的事。JDK 8和JDK 9之后的日志参数差异很大这里给出两套我常用的配置。JDK 8配置-Xloggc:/opt/app/logs/gc.log -XX:PrintGCDetails -XX:PrintGCDateStamps -XX:PrintHeapAtGC -XX:PrintGCApplicationStoppedTime -XX:PrintTenuringDistribution -XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/opt/app/logs/JDK 11统一日志-Xlog:gc*:file/opt/app/logs/gc.log:time,uptime,level,tags:filecount10,filesize20m -XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/opt/app/logs/JDK 9以后的统一日志系统用-Xlog取代了以前一大堆开关类参数。gc*表示抓取GC相关的所有分类日志filecount和filesize控制滚动策略避免日志文件把磁盘打满。-XX:HeapDumpOnOutOfMemoryError是一定要开的OOM时自动生成堆快照这是事后分析根因最重要的证据。3.2 一段GC日志逐行拆解以JDK 8的Parallel GC日志为例常见的一行长这样2026-06-10T13:25:10.0990800: 8213.327: [GC (Allocation Failure) [PSYoungGen: 921600K-122880K(1024000K)] 1048576K-245760K(1048576K), 0.0423290 secs] [Times: user0.05 sys0.01, real0.04 secs]拆开看2026-06-10T13:25:10.0990800是从系统日期角度记录的绝对时间方便和外部监控对齐。8213.327是JVM启动以来的秒数方便统计两次GC间隔。PSYoungGen: 921600K-122880K(1024000K)表示新生代GC前占用921600KBGC后占122880KB新生代总额度1024000KB。1048576K-245760K(1048576K)表示整个堆GC前占用、GC后占用和堆总容量。0.0423290 secs是本次GC耗时。[Times: user0.05 sys0.01, real0.04]中user是CPU用户态耗时sys是内核态耗时real是墙钟时间。如果多核并行回收user很可能比real大因为多个线程同时消耗CPU。读懂这几列数字你就能回答几个关键问题GC频率高不高、单次GC耗时是否异常、GC后老年代是否还在持续增长。如果GC后堆使用量总是维持在很高水位说明存量老年代对象已经逼近堆上限。G1的日志则类似这种[2026-06-10T13:25:10.0990800: 8213.327] GC pause (G1 Evacuation Pause) (young) 85.423-65.728 MB, 0.0423290 secsG1会区分young、mixed、full等不同GC阶段。GC pause (G1 Evacuation Pause) (young)表示这是一个年轻代转移暂停后面的数字代表整个堆GC前后的占用末尾是耗时。如果日志里频繁出现G1 Humongous Allocation就要高度怀疑大对象问题。3.3 常用分析和诊断工具怎么配合光有日志还不够结合工具才能更快定位问题。我常用的组合是jps列出当前机器上的Java进程找到目标进程PID。jstat -gcutil pid 1000 10每1秒打印一次GC统计连续10次能看到新生代、老年代使用率以及YGC/FGC次数和耗时适合现场快速判断。jmap -dump:formatb,fileheap.hprof pid导出堆快照OOM之后或怀疑泄漏时做离线分析。MAT或JVisualVM分析堆快照中对象直方图、支配树定位到底是谁持有了大量内存。GCEasy或GCViewer把GC日志传上去自动绘制GC停顿分布、吞吐量、各代内存趋势能快速看出趋势性异常。JMCJava Mission Control适合做本地或可接入JMX的线上实例监控采集飞行记录器数据。工具不在多能用顺手就行。关键是用的时候要带着问题去确认假设而不是漫无目的地瞎看。4. 实操一次线上Full GC调速4.1 现象接口P99直接飙到1.8秒之前有次线上一个交易服务高峰期接口P99从120ms左右一路飙到1.8s大量请求超时。最初团队怀疑是数据库慢查询但DBA排查过后端数据库慢SQL和锁等待都不明显。后来看JVM监控发现Full GC非常频繁每两次Full GC间隔不到2分钟单次Full GC最长到达2.3秒。这就说明问题大概率出在JVM内存管理层GC停顿直接压垮了接口响应。好在线上开了GC日志和OOM dump事后有据可查这是一开始就铺垫好的。4.2 用数据和日志把问题定位到晋升路径登录到节点先跑了一段jstat确认当时的GC统计。简化后的数据大概是S0C S1C S0U S1U EC EU OC OU MC MU YGC YGCT FGC FGCT 0.0 10240.0 0.0 8192.0 102400.0 91240.0 204800.0 160355.2 112640.0 ... 5234 45.36 182 312.5从数据能看到几个特征Minor GC次数已经到5234次Full GC达到了182次Full GC总耗时312.5秒平均每次超过1.7秒。新生代的Eden区占用率长期在90%上下每次Minor GC之后Survivor的S1使用率也高达80%左右很多对象还没来得及在S区数组中慢慢升级就被迫晋级老年代。看GC日志进一步确认每次Minor GC日志里大量出现类似[GC (Allocation Failure) [PSYoungGen: 1024000K-120832K(1024000K)] 1228800K-245760K(1228800K), 0.0132345 secs]新生代回收后还有约120MB的存活对象但Survivor区总大小只有两个10MB也就是20MB左右。120MB的活对象根本塞不进去只能直接进入老年代。这些对象里头有很多是短命对象只是生命周期恰好跨了两次Minor GC结果全被错误地晋升到老年代老年代占用从缓变陡最后触发Full GC。这类问题叫“过早晋升”。它的根因不是老年代不够大而是Survivor空间容量匹配不上存活对象体积晋升阀门开得太早。4.3 调整动作拆解从参数到代码分析清楚了调整策略分三个步骤优先级从高到低。第一步修正Survivor空间和新生代大小。当时原配置是-Xms2g -Xmx2g -Xmn1gEden和Survivor默认比例是8:1:1Survivor总共只有约100MB。根据日志实测Minor GC后存活对象在120MB左右Survivor装不下。所以我把新生代从1g扩到1.5g同时显式设置-XX:SurvivorRatio6让两个Survivor区各自占新生代的12.5%左右总容量从约100MB提升到约192MB这样120MB的存活对象就进得去了。这里得说一句SurvivorRatio不能拍脑袋改它取决于你的存活对象有多大。如果存活对象实在太大光调比例也救不了还得看分配速率是不是太高了。第二步设置更可控的停顿目标。这个服务当时还是JDK 8我最终没有直接切G1因为切换回收器对线上应用来说属于高成本变更风险太大。我的做法是先在测试环境用G1跑同一套压测验证MaxGCPauseMillis设置到100ms时的停顿表现再决定下一步。线上先用ParNewCMS组合配合调整后的分区比例压住问题为后续窗口期更换GC器留了余地。第三步也是根因修复——改代码。从堆dump看到对象直方图里大量重复对象来源于一个第三方的序列化库每次处理请求都会创建几百个包装对象。这些对象生命周期短但是数量巨大直接把Eden区的压力顶满。最终我们把这个序列化方案改成线程级复用对象池并把请求链路里的集合初始容量修改得更加贴近真实使用量尽量避免扩容。分配量降下来之后新生代压力指数级下降Survivor区完全能装下真实存活对象晋升到老年代的垃圾量明显减少。4.4 调优后的验证与收益调整后观察一周数据对比非常明显Minor GC频率从每分钟约30次降到每分钟8次左右。Full GC间隔从不到2分钟一次拉长到约40分钟一次。P99响应时间从1.8s回落到了150ms以内。这个案例说明一件事参数调整是缓解手段代码优化才是终极解。如果当时只调参数虽然能撑一段时间但只要流量再涨一波老问题还是会回来。GC调优最终要落到分配源头多问一句“为什么会有这么多对象在这里分配”往往比反复调整JVM参数更能解决问题。5. 常见问题与避坑指南5.1 典型GC问题速查表实际工作中GC问题可以被归成几类典型的模式判断模式比逐个参数试要快很多。问题现象可能原因优先排查方向Minor GC频繁但单次耗时短Eden区过小或对象分配速率太高看对象分配速率、调整新生代大小、检查大循环内创建对象Full GC频繁老年代使用率低调用System.gc()、Metaspace溢出、JNI触发检查是否显式调用System.gc、元空间配置Full GC频繁老年代使用率很高老年代空间不足、对象晋升过多、内存泄漏dump堆分析、检查长生命周期对象引用链GC停顿时间越来越长老年代碎片化、根集合扫描超大换G1或考虑压缩式回收器缩小堆快照根大对象导致GC异常Humongous对象占多个Region检查大数组、大缓存设置大对象阈值这五类模式覆盖了我这些年线上遇到的大部分场景。遇到问题时先用这几行对照一下通常能省去半天瞎调。5.2 调优误区盘点多层误区导致GC调优失败的情况很常见。第一个误区是只调堆大小、不看分配压力。-Xmx调大以后GC频率确实会下降但单次GC的扫描时间也会上升停顿时间反而可能变长。堆内存是用来兜底的不是用来掩盖代码问题的。第二个误区是盲目切换回收器。同样的业务、同样的堆在Parallel和G1下表现截然不同有的G1报表统计反而更差。没有压测数据支撑的换回收器就是赌博。第三个误区是只关注GC次数不关注GC时间。一天100次GC如果每次只有5ms完全不是问题一天10次GC每次2秒倒是要命。优化目标始终应该落在“可控的停顿时间”和“合理的GC总开销”上。第四个误区是忽略系统资源约束。GC的并发行为依赖空闲CPU和IO能力只有2个核的机器硬上ZGC压缩停顿少了但读屏障让整体吞吐量掉个10%以上反而得不偿失。5.3 实操经验清单最后整理一份我每次做GC调优都会执行的步骤清单算是不太容易外传的经验集合把日志和dump配置当作上线标准任何线上Java服务都加上GC日志和OOM dump不要等出问题了再补。动手前确定目标和基准写下你追求的指标比如P99小于500ms、GC总耗时占比小于2%调优前后用同一套压测模型做对比。一次只改一个参数改了之后留足观察窗口否则多个变量同时变你根本不知道是谁的效果。先看分配速率再看GC日志最后才调参数分配速率是根因GC日志是现象参数只是中间变量。线上变更先灰度一台机器至少观察24小时结合业务流量走势确认效果再扩大到全量。调优过程一定要记录包括当时的业务场景、堆配置、日志片段、调整原因和结果评估。过两个月你可能连自己为什么调某个参数都忘了。GC优化真正难的不是参数本身而是找根因的整个过程。日志、监控、dump、代码一层一层抽丝剥茧最后往往是一个很小的代码改动解决了大问题。调优前先想清楚业务最在意什么排清楚优先级再动手这条路会顺很多。
返回列表