ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java垃圾回收全解析:从JVM内存模型到GC调优实战

Java垃圾回收全解析:从JVM内存模型到GC调优实战 提起Java垃圾回收器很多人的第一反应是面试八股文什么GC Roots、可达性分析、CMS、G1背得滚瓜烂熟但真到了线上服务出现频繁Full GC、CPU飙高、接口超时的时候往往又不知道从哪下手。这篇内容不打算做面面俱到的源码解析而是站在实际开发者的角度把Java垃圾回收这块从底层原理到选型调优、再到问题排查的完整链路捋一遍希望能帮你把脑子里零散的知识点串成一条线。不管你是准备面试的初中级工程师还是正在为线上JVM性能头疼的运维开发或者是刚接触JVM想系统建立知识框架的初学者这篇文章都值得花几分钟读完。我会尽量用大白话拆解那些看起来很玄的概念再配上一线实战中的踩坑记录和排查套路不做纯理论复读机。1. JVM内存模型垃圾回收器工作的“主战场”要理解垃圾回收器在干什么先得搞清楚它管的是哪块地盘。JVM运行时数据区大致分成线程私有的虚拟机栈、本地方法栈、程序计数器和线程共享的堆、方法区/元空间而垃圾回收的重点区域就在堆这也是面试里最常被追问的部分。1.1 运行时数据区域划分堆是Java对象的主要栖息地几乎所有的对象实例都在这里分配内存。垃圾回收器盯着的就是这块区域。虚拟机栈、本地方法栈、程序计数器是线程私有的随线程生灭通常不需要垃圾回收介入。方法区在JDK 8以后被元空间取代存储类元信息一般也不做频繁回收但确实存在类卸载的机制只是触发条件苛刻。堆内部又细分为新生代Young Generation和老年代Old Generation新生代里再分出Eden区、两个Survivor区S0和S1默认比例是8:1:1。这样划分的逻辑很简单大多数对象“朝生夕灭”存活时间极短放在新生代用复制算法做高频回收效率最高少数熬过多次GC的对象晋升到老年代回收频率低一些。元空间的引入解决了JDK 7及以前永久代容易OutOfMemory的痛点它直接使用本地内存默认不受JVM堆大小限制但这不代表不用管它——类加载器泄漏时元空间一样会爆。1.2 对象在堆中的生命周期一个普通对象的典型旅程是这样创建时优先在Eden区分配Eden区不够了触发Minor GC能存活下来的对象年龄加1从Eden区挪到Survivor区。每熬过一次Minor GC年龄继续增加。当年龄达到阈值默认15就被晋升到老年代。如果Survivor区放不下也会触发动态年龄判定相同年龄对象占用空间超过Survivor一半时年龄大于等于这批对象的会直接晋升。这里有个容易忽略的点大对象很长的字符串、大数组等会直接进入老年代目的是避免在Eden区和两个Survivor区之间发生大量的内存复制。我在实际项目里就见过有人频繁创建超大byte[]导致老年代快速膨胀最后触发Full GC性能雪崩。这类问题靠调参解决不了得先改代码把大对象池化或者分块处理。注意JVM参数-XX:PretenureSizeThreshold可以设置大对象直接进入老年代的阈值但默认是0表示不做限制。真要设置的话需要配合-XX:UseSerialGC或-XX:UseParallelGC等特定收集器才有意义用G1时这个参数是无效的。2. 对象存活判断一个对象的“生死判定机制”垃圾回收器要把“死亡”的对象找出来回收掉但怎么判定一个对象是不是真的死了这有两种主流方案引用计数法和可达性分析。前者因为解决不了循环引用问题现代JVM已经不用了后者是包括HotSpot在内的主流实现采用的方式。2.1 引用计数法为什么被淘汰引用计数法的思路很直白给每个对象配一个计数器被引用一次加1引用失效减1计数器为0就回收。实现简单、判定快但它有个致命问题——循环引用。假设对象A引用了BB也引用了A这两个对象已经没被任何外部引用使用了但它们的计数器互相撑着不为0垃圾回收器永远不会碰它们这俩就成了名副其实的内存泄漏。我记得刚学Java时自己还验证过一个循环引用的例子两个对象互相引用然后把外部引用置空手动调用System.gc()结果发现这两个对象还是被回收了。当时挺震惊的后来才知道HotSpot用的是可达性分析压根不看引用计数。这个例子也说明死记硬背原理不如亲手验证一遍印象会深刻很多。2.2 可达性分析从GC Roots开始的扫描可达性分析的逻辑说起来简单以一组称为GC Roots的根对象为起点沿着引用链向下搜索走过的路径称为Reference Chain能到达的对象就是存活的不可达的对象就是可回收的。这有点像图论里的连通性判定把整个堆对象看成一张有向图从根节点出发做遍历。GC Roots包括哪些栈帧中的本地变量表引用的对象、方法区中的静态属性引用对象、常量引用对象、JNI本地方法引用的对象、活跃线程本身等。面试里经常问“GC Roots有哪些”很多人只记得前几个把活跃线程和JNI引用漏掉实际上这些也会影响对象是否可回收。另外还有一点跨代引用对可达性分析影响很大CMS和G1都引入了记忆集Remembered Set来解决跨代引用扫描的效率问题这属于进阶内容后面聊收集器时再说。2.3 四种引用类型的实战意义Java提供了四种引用类型强引用Strong Reference、软引用Soft Reference、弱引用Weak Reference和虚引用Phantom Reference。这可不是面试用的装饰品它们直接影响对象的回收时机。强引用我们是每天都在用的Object obj new Object()只要强引用还在垃圾回收器永远不会回收它软引用是内存不足时才会回收适合做缓存弱引用是每次GC都会被回收比如ThreadLocal的ThreadLocalMap里key就是弱引用这也是ThreadLocal内存泄漏讨论的焦点虚引用最特殊它不影响对象生命周期主要用于跟踪对象被回收时的通知比如堆外内存的回收配合Cleaner机制用的就是虚引用。实操心得排查内存泄漏时优先查强引用链上是不是有长生命周期的对象比如静态集合把本该回收的对象拽住了。用jmap或者MAT做堆dump分析时看的就是从GC Roots到嫌疑对象的引用路径路径越短越可疑。3. 垃圾收集算法回收动作的底层逻辑判定出“该死”的对象之后怎么把它们清理掉同时把堆整理好这就是垃圾收集算法要做的事。别小看这层所有看起来高大上的垃圾回收器底层无非就是三种基本算法在特定场景下的组合应用。3.1 标记-清除最简单的思路与碎片化问题标记-清除算法分两步第一步把所有存活对象标记出来第二步统一清理掉未标记的对象。思路简单但问题也明显内存碎片。被回收对象的内存是零散分布的清完之后留下大量不连续的空洞后续要分配一个大对象时找不到连续空间只能提前触发GC。这一点我感触很深碎片化严重的时候明明堆总空间还有富余却不停地GC这其实就是老年代碎片导致的。3.2 标记-复制牺牲空间换效率标记-复制算法解决了碎片化问题把内存分成等大小的两块每次只使用其中一块回收时把存活对象复制到另一块上然后一次性清掉当前这块。实现简单、无碎片代价是只有一半内存可用。新生代用的就是这种思路不过HotSpot没有浪费一半空间而是把新生代分成一块较大的Eden和两块较小的Survivor只有Eden和一块Survivor在服务另一块Survivor作为交换区。这样设计后内存浪费比例只有10%默认8:1:1同时利用分代的特点大幅减少了需要复制的对象数量。这里有件事我踩过坑当Survivor区放不下存活对象时多余对象会直接晋升到老年代。某次线上一个大流量活动对象存活率异常升高大量对象涌进老年代导致老年代Full GC频率暴涨。所以监控时不能只看堆总大小还得盯各分区的使用率曲线S区长期打满说明对象存活率超过了预期。3.3 标记-整理移动存活对象解决碎片化标记-清除不搬移对象标记-复制搬移对象但空间利用率打折标记-整理算法则是绕了一圈先标记所有存活对象然后让存活对象向内存一端移动紧凑排列最后清理掉边界之外的全部空间。优点是消除了碎片、空间利用率高缺点是需要移动对象移动本身有开销而且如果移动期间有其他线程在并发访问这些对象引用还需要更新引用关系这就涉及Stop The WorldSTW暂停用户线程的问题。老年代收集器里Parallel Old、G1都大量使用这种算法的变体。3.4 分代收集理论分代收集不是一个具体的算法而是一条设计策略不同生命周期的对象采用不同算法组合。新生代对象存活率低用复制算法成本低老年代对象存活率高加上没有额外空间做复制担保就用标记-清除或者标记-整理。这套理论直接决定了标准堆布局和主流收集器的工作方式。即便是像G1这样“区域化”的收集器也保留了逻辑上的分代概念Young GC和Mixed GC的触发条件、处理区域都是不一样的。可以把这个逻辑理解成城市垃圾处理小区里每天定时清运厨余垃圾新生代速度快频率高废弃家具这种大家伙老年代每周统一收一次处理起来慢但频率低。分代策略本质上就是“按垃圾特性匹配不同处理方式”这也是JVM能在大吞吐量下保持稳定性能的关键。4. 垃圾收集器全家桶从Serial到ZGC算法是指导理论真正干活的是垃圾收集器。很多初学者会被这堆名字绕晕Serial、ParNew、Parallel Scavenge、CMS、G1、ZGC……其实把它们按“单线程→多线程→并发→区域化”这条演进线排开记忆负担会小很多。下面逐个拆重点讲清各自的核心设计和适用场景顺带给出选型建议。4.1 单线程时代Serial与Serial OldSerial是Client模式下的默认新生代收集器Serial Old配老年代。特征是单线程工作GC期间会STW暂停所有用户线程。听上去很惨但Serial在单核或者机器配置很低的环境下反而高效因为它没有线程切换开销。Serial用在几十上百MB的小堆上表现非常稳定接口机、边缘服务、本地开发环境都够用。别因为看到“单线程”就小看它我在老项目上见过Serial Serial Old的方案在低并发场景下跑得很平稳GC停顿短而且可预测。4.2 多线程并行ParNew与Parallel ScavengeParNew是Serial的多线程版本核心价值在于能和CMS配合是新生代收集器里少数能接入CMS的。多核环境下可以并行执行GC减少STW时间。不过现代JDK里CMS已经废弃ParNew的舞台也小了很多。Parallel Scavenge更特别它追求的是可控的吞吐量通过-XX:MaxGCPauseMillis设置期望最大停顿、-XX:GCTimeRatio设置吞吐量比例JVM会动态调整年轻代大小来达成目标。Parallel Scavenge搭配Parallel Old多线程标记-整理就是经典的“吞吐量优先”组合适合计算密集型、对延迟不敏感的后台任务。我做过的批量数据处理服务用的就是这套堆可以开很大GC吞吐量指标很理想但单次GC暂停时间确实不短。4.3 CMS追求最短停顿时间的先行者CMSConcurrent Mark Sweep的大名估计很多人都听过它是第一款真正意义的并发收集器设计目标是“最短停顿时间”。核心做法是让GC线程和用户线程尽量并发执行把标记阶段的耗时打散。但这东西在JDK 14已经被正式移除原因很真实使用标记-清除算法碎片化问题无解长时间运行后老年代空间碎片严重并发阶段会持续占用CPU对CPU资源紧张的服务不友好并发模式失败Concurrent Mode Failure时会退化到Serial Old做Full GC单线程老年代GC停顿能到几十秒线上基本等于事故我接手过一个跑了三年的老服务用的就是CMS线上查询还是卡顿打日志发现老年代碎片化严重每次Full GC只能回收几个百分点最后协调窗口期切换到了G1才消停。如果你维护的项目还在用CMS规划尽早升级别再对接新需求了。4.4 G1面向服务端的默认选择JDK 9之后G1成了服务端默认收集器JUDGEMENT影响巨大。G1的设计不再遵循严格的“整堆分代”而是把堆划分成一个个大小相等的Region默认2048个每个Region可以是Eden、Survivor、Old或者Humongous大对象区。回收以Region为单位每次选择回收价值最大的Region集合Garbage First这样就能做到相对可控的停顿时间——这也是G1名字的由来。G1的整套机制里记忆集Remembered Set和SATBSnapshot At The Beginning是两个关键点。记忆集用于跟踪跨Region引用避免全堆扫描SATB则确保并发标记阶段新产生的引用不会漏标。G1还支持-XX:MaxGCPauseMillis200这种停顿目标设置不过需要说明白停顿目标只是一个软指标G1会尽量满足大幅调低可能增加回收频率反而拖累吞吐量。G1也有自己的坑。最大的问题是Region被Old区占用后如果大量Humongous对象分配超过Region大小一半会直接占据整个Region老年代空间分配效率直线下降。有一次我们分析GC日志发现连续分配了几百个1MB的数组对象直接把G1的老年代Regional空间吃光了。解决办法也很朴素代码里拆掉大对象或者把Region调大。4.5 ZGCTB级堆下的超大容量方案比G1更激进的是ZGCZ Garbage Collector最大亮点是把STW时间压到10毫秒以内堆大小可以扩展到TB级别。ZGC的秘诀是染色指针Colored Pointer和读屏障Load Barrier标记信息直接存在指针里并发转移时通过读屏障拦截访问不需要全堆扫描。ZGC在JDK 15转正JDK 17之后的版本里已经支持分代ZGC进一步提升了吞吐量。但要冷静对待超大堆超低停顿的代价是更高的CPU和内存开销读屏障对每次引用访问都有额外检查成本在CPU资源本就紧张的服务里可能得不偿失。适合ZGC的场景是那种内存达到几十GB甚至更大、对延迟极其敏感的核心在线服务比如大型游戏服务端、实时交易系统。我个人的建议是堆小于16GB别碰ZGCG1的性价比更高。这里给个选型速查表方便对照使用收集器适用年代执行方式追求目标适用场景现状Serial新生代单线程串行简单稳定单核环境、小堆JDK 9起非默认Serial Old老年代单线程串行简单稳定CMS的备用方案JDK 14起移除CMS后少见ParNew新生代多线程并行低停顿配合CMS使用随CMS一起被淘汰Parallel Scavenge Parallel Old新生代老年代多线程并行高吞吐计算密集型后台服务JDK 8默认可选CMS老年代并发低停顿对响应要求高的服务JDK 14移除G1全堆分Region并发并行可预测停顿服务端多核大内存JDK 9起默认长期可用ZGC全堆分Region并发超低停顿超大堆、极低延迟JDK 15转正仍在演进5. 垃圾回收器调优实战参数、日志与排查了解了收集器家族下一步就是落地实操。调优不是玄学也不是说堆内存调大就完事了。我的经验是先合理设置基础参数再通过GC日志定位问题最后做针对性调整每一步都要有数据支撑。5.1 核心JVM参数速查一份典型的JVM参数往往长这样java -Xms4g -Xmx4g -Xmn1g \ -XX:SurvivorRatio8 \ -XX:UseG1GC \ -XX:MaxGCPauseMillis200 \ -XX:PrintGCDetails \ -XX:PrintGCDateStamps \ -Xloggc:/data/logs/gc.log \ -XX:HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath/data/logs/heapdump.hprof \ -jar myapp.jar逐个拆开说关键参数的含义-Xms和-Xmx初始堆大小和最大堆大小。生产环境强烈建议设成相同值防止JVM在运行期动态扩容、缩容带来额外的系统调用开销同时便于准确评估内存水位-Xmn新生代大小。一般建议占堆的1/3到1/4新生代太小导致对象频繁晋升老年代太大又压缩老年代空间-XX:SurvivorRatio8Eden区和Survivor区的比例8代表Eden:S0:S18:1:1。过大会浪费交区域过小会导致S区放不下存活对象直接晋升-XX:MaxGCPauseMillisG1的停顿目标软目标-XloggcGC日志路径。JDK 8及以后建议用-Xlog:gc*:filegc.log这种统一日志语法字段更丰富-XX:HeapDumpOnOutOfMemoryErrorOOM时自动导出堆快照排查内存问题的必备开关-XX:PrintGCDetails/-XX:PrintGCDateStamps打印GC详细日志和时间戳这两个参数在JDK 8、9的排查阶段几乎必带有个细节值得注意-XX:UseAdaptiveSizePolicy在Parallel收集器下默认开启会自动动态调整新生代比例和晋升阈值。如果你的目的是稳定可预期的GC建议显式关闭否则你以为你手写了参数实际JVM在后台自行优化排查时容易产生“认知偏差”。5.2 GC日志解读技巧GC日志是调优的“仪表盘”不会读日志等于蒙着眼睛开车。看一段G1的日志[GC pause (G1 Evacuation Pause) (young), 0.0123456 secs] [Parallel Time: 8.9 ms, GC Workers: 8] [Eden: 512.0M(512.0M)-0.0B(460.0M) Survivors: 16.0M-32.0M Heap: 640.0M(8192.0M)-469.0M(8192.0M)] [Times: user0.06 sys0.01, real0.01 secs]重点看三个指标Eden: 512.0M(512.0M)-0.0B(460.0M)Eden从满的512M降到0说明这轮GC把Eden清了Survivors: 16.0M-32.0M存活对象在S区中的容量翻了倍说明这次晋升压力不小需要关注存活率user/sys/real用户态CPU时间、内核态CPU时间和实际墙钟时间。user远大于real说明GC并行执行得很好user接近real可能说明收集器串行阶段多再看一条混合回收[GC pause (G1 Humongous Allocation) (mixed), 0.0234567 secs]带“Humongous Allocation”说明触发原因是大对象分配。这种GC频繁出现时代码里一定有大数组或大集合在反复创建日志分析的价值不只是看回收效果更是为了倒推代码问题。5.3 性能问题排查复盘分享一个真实案例。某次活动期间服务出现周期性接口超时CPU使用率却不高监控面板上看到Young GC频率每分钟几十次Full GC每五分钟一次老年代回收后使用率下降不明显。排查路径是这样的先看GC日志确认Full GC的触发原因是老年代空间不足回收效果差再做堆dump用MAT分析了支配树发现有个静态Map里缓存了海量业务数据而且只加不删key是用户IDvalue是用户最近一段时间的操作记录集合修复方式很简单改用带过期时间的本地缓存Caffeine并加上最大条数限制上线后Full GC直接消失Young GC频率也降到每分钟几次这事的经验其实就一句话内存问题的根源十有八九在代码不经意的持有收集器只是照章办事的回收到期空间。遇到GC频繁先别急着调大堆先查是谁占着不放手。另外还有个常见的坑很多人堆OOM都习惯先堆ulp调节-Xmx但很多时候问题出在线程栈太深、元空间类加载器泄漏或堆外内存DirectBuffer没释放表现完全不同。典型例子是Netty框架下的Direct Buffer泄漏堆内存看着一切正常但系统内存被吃满了。这种情况下参数再怎么调都救不了必须排查堆外内存分配和释放路径。6. 常见问题速查与避坑指南把运营维护过程中最常遇到的问题整理成一张速查表陪着做过的每一次止损当时看的就是这些点。现象可能原因排查手段处理方向频繁Young GCEden区设置过小/对象分配速率过高GC日志看Eden回收前后容量调大新生代优化代码减少短命对象频繁Full GC老年代空间紧张/碎片化堆dump分析存活对象排查强引用泄漏考虑切G1/ZGCGC后老年代使用率未明显下降大量对象长期存活MAT查支配树找静态缓存/连接池泄漏Concurrent Mode FailureCMS老年代被耗尽GC日志查退化触发尽早迁移到G1元空间OOM类加载器未卸载/动态生成类过多监控Metaspace使用率检查反射、CGLIB、热部署逻辑堆内存不高但系统内存高DirectBuffer/线程栈泄漏用Native Memory TrackingNMT排查Netty/自定义NIO资源清理这几个场景我几乎都在线上碰过尤其“老年代使用率未明显下降”这条基本每次都是某段代码把对象长期持有而不是GC配置坏了。所以调优的关键在“先复核代码再微调参数”参数调参能掩蔽一时但没法掩盖病根。最后再分享一个小技巧调优前先给JVM加上-XX:PrintFlagsFinalJDK 8用-XX:PrintFlagsFinalJDK 9用-XX:PrintFlagsFinal或启动时加-Xlog:gc*启动后可以看到所有JVM参数的最终生效值。修订参数前先用这个命令确认你的设置真正生效了这个习惯能帮你避免很多“调了跟没调一样”的尴尬局面。实际线上问题很多时候不是参数不够好而是参数根本没被JVM吃掉。改完参数也要记得观察几天GC曲线确认稳定再广谱。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进