ARTICLE DETAIL

资讯详情

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

JVM垃圾回收调优实战:从分代假说到CMS与G1,一文吃透Full GC

JVM垃圾回收调优实战:从分代假说到CMS与G1,一文吃透Full GC 上周排查一个线上服务现象很典型CPU不高、内存看着也够但请求延迟每隔半小时就有一个尖刺。jstat一查Full GC次数在一小时里从个位数飙到三十几次老年代在两次Full GC之间就涨了几个G。这种场面对写Java的同学来说算是噩梦级的熟悉。但问题本身并不难难的是很多人对垃圾回收的理解停留在“会调-Xmx”的层面一遇到GC瓶颈就盲目改堆大小最后越调越乱。按这个系列的进度“上”篇聊完了JVM的运行时数据区和对象存活判定这篇“中”篇正好往深里走一步分代假说到底在说什么三种经典回收算法为什么谁都没能独大CMS和G1的演进究竟在解决什么问题以及一套可以直接抄进Tomcat启动脚本的参数清单。整个系列的目标读者很明确——正在为线上Full GC发愁的人、准备面试时被JVM问懵的人以及想真正理解GC设计取舍而不是只背概念的开发者。这篇不会堆概念所有内容都尽量贴着实战场景讲很多细节是我自己踩过坑之后才真正想通的。1. 分代假说JVM堆内存拆成两段的底层逻辑很多人一上来就背“新生代、老年代、Eden、Survivor”但从来没有人讲清楚JVM为什么非要把堆拆成两段来回收直接整个堆一起清不行吗要回答这个问题得先接受一个统计性观察——这个观察在GC设计里叫分代假说。1.1 两个假说为什么大量对象活不过一场Minor GC分代假说本质上是两个经验规律弱分代假说绝大多数对象都是“朝生夕死”的创建之后很快就变成垃圾。强分代假说熬过越多次GC的对象越有可能继续存活下去。这两个规律听起来像废话但它是整个分代收集策略的地基。只要微观世界里你new过的对象大多活不了太久那让GC每次都从整个堆里找垃圾就是巨大的浪费——绝大多数时间你都在反复扫描老年代里那堆其实很稳定的长命对象。信不信由你HotSpot的社区经验数据显示80%甚至90%以上的对象在第一轮Minor GC时就已经变得不可达。这批对象集中在新生代回收它们的成本非常低。而老年代里的对象存活率普遍高不值得频繁清理。基于这个观察JVM把堆拆成**新生代Young Generation和老年代Old Generation**两段物理区域新生代的GC叫Minor GC老年代的GC叫Major GC老年代专用或者Full GC整堆回收。服务端调优时我们最关心的其实就是这两个代各自的空间比例和回收频率。1.2 内存区域划分JVM堆里的Eden、Survivor和老年代如何呼应假说再往细里看新生代又被切成了三个区一个Eden伊甸园加两个Survivor幸存者区通常叫S0和S1。默认比例是8:1:1。这套切分不是拍脑袋定的它和“标记-复制”算法强相关。Eden是所有普通对象诞生的地方S0/S1负责承接Minor GC后仍存活的对象。每次Minor GC发生时JVM把Eden和正在使用的那个Survivor里存活的对象统一复制到另一个空闲Survivor然后清空Eden和旧Survivor整个过程不需要碎片整理。由于绝大多数对象在Eden里就已经死了真正需要拷贝到Survivor的数量很小所以把Eden划得很大、Survivor划得很小是划算的——内存利用率能接近90%。说白了分代是一种布局优化而不是一种具体算法。它利用的是对象生命周期的统计规律把回收成本集中在最可能有垃圾的区域同时让老年代少被折腾。1.3 分代的意义GC Roots和全堆扫描的关系还有一个关键点必须提一下无论回收哪个代判断对象是否存活都要从GC Roots出发做可达性分析。通俗讲GC Roots就是一组“根引用”——线程栈里的局部变量、静态变量、JNI引用、活跃线程对象等。从根出发能遍历到的对象都视为存活遍历不到的就是垃圾。在上篇里我已经详细聊过GC Roots这里只补充一个容易误解的点Minor GC并不是不看其他区域。新生代回收时如果老年代对象引用了新生代对象那这个新生代对象不能被当成垃圾。为了处理这种跨代引用JVM用了一种叫**卡表Card Table**的结构把老年代按区域划分标记哪些卡页有跨代引用这样Minor GC扫描时不必全量扫老年代。很多调优事故都是因为对这块理解不到位以为“Minor GC只动Eden”才误判了观察数据。2. 三种经典回收算法没有谁的完美只有谁的合适分代搞定了“在哪里回收”接下来的问题是“用什么方式回收”。常说的**标记-清除Mark-Sweep、标记-复制Mark-Copy、标记-整理Mark-Compact**三种算法名字里都带“标记”因为它们的第一步完全一样从GC Roots出发把存活对象打个标。区别全在第二步怎么处理垃圾和存活对象的关系。2.1 标记-清除最直白的思路但留下了碎片难题标记-清除的逻辑最简单先标记所有存活对象第二步把没有标记的内存区回收掉。听起来效率很高因为不清扫存活对象垃圾内存直接“晾着”等待后续分配。它的致命伤是内存碎片。想象一个书架上摆满大小不一的旧书你抽走几本后空出来的缝隙零散分布新书根本塞不进去。JVM的分配器在堆上找连续内存时如果可用空间是一地碎片一个大数组或者大对象就可能分配失败逼不得已触发Full GC或直接抛OOM。另外标记和清除两个阶段的工作量都和堆大小成正比堆一大整体效率就上不去。CMS是这套算法最出名的使用者后面会聊到——它的衰落很大程度上就是为碎片问题买单。2.2 标记-复制拿空间换时间新生代的赢家标记-复制的思路不是原地清理而是把堆分成两块只使用其中一块。GC时把存活对象整体拷贝到另一块空白区域再一次性把原来那一整块清空。因为拷贝之后所有存活对象挤在一起分配新对象永远只需要移动一个指针天然无碎片。空间换来了时间代价是内存利用率低。如果傻乎乎地对半分那真浪费了一半内存。HotSpot没有这么干它假设“绝大多数对象会被回收”于是设计成Eden占大头、Survivor占小头GC时把存活对象平移到一块较小的Survivor里。这就是上节说的8:1:1。当Survivor实在塞不下时怎么办那就要用到分配担保——把多余的存活对象直接送进老年代。这个决定看起来聪明但也是很多问题的源头如果Survivor容量严重偏小大量“本该留在新生代多熬几轮”的对象会被提前晋升老年代迅速膨胀最终引来更可怕的Full GC。2.3 标记-整理老年代求稳的解决方案老年代的对象存活率高用复制算法不划算——拷贝大量长效对象成本太高。所以老年代通常用标记-整理标记完存活对象后把它们全部往内存一端移动然后清理掉边界以外的空间。既消灭了碎片又不用老搬移对象。代价是整理过程非常“重”存活对象越多移动量越大停顿时间也越长。因此老年代的GC频率必须压得很低否则服务会频繁感受到明显的“世界停止”。Parallel Old和Serial Old都走这个路线。2.4 组合拳与分配担保机制现实中JVM不是只用一种算法而是“一鱼两吃”新生代用标记-复制老年代用标记-整理或标记-清除。CMS的特殊性在于它在老年代使用了标记-清除的并行变种结果吃了碎片的大亏而G1在逻辑上把堆拆成Region宏观上走混合回收但微观上每个Region的存活对象也是靠复制和整理来移动的。理解这三种算法后再看一个实战里经常冒出来的概念空间分配担保。前面说了Minor GC之后如果Survivor装不下存活对象多出来的对象会直接进老年代。进老年代之前JVM会判断老年代剩余空间是否足够容纳“历史晋升对象的大致规模”。不够时就会提前触发Full GC或者直接抛Promotion Failed。很多线上服务莫名抖动就是这条担保链路的某个环节出了漏洞对象被过度晋升。3. 收集器演进史从Serial到G1停顿时间是怎样被一步步压低的分代假说和算法是理论底座真正落地的是一批批具体的垃圾收集器。从JDK诞生到现在收集器的演进主线可以用一句话概括在“吞吐量”和“停顿时间”之间不断找平衡。这条线比背一串名词有意思得多。3.1 单线程时代Serial和Parallel的取舍最早的Serial收集器是单线程GCGC时必须暂停所有应用线程Stop The WorldSTW。它简单可靠至今仍可用于小堆、客户端应用或调试环境。Parallel Scavenge在同一时代主打多线程并行回收把吞吐量当作核心指标——吞吐量运行用户代码时间 /运行用户代码时间GC时间。配合Parallel Old后成为很长一段时间内服务端默认收集器。选择它们意味着你接受“GC时整段业务停顿”换取“每秒吞吐够高”。早年大数据批处理、离线任务就吃这一套因为跑一次任务动辄几小时多停顿几秒无感但吞吐量高能明显缩短总耗时。3.2 CMS并发收集的破局者成也并发败也并发CMSConcurrent Mark Sweep的正确历史地位是“第一款真正追求低停顿的收集器”。它把老年代收集拆成四步初始标记STW只标记GC Roots直接可达的对象后者数量少停顿极短。并发标记和应用线程同时运行遍历对象图。这一步很长但业务无感。重新标记STW修正并发期间因引用变动导致的标记偏差。并发清除和应用线程并发清理垃圾。听起来很美现实很骨感。CMS最大的问题是它会一边回收一边产生新垃圾——并发清除阶段业务线程还在new对象这些新垃圾只能留到下一轮称为浮动垃圾。更要命的是因为老年代用了标记-清除而非整理长期运行后碎片越来越严重稍微分配一个大对象就会触发并发模式失败Concurrent Mode FailureCMS直接放弃治疗退化成Serial Old串行Full GC——停顿时间瞬间爆炸。我在生产上见过很多次这种“偶尔一下几十秒的卡顿”查GC日志多半就是CMS退化。正因如此JDK 9之后CMS被标记为废弃JDK 14直接移除。到今天还守着CMS的存量服务应该尽快迁出去。3.3 G1Region化堆把停顿变成可预期G1Garbage First的出现让收集器的设计范式换了个赛道。它不再是“新生代一个物理区、老年代一个物理区”而是把整个堆切成若干大小相等的Region新生代和老年代只是Region的逻辑集合。每个Region既能当Eden也能当Survivor或OldFlexible是关键词。G1为每个Region保存了一份Remembered SetRSet记录谁引用了这个Region里的对象。这样跨Region引用关系被记录下来Minor GC时不需要扫全堆只需处理相关RSet。代价是维护RSet要插入写屏障Write Barrier每次引用字段赋值都要顺手记账这带来一些CPU开销。G1的回收路径分为几步Young GC只清理Eden和部分Survivor速度很快。并发标记与业务线程并发为Mixed GC做准备。Mixed GC不只要收年轻代还选一批老年代Region一起收按停顿目标挑选性价比最高的Region。Full GC并发回收退化为全停顿串行回收通常意味着程序已经有严重问题。这套设计把它变成一个“可以聊停顿目标”的收集器——通过-XX:MaxGCPauseMillis设定软目标G1用积累的历史数据去估算回收成本挑性价比最高的Region回收尽量把停顿压在目标以内。但G1不是银弹实战中常见的坑包括停顿目标设太激进比如50ms导致回收频率猛增、吞吐量下降幸存者区规划不合理产生大量Humongous超大对象连续占多个Region又很难回收另外大堆场景下RSet本身的内存开销也不可小觑。3.4 中篇的一次横向对比到了这一步可以把CMS和G1放一张表里对比着看面试和选型时都有用。维度CMSG1堆模型物理分代老年代连续Region逻辑分代物理连续但可灵活划分老年代算法标记-清除易碎片Region内复制/整理缓解碎片停顿控制尽量低但并发模式失败后可能爆炸有停顿目标增量回收相对可控跨代处理卡表RSet写屏障适用场景JDK8时代的低延迟老项目JDK8u20之后的中大堆、更均衡的新项目现状JDK9废弃JDK14移除JDK9默认收集器再往后还有ZGC和Shenandoah主打超大堆和极低停顿但我计划把它们的细节留到“下篇”展开中篇先把分代、算法和收集器演进这条主线理扎实。4. 对象的一生从Eden诞生到老年代定居晋升路径全拆解搞懂了收集器你会发现真正的实战核心是“对象怎么被分配、被晋升、被回收”。这一节就完整走一遍Java对象的生命周期结合上面的一套理论。4.1 分配路径先走快车道再进Eden一个Java对象诞生不一定立刻进堆。JVM的JIT编译器在做逃逸分析后会把没有逃逸出方法的小对象直接在栈上分配方法一退出内存自然回收完全绕开GC。这是性能最好的路径可惜只有部分纯局部对象享受得到。大多数普通对象会走第二条快车道——TLABThread Local Allocation Buffer。Eden被划分成很多线程私有小块线程在自己那块里分配对象不需要锁竞争效率极高。TLAB空间用完才回到Eden的主空间通过CAS去抢。也就是说对象真正进入托管堆时默认都在Eden。Eden满到一定程度Minor GC触发存活下来的对象被复制到S0接着才有资格谈后续晋升。4.2 晋升规则年龄、动态判定与大对象对象每熬过一次Minor GC年龄age加1。达到-XX:MaxTenuringThreshold默认15的对象会晋升到老年代。但实际中晋升往往比15次更早因为还有一个动态年龄判定当Survivor中某一年龄的对象总大小超过了TargetSurvivorRatio默认为50%系统就会把不小于该年龄的对象全部提前晋升。这也是为什么很多对象看着年龄不大却早早进了老年代。除了年龄还有两类特殊入口大对象直接进老年代通过-XX:PretenureSizeThreshold设置阈值超过大小的对象不经过Eden直接放老年代。这样做的原因是大对象在Eden和Survivor之间来回复制很昂贵还给新生代GC造成压力。分配担保路径Minor GC后如果Survivor装不下剩余存活对象直接晋升老年代。4.3 一个请求对象的完整旅程拿一个电商“下单”接口举例请求进来控制器里new了一批DTO、List、Map还有一个上传用的字节缓冲。大部分对象在方法执行完就不可达了小部分被JIT优化直接栈上分配方法返回即消失剩下的进入Eden经历了一次Minor GC后仅剩少数进入S0。如果这批对象在S区来回拷贝了几轮还没被业务缓存引用释放最终就被晋升到了老年代等待下一次Major/Full GC来终结。这条路径里最容易出事故的点是Survivor容量偏小导致大量存活对象提前晋升。用jstat -gcutil观察时你会看到老年代占比O列持续走高Minor GC不频繁但老年代每几分钟就涨一圈这类信号基本就是晋升节奏出了问题而不是堆不够大。5. 落地到Tomcat一份可上线的GC参数清单与验证方法理论讲得再多最后都要变成启动脚本里的几行参数。以最常见的Tomcat部署为例配置会写在catalina.sh的JAVA_OPTS里。5.1 JAVA_OPTS里到底该写什么下面是我在JDK 8 Tomcat 8.5场景下比较稳妥的一套起始配置直接可抄但抄完要按自己的业务观察去微调# 堆与元空间 JAVA_OPTS-Xms4096m -Xmx4096m -Xmn1536m JAVA_OPTS$JAVA_OPTS -XX:MetaspaceSize512m -XX:MaxMetaspaceSize1g # 收集器与停顿目标 JAVA_OPTS$JAVA_OPTS -XX:UseG1GC -XX:MaxGCPauseMillis200 -XX:G1HeapRegionSize8m # 新生代细节 JAVA_OPTS$JAVA_OPTS -XX:SurvivorRatio8 -XX:MaxTenuringThreshold10 # 故障现场留痕 JAVA_OPTS$JAVA_OPTS -XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/data/logs/heapdump.hprof # GC日志JDK 9 用 -Xlog 统一格式JDK 8 用下面这组 JAVA_OPTS$JAVA_OPTS -XX:PrintGCDetails -XX:PrintGCDateStamps -Xloggc:/data/logs/gc.log如果你用的是JDK 11或17最后一行要换成-Xlog:gc*:file/data/logs/gc.log:time,uptime,level,tags旧参数在新版本里已经识别不出来了。5.2 参数背后的计算与选择很多文章只给参数不给理由这里补上我当时做这些选择的逻辑。-Xms和-Xmx为什么必须相等如果不相等JVM会在业务高峰期堆扩容、低谷期又收缩扩容本身就是一次昂贵的“搬家”还会让GC行为变得不可预测。让起始堆等于最大堆等于告诉JVM“你老老实实把内存吃满别瞎折腾”。-Xmn设多大的问题年轻代设大了Minor GC次数减少但单次停顿变长更关键的是它挤占了老年代老年代太小会让Full GC变得频繁。我的经验是年轻代控制在堆的1/3到1/2之间比较稳不能贪大。为什么用G1而不是Parallel有不少“吞吐量优先”的教程还在推荐Parallel。但现代业务大多是对延迟敏感的在线服务G1的可控停顿更贴心而且JDK 9后它就是默认收集器社区踩坑少资料多。MaxGCPauseMillis真别设太小设成50ms的话G1每次回收的Region数量会很少回收频率变高浮动垃圾来不及清理反而让Full GC风险升高。200ms左右是均衡点某些超低延迟场景再配合ZGC而不是硬调G1。5.3 用jstat和GC日志验证调优效果参数不是调完就完事必须去验证。我的验证三板斧jstat -gcutil pid 1000每秒输出Eden、Survivor、老年代、元空间的使用百分比和GC次数。重点看EEden是不是频繁到100%OOld的长期趋势是持平还是缓慢上涨。jmap -heap pid看当前各代容量和实际使用量确认参数真的生效。GC日志启动参数里已经留了gc.log线上出了问题直接看日志里有没有Concurrent Mode Failure、Humongous Allocation、Promotion Failed这三个高频关键词。5.4 调优现场最常见的三个翻车姿势第一照抄网上的CMS配置到JDK 11上启动直接报错。CMS已经被移除老文章里的-XX:UseConcMarkSweepGC在新版本里是无效参数。第二盲目调大Xmx。我见过一次案例把堆从2G改成6G后Minor GC次数确实降了但因为老年代容量没同步调整对象晋升空间反而变紧张Full GC从原本的每天几次变成每小时几次。堆的大小要和其他代参数联动设计。第三生产环境只装了JRE没装JDK。JRE是JVM加标准类库JDK是JRE再加编译和诊断工具。你可以在JRE上跑Java服务但需要jstat、jmap、jcmd排查问题时会发现命令全都没有。线上至少留一个JDK的bin目录这个问题值得在部署规范里写死。6. 面试官追问GC时的真实意图与答题框架这几年看过不少候选人背GC八股文说出来的名词都对一问场景就露馅。面试官真正想考核的其实有三层概念是否自洽、有没有真实问题处理经验、做决策时有没有取舍意识。下面把这些年的高频考题和背后的考察点梳理一遍。6.1 高频考题背后的三个考核层以“如何判断对象可回收”为例很多人张口就答“引用计数和可达性分析”这就落在第一层。但面试官紧接着一定会问“GC Roots有哪些”或者抛一个“循环引用会不会泄漏”。如果你能说出“JVM主流的可达性分析天然免疫循环引用但ThreadLocal的ThreadLocalMap里key是弱引用、value是强引用处理不当会形成另类泄漏”那就进入第二层而且是你真的有实战体会。再来“CMS和G1的区别”。合格答案是堆模型、回收粒度、停顿控制三个维度横向对比优秀答案是讲清楚CMS的并发模式失败是怎么来的G1的RSet和写屏障又付出了什么代价。第三个维度“取舍意识”的体现是“G1更均衡但如果你追求极低延迟且能接受超大堆成本ZGC更合适。”6.2 一套不会冷场的答题思路我总结过一套答GC题的固定框架先讲分代假说再讲算法再落到收集器最后给参数和排查方法。比如被问到“什么时候触发Full GC”按这套框架你会依次展开Minor GC是Eden满时触发晋升是Survivor装不下时发生Full GC是老年代空间不足或元空间不足时触发接着补一句“在实际服务里我不太关注它叫什么名字更关注GC日志里的三个关键词”最后说“排查时我先看老年代增长曲线再看晋升速率而不是一上来就改堆大小”。这样答面试官想追问都很难打断你。6.3 别忽略的细节内存模型和内存区域是两回事必须要澄清一个高频混淆点JMM内存模型和**JVM内存区域运行时数据区**完全不是一回事。面试时讲“jvm内存模型”多半问的是JMM——它讨论的是并发可见性、原子性、有序性是规范和抽象而“jvm内存区域”才是堆、栈、方法区这些数据结构。若把“内存分为堆和栈”当成JMM的答案一般会被直接判负。还有一个常见追问“元空间不足会触发Full GC吗”答案是会。JDK 8以后方法区由Metaspace承担默认无上限会随类加载数量增长一旦本机物理内存不足或设置MaxMetaspaceSize过小照样会触发Full GC。所以生产上我习惯把元空间的上限显式配出来防止无脑类加载把服务拖垮。最后分享一个我在调参过程里养成的习惯做任何JVM优化之前先把GC日志落地至少保留两周。没有日志的调参就是蒙着眼调方向盘。等你真正从日志里读懂了老年代增长曲线很多Full GC问题在爆发前就能看到苗头这时候再回来翻这篇“中篇”里的理论你会发现每个参数背后都站着一条明确的演进逻辑。下篇我打算把G1、ZGC的细节和GC日志分析方法展开写到时候见。
返回列表