十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

JVM垃圾回收全解析:从GC算法到G1调优实战,解决Full GC卡顿

JVM垃圾回收全解析:从GC算法到G1调优实战,解决Full GC卡顿 Java程序跑得好好的突然某个时刻整个服务卡住几秒钟CPU飙高日志里出现大段大段的GC日志——这种场景做后端的朋友应该都不陌生。很多人第一反应是是不是代码死循环了排查半天发现业务代码完全正常最后定位到是Full GC在作祟。这其实就是JVM垃圾回收机制的典型表现它在帮你管理内存但也可能在不合适的时间点停下来做整理造成应用卡顿。GCGarbage Collection垃圾回收这个机制是Java相比C/C最核心的差异之一。它把内存管理的脏活累活从程序员手里接了过去代价是我们在享受自动内存管理的同时也必须理解它背后的算法逻辑和不同回收器的行为差异。我个人觉得GC相关的内容是Java开发者从会写代码进阶到懂JVM的一道必经门槛也是面试中被问得最多的知识点之一。这篇文章不做那种碎片化的八股文罗列而是把GC算法和垃圾回收器这条线完整串起来讲清楚它们解决什么问题、各自的取舍在哪里、实际项目中怎么选型怎么调优。1. GC不是倒垃圾——先搞懂JVM内存到底在管什么很多人一上来就背可达性分析、复制算法、CMS、G1这些名词但忽略了一个前提GC管的是哪块内存为什么要把它划分成不同的区域这块不搞清楚后面所有算法都像是在空中盖楼。1.1 堆内存划分新生代与老年代的真正意义JVM的内存区域其实分好几块但GC主要作用的区域是堆Heap。堆里面又分成新生代Young Generation和老年代Old Generation新生代里面还细分为Eden区、From Survivor区S0、To Survivor区S1。这个结构不是拍脑袋设计的它背后的逻辑是对象生命周期的不均匀分布。绝大多数Java对象都是朝生夕死的。比如在一个Web请求里创建的对象通常是用来承载中间计算结果的请求结束之后它们就没有用处了。有研究表明新生代里98%左右的对象存活时间极短。如果每次回收都把整个堆扫描一遍成本太高如果每次回收都把存活对象搬来搬去也扛不住。所以设计者把堆分成两块新生代专门处理那些活不久的对象用复制算法快速清理老年代放那些熬过了多次回收、生命周期较长的对象用标记-清除或标记-整理来处理。这里有个关键参数叫-Xmn用来指定新生代的大小。新生代设多大是有讲究的设大了Eden区能容纳更多对象Minor GC触发的频率会降低但老年代空间被压缩Full GC反而更容易发生设小了Minor GC频繁触发对象没活多久就被频繁复制性能反而更差。这个参数没有固定推荐值要看应用实际的对象分配速率和存活情况来定。还有两个细节经常被忽略。第一JVM会动态调整新生代和老年代的比例这个机制叫动态年龄判定和堆目标大小自适应Ergonomics。第二大对象比如很长的数组、大列表会直接进入老年代不走新生代。也就是说-XX:PretenureSizeThreshold参数可以设置一个阈值超过这个大小的对象直接分配到老年代避免在Eden区和两个Survivor区之间发生大对象复制白白浪费性能。我见过很多团队忽视这个参数导致大量大对象在新生代反复复制GC时间异常升高加了这个阈值之后问题立刻缓解。1.2 对象年龄与晋升机制对象如何熬进老年代对象不是平白无故从新生代搬到老年代的它有一套晋升机制。每经历一次Minor GC对象如果还能存活下来它的年龄就加1年龄记录在对象头的Mark Word里。当年龄达到-XX:MaxTenuringThreshold设定的阈值默认15它就会被晋升到老年代。但这里面有个容易忽略的规则动态年龄判定。JVM不是死板地等对象年龄到15才晋升而是会统计Survivor区里相同年龄对象的总大小。如果发现某个年龄的所有对象大小之和已经超过了Survivor区容量的一半那么年龄大于等于这个值的对象就会提前晋升。这个设计的目的是防止Survivor区被长期存活的钉子户对象占满导致新生代无法正常运作。实际排查问题时这个机制能解释很多奇怪的现象。比如你明明设置了MaxTenuringThreshold15但GC日志里显示对象才活了几个轮回就进了老年代这时候看看是不是动态年龄判定触发了。另外需要注意的是NewRatio参数默认2控制新生代和老年代的空间比例但这两个参数不是独立起作用的它们配合之后直接影响Minor GC的频率和晋升速率。调优的时候要一起看。2. 判断对象生死可达性分析的来龙去脉GC要回收对象首先得知道哪些对象是死的。判断生死的算法主要有两种引用计数法和可达性分析。主流JVM用的是可达性分析但为什么不用引用计数法这个问题能讲清楚的人不多面试却很喜欢问。2.1 引用计数法为何被JVM劝退引用计数法的思路很直接每个对象维护一个计数器被引用一次就加1引用失效了就减1计数器归零就回收。Python的垃圾回收机制里就使用了引用计数搭配循环检测作为基础方案。这套方案实现简单、执行效率高但它有一个致命缺陷处理不了循环引用。假设A对象持有了B对象的引用B对象也持有了A对象的引用除此之外没有其他任何引用指向它们。引用计数法里A和B的计数器都是1永远不会归零但这块内存实际上已经没法被外部访问到了。在Java这种存在大量对象依赖关系的语言里循环引用太常见了这个缺陷几乎是致命的。虽然可以通过额外的算法解决但会让复杂度爆炸性上升得不偿失。2.2 GC Roots可达性分析的起点集合可达性分析的核心思路是逆向找根。它从一组称为GC Roots的根节点出发沿着引用链向下搜索。能一路搜索到的对象就是可达的暂时不回收凡是无法从GC Roots出发到达的对象就会被判定为可回收对象。GC Roots包括哪些主要分为四类虚拟机栈中局部变量表里引用的对象也就是当前正在执行的方法里的局部变量方法区中类静态属性引用的对象static变量方法区中常量引用的对象字符串常量池里的引用本地方法栈中JNI引用的对象这里有个容易被误解的地方可达性分析只是判定是否被引用并不代表对象一定会马上被回收。一个对象如果经过可达性分析被标记为不可达它还有机会自救——条件是重写finalize()方法并且在回收前被重新与GC Roots建立关联。但我必须说finalize()机制官方已经废弃了强烈不建议使用。它执行时机不确定、性能开销大、还可能因为异常导致对象复活造成更严重的问题。处理资源释放请用try-with-resources或显式关闭。2.3 四种引用类型不只是强与弱的区别Java从JDK 1.2开始把引用分成了四种类型从强到弱依次是强引用、软引用、弱引用、虚引用。它们在GC中的表现和作用完全不同。强引用就是我们平时写的Object obj new Object()这种只要强引用还在对象永远不会被回收。软引用SoftReference在内存充足时不回收在内存不足时即将抛出OutOfMemoryError之前回收适合做缓存比如图片缓存、大对象缓存。弱引用WeakReference在下次GC时无论内存是否充足都会被回收ThreadLocal里的ThreadLocalMap的key用的就是弱引用用来避免内存泄漏。虚引用PhantomReference最特殊它不能通过引用获取对象引用被回收后会收到一个通知主要用来跟踪对象被回收的时间常见的用途是堆外内存DirectByteBuffer的回收通知。有个经典问题弱引用能不能解决内存泄漏答案是需要配合逻辑才能解决。拿ThreadLocal举例如果ThreadLocalMap的key用强引用那么线程存活期间ThreadLocal对象永远无法被回收即使业务代码已经不需要它了。用弱引用做key至少ThreadLocal对象本身可以被回收但value依然是强引用如果不调用remove()value还是会被钉在ThreadLocalMap里。所以正确使用ThreadLocal的姿势是用完就调remove()而不是指望弱引用。3. 三大基础GC算法复制、标记-清除、标记-整理的取舍你听到的G1、CMS、ZGC名字再怎么花哨底层核心逻辑都逃不开三种基础算法复制、标记-清除、标记-整理。理解了这三者的取舍再去看各种回收器就是在看同一道食材的不同做法。3.1 复制算法新生代的默认选择复制算法的思路是把内存分成两块比如Eden和Survivor每次只用其中一块。回收时把还存活的对象复制到另一块未用的空间里然后把原来那块空间整体清空。这样实现极其简单内存连续无碎片分配新对象只需要移动指针就行。代价也很明显内存有浪费。如果按1:1切分那永远有半块内存闲着没法用。所以实际使用中JVM的Eden和Survivor比例是8:1:1而不是1:1。这样浪费的空间只有10%。回收时Eden区里绝大部分对象已经死亡只需要把少量存活对象复制到Survivor区效率极高。但是如果存活对象很多复制成本就很高。所以复制算法只适合对象死亡率极高的场景这正好对应新生代的特征。反过来说如果应用有大量长生命周期对象频繁进入新生代Minor GC就会经常做沉重的复制工作表现为GC时间变长——这时就要考虑是不是晋升阈值设置不合理或者对象分配速率有问题。3.2 标记-清除朴素但有碎片化后遗症标记-清除算法分两步先遍历所有对象标记出所有需要回收的对象然后再遍历一遍回收那些被标记的对象。它不需要移动存活对象也不会像复制算法那样浪费额外内存实现非常简单直接。但它的致命问题是内存碎片。回收完之后可用内存变成了一段一段不连续的空间。当需要分配一个大对象时明明总空闲内存够用却因为找不到连续的空间而触发Full GC甚至直接OutOfMemoryError。这就像一间堆满杂物的仓库你清掉了其中一些箱子但剩余的空间零碎分布在各个角落一个大衣柜根本放不进去。CMS回收器的老年代回收用的就是标记-清除思路所以它天生有碎片问题运行久了必须依赖Full GC来整理内存。这也是CMS终将被淘汰的根源之一。3.3 标记-整理解决碎片化的折中方案既然标记-清除会产生碎片那就在它基础上加一步整理把所有存活对象往内存一端移动然后清理掉边界以外的内存。这样既解决了碎片问题又不需要像复制算法那样预留一大块空闲区域。缺点是对象移动需要更新所有引用关系这一步相当耗时。而且移动过程中对象的引用地址会发生变化如果期间有其他线程在访问对象就会出现问题。这也是为什么早期的标记-整理算法通常需要Stop-The-WorldSTW——暂停所有应用线程来执行GC。不过现代收集器比如G1已经能做到部分整理并发进行不是完全停顿。但核心原则没变整理意味着移动移动意味着成本成本意味着停顿。所以各种新收集器的优化方向本质上都是在减少停顿和减少对象移动之间做平衡。3.4 分代收集把三种算法组合成一套组合拳理解了三种基础算法分代收集就很好懂了。它不是一个独立的算法而是一个策略根据不同内存区域对象的特点组合使用不同的算法。新生代对象存活率低用复制算法快且无碎片老年代对象存活率高空间也大不适合频繁复制用标记-清除或标记-整理避免大量对象搬移的开销。JVM默认的Parallel Scavenge Parallel Old组合新生代走复制、老年代走标记-整理CMS ParNew组合新生代走复制、老年代走标记-清除后面配合Serial Old做整理兜底。每种组合的差异本质上就是新生代和老年代算法选择上的差异。这个设计给了开发者一个很重要的启示GC调优不是单纯改几个堆大小参数而是要理解你的应用对象在新生代和老年代之间的分布情况。如果Young区频繁GC但每次回收率很高说明对象在快速死亡这是健康的如果每次Minor GC后存活对象比例很高、大量晋升老年代那就要看看是不是有对象被错误年龄晋升了或者根本就是对象太多了。4. 从Serial到ZGC主流垃圾回收器全景剖析基础算法是内功实际运行的垃圾回收器则是招式。JDK迭代了这么多版本回收器也从最早的Serial发展到现在的ZGC、Shenandoah。每一代回收器的核心矛盾都是在响应时间、吞吐量、实现复杂度之间做取舍。4.1 Serial与Serial Old单线程回收器的简单粗暴Serial是最古老、最简单的回收器新生代和老年代回收都只有单线程执行回收时必须STW。在单核CPU的老机器上Serial反而是最高效的选择因为它没有线程切换开销。但在现代多核服务器上单线程回收时其他核心都闲着显然浪费。Serial Old作为CMS的后备方案专门在CMS出现并发失败Concurrent Mode Failure时降级使用做老年代的Full GC回收。虽然它慢但至少能兜底不至于让程序直接崩溃。现在只在客户端模式或极小堆内存的场景下还能看到它的身影。4.2 Parallel与Parallel Old追求吞吐量的性能党Parallel Scavenge新生代和Parallel Old老年代是JDK 8默认的收集器组合。它的核心关注点是吞吐量也就是运行用户代码的时间 /运行用户代码的时间 GC时间。它提供了两个好用的参数-XX:MaxGCPauseMillis和-XX:GCTimeRatio前者控制最大GC停顿时间后者控制吞吐量目标。但这里有个天真的地方要注意把MaxGCPauseMillis设得很小比如50ms并不代表GC真的会做到50ms以内。JVM会努力通过调整堆大小、新生代比例等方式向这个目标靠拢代价是GC频率变高总吞吐量下降。所以追求低停顿和追求高吞吐在某种程度上是对立的调优的人需要明确应用的优先级。Parallel组合适合对吞吐量要求高、对延迟要求不敏感的后台批处理任务、离线计算场景。比如一次性跑大批数据处理的Job停顿几秒问题不大只要总执行时间短就行。4.3 CMS第一款面向低延迟的并发回收器得与失CMSConcurrent Mark Sweep是第一款真正意义上的并发回收器它的目标是把STW时间尽可能缩短。老年代回收的标记阶段大部分与用户线程并发执行只有初始标记和重新标记需要短暂STW。CMS有四个阶段初始标记、并发标记、重新标记、并发清除。初始标记和重新标记STW时间都很短毫秒级并发标记和并发清除与业务线程同时跑所以单次停顿非常小在当时是很惊艳的设计。但CMS的缺点也很明显使用标记-清除导致内存碎片并发阶段会占用CPU资源导致吞吐量下降最要命的是无法处理浮动垃圾如果并发标记期间业务线程产生了新垃圾回收器可能来不及处理导致Concurrent Mode Failure然后触发Full GC用Serial Old做完整的STW标记-整理停顿反而更长。CMS在JDK 9开始被标记为废弃JDK 14正式移除。虽然它退出了历史舞台但它的并发标记思路对后来的G1和ZGC影响深远。可以说CMS是现代低延迟收集器的开山鼻祖。4.4 G1把堆拆成一个个Region的全能选手G1Garbage First从JDK 9开始成为默认收集器直到JDK 21依然是默认。它最大的变化是不再严格区分新生代和老年代的物理连续空间。整个堆被划分成大约2048个大小相等的Region每个Region在逻辑上可以扮演Eden、Survivor、Old或者Humongous大对象区的角色。G1的核心设计是预测停顿时间模型。它跟踪每个Region的回收收益回收能释放多少空间、耗时多久然后通过维护一个优先级列表优先回收那些垃圾最多、回收最快的Region从而在有限的停顿时间内获得最大的回收收益。这个思路叫Garbage First也就是先清垃圾最多的地方。G1可以设定-XX:MaxGCPauseMillis为软目标默认200ms它会根据历史数据动态调整Region大小和回收节奏。但这东西不是万能的如果应用对象分配速率极高或者堆很大且垃圾对象比例很高G1可能来不及回收软目标就会失效最终导致Mixed GC和Full GC频繁发生。G1适合大堆几GB到几十GB、延迟要求中等的服务端应用是目前绝大多数Java后端服务的正确默认选择。4.5 ZGC与Shenandoah低延迟时代的极客玩具还是生产利器ZGCJDK 11引入JDK 15转为正式和ShenandoahJDK 12引入的设计目标非常激进让GC停顿时间不超过10毫秒而且不随堆大小变化。它们的实现用到了染色指针Colored Pointer、读屏障Load Barrier等高级技术实现了几乎全并发的标记、转移和重定位堆内存再大也能保持极低停顿。ZGC的重要特点是不分代JDK 21引入了分代ZGC它通过染色指针把对象状态记录在指针的某些位上配合读屏障在用户线程读取对象时就能感知到对象是否被移走了如果移走了就转发到新地址。这样就不需要像G1那样STW来做转移。ZGC适合超大堆几百GB甚至几TB和极低延迟要求的场景比如高频交易、在线游戏服务器。Shenandoah和ZGC思路类似但用的是转发表加读屏障的方式而不是染色指针它在JDK 12之后也在持续演进。不过说实话在目前大多数企业的应用规模下G1已经够用了。ZGC的调优空间和运维难度都更高如果是普通Web应用没有必要为了新技术而冒险切换。下面我整理了一张主流回收器的对比表方便查阅回收器适用代算法核心优势核心劣势适用场景Serial新生代复制简单、单核高效STW较长客户端、小堆Serial Old老年代标记-整理简单、兜底STW极长CMS失败后备Parallel Scavenge新生代复制吞吐量高停顿不可控批处理、离线计算Parallel Old老年代标记-整理吞吐量高停顿不可控批处理、离线计算ParNew新生代复制可与CMS配合单线程场景无优势CMS时代组合CMS老年代标记-清除并发、低停顿碎片、CPU开销已废弃G1全堆复制标记-整理可预测停顿、大堆友好小堆优势不明显服务端默认ZGC全堆并发标记-复制极低停顿、超大堆调优复杂高要求低延迟Shenandoah全堆并发标记-复制极低停顿调优复杂高要求低延迟5. 实战中的GC排错与调优一次线上Full GC问题的完整排查前面讲了很多原理但大家真正头疼的场景是线上服务时不时卡顿不知道该怎么定位。我拿一次真实的线上排查经历为例走一遍完整的GC调优排查链路这里面包含了我踩过坑之后沉淀下来的方法希望能帮大家省点时间。5.1 第一步从GC日志和监控指标确认问题真的出在GC遇到服务卡顿别急着改参数先确认问题是否由GC引起。打开GC日志启动参数加-Xlog:gc*如果是JDK 8及之前用-XX:PrintGCDetails -XX:PrintGCDateStamps -XX:PrintGCTimeStamps或者直接看监控平台里的GC相关指标。我当时的情况是服务每过两个小时就会出现一次长达5到8秒的卡顿期间健康检查探针超时导致实例被负载均衡踢掉流量进来又触发新一轮GC形成恶性循环。打开GC日志后发现每次卡顿都对应一次Full GC而且堆内存的老年代在Full GC之前已经飙到了90%以上Full GC之后能降到30%。这说明老年代空间被大量生命周期很长的对象占满了。判断要点如果服务卡顿时间和Full GC时间高度吻合基本可以锁定问题与GC有关。这时候再结合JVM监控里的堆内存使用曲线、GC频率和GC耗时就能初步判断问题方向。5.2 第二步用堆转储定位谁占满了老年代确认问题在GC之后就需要搞清楚老年代里到底是什么对象。推荐的工具是jmap -dump:live,formatb,fileheap.hprof pid导出堆转储然后用MATMemory Analyzer Tool或者Eclipse MAT插件分析。注意导出大堆时服务会卡顿最好在低峰期操作或者用jmap -dump的时候配合-F强制模式但能不用尽量不用。我那次导出的堆转储大概有6GB用MAT打开后Dominator Tree视图里一眼就看到一个缓存Map占了将近70%的老年代空间。那个Map是团队里某个同事为了实现本地缓存写的key是用户IDvalue是一个复杂的业务对象既没有设置过期时间也没有容量上限。看起来是个缓存实际上变成了一个无限增长的内存黑洞。定位到具体对象之后解法反而简单了直接改用Caffeine这种自带过期策略和容量限制的本地缓存框架并且把最大条数限制在5万条。上线后老年代内存使用率稳定在40%上下Full GC再也没出现过。5.3 第三步参数调优不只是改大小在确认没有明显内存泄漏后还有一种常见情况是老年代确实需要更多空间。这时候可以做参数微调但顺序很重要先调整堆大小再调整新生代比例最后再动回收器选型。我当时遇到过另一个案例应用堆内存设置为2GB默认G1回收器高峰期出现频繁的Mixed GC。用GC日志看Region分布发现新生代被大量短期对象占满但对象分配速率太高G1的回收速度跟不上分配速度。解决方案很简单把-Xmx从2GB调到4GB给堆更多缓冲空间问题直接消失。但要注意调大堆不一定总是好事。堆越大单次GC的扫描范围越大GC停顿时间可能反而变长而且如果对象本身就有大量不可回收的垃圾堆越大只是延迟了Full GC的到来并不能根治问题。正确的思路是先确认有没有内存泄漏用堆转储排查再确认对象的存活周期是否合理最后才考虑要不要调大堆。5.4 常见参数速查与调优经验对于G1回收器我最常调的几个参数-XX:G1HeapRegionSizeRegion大小1MB到32MB。默认情况下G1会根据堆大小自动计算Region数量目标约2048个Region。如果堆很大可以把Region调大一点减少大对象跨Region的情况。-XX:MaxGCPauseMillis软目标停顿时间。它不是硬性指标不要设得过小否则会以增加GC频率为代价。一般200ms是比较合理的起点。-XX:InitiatingHeapOccupancyPercent触发混合回收的堆占用百分比默认45%。堆大、老年代增长慢的话可以适当调高到55%~60%减少GC触发频率。-XX:ConcGCThreads并发标记线程数默认值是总CPU核数的20%左右。并发阶段会占用CPU如果业务对CPU敏感可以适当减少。对于Parallel组合-XX:MaxGCPauseMillis尽量不设太低否则GC会频繁发生拖垮吞吐量。-XX:GCTimeRatio默认99表示GC时间最多占总时间的1%。按需调整。-XX:SurvivorRatioEden与Survivor的比例默认8可以适度调整到5或6给Survivor更多空间减少对象过早晋升老年代。调优是先观测、再假设、后验证的循环。改一个参数、看一段时间的GC趋势不要一次改一堆参数否则出了问题根本不知道是哪个引起的。6. 关于GC这个话题最后想说的几个反直觉结论写了这么多原理和实战内容我再补充几个容易被误解的点这些结论往往是经验丰富的开发者踩过坑之后才总结出来的。第一个反直觉的结论是GC调优的终极目标不是让GC时间为零而是让GC的行为可控可预测。完全消除GC停顿在目前的技术条件下是不可能的——除非你根本不创建对象而这是不可能的。与其纠结把停顿从50ms压到30ms不如先控制住GC发生的频率和位置避免在高峰期突然来个Full GC。第二个反直觉的结论是默认的垃圾回收器往往不是最优的但它通常是足够好的。G1作为默认收集器在大多数服务端场景下表现稳定。很多人一上来就换ZGC觉得既然ZGC停顿低那就用ZGC结果出现了莫名其妙的性能和兼容性问题。我的建议是除非G1已经明显无法满足延迟要求否则不要轻易切换到ZGC或Shenandoah。它们确实优秀但更适合特定场景。第三个反直觉的结论是GC调优很多时候调的不是GC而是代码。我在实际工作中遇到的大部分GC问题根因都是代码层面可以解决的——大对象被频繁创建、无界缓存、不必要的对象持有、连接没有关闭导致资源泄漏等。JVM参数只是止痛药代码质量才是根治方案。别把调优当成背参数和抄配置先把代码写好GC自然就安静了。第四个建议是关于学习路径的。如果你正在准备Java面试不要把GC相关的知识背成八股文死记硬背Serial、Parallel、CMS、G1的特点和作用。更好的方式是把你正在写的应用跑起来用jstat -gcutil pid 1000观察它的GC趋势用jmap看堆内存分布遇到问题后用GC日志和堆转储去定位原因。把背知识点变成解决问题知识才算真正内化。我个人的体会是JVM和GC的内容越深入越发现它和业务代码的性能边界紧密相连。搞懂了GC你不光能解决线上卡顿还能在设计数据模型、选择缓存方案、评估并发策略时做出更合理的判断。这也是这篇文章想传达的核心理念——GC不是面试题里的一个章节而是Java开发者理解运行时性能的基石。
返回列表