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

文章详情

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

深入浅出JVM底层:垃圾回收、内存分配与线上调优实战

深入浅出JVM底层:垃圾回收、内存分配与线上调优实战 搞JVM底层这块很多人第一反应就是啃那本大部头的经典书但说实话真没几个人能坚持从头翻到尾。尤其到了工作三五年这个阶段业务代码写了无数一到线上出现内存暴涨、CPU飙高、频繁Full GC的时候就抓瞎回头再去翻书知识点又散又杂根本静不下心。这篇是“深入浅出JVM底层”的下篇咱们不聊虚的直接围绕垃圾回收、垃圾回收器选型、内存分配策略和线上调优这几个最硬核的方向展开把经典书籍里那些值得记的东西捞出来结合我实际踩过的坑一起讲看完你至少能知道线上JVM参数该怎么调、GC日志该怎么看、OOM该怎么查。这系列的上篇主要讲了类加载机制和运行时数据区那是地基这篇的内容则是真正决定Java应用“稳不稳”“快不快”的部分。如果你正准备面试、或者正被线上JVM问题折磨这篇文章正好适合你。我会尽量用大白话把底层原理讲透也会给出可以直接抄走的命令和参数哪怕你之前没看过任何JVM书籍跟着走一遍也能建立一套完整的知识框架。1. 垃圾回收JVM最核心的“自动内存管理”机制1.1 为什么说GC是JVM的灵魂Java和C最本质的区别就是内存由谁负责清理。C开发者要亲手delete每一个new出来的对象稍有不慎就是内存泄漏Java开发者只管new不用管回收JVM的垃圾回收器GC会在后台自动把不再使用的对象清掉。这听起来很美但代价就是回收过程本身需要消耗CPU资源而且回收时机不可控一旦出现频繁的Full GC应用就会卡顿甚至假死。要理解GC先得搞清楚两个基础问题哪些内存需要回收怎么判断对象“已死”1.2 判断对象存活的两种算法第一个是引用计数法。每个对象有一个计数器被引用一次就加一失效就减一计数器为零就判定为可回收。这算法简单高效但解决不了循环引用的问题——A引用B、B引用A两个对象计数器都不为零但对外已经没有引用了这俩就成了永远回收不掉的内存垃圾。JVM没有采用这个方案。第二个是可达性分析算法。这个思路是从一组称为GC Roots的根对象出发沿着引用链往下走能走到的对象就是“活的”走不到的统统判死刑。这组GC Roots包括虚拟机栈栈帧中的本地变量表中引用的对象、方法区中静态属性引用的对象、方法区中常量引用的对象、本地方法栈中JNI引用的对象以及Java虚拟机的内部引用如基本数据类型对应的Class对象、常驻的异常对象、系统类加载器等。注意可达性分析里的“可达”不是指对象之间必须直接引用而是指从根节点出发能否通过引用链到达。这个判定过程必须在一个一致性的快照中进行所以会触发Stop The WorldSTW也就是暂停所有用户线程。1.3 四大垃圾回收算法盘点判断出垃圾之后就要真正回收了。经典书籍里讲了四种基础算法我按“从简单到现代”的顺序给你串一遍标记-清除算法分两个阶段先标记出所有需要回收的对象然后统一回收。缺点很明显标记和清除的效率都不高而且清除之后会产生大量不连续的内存碎片。碎片多了后面分配大对象时找不到连续空间就会提前触发下一次GC。这就像房间里堆了一堆杂物你只是把不要的东西拿走但东西还是散落各处想放个大柜子却发现没整块地方。标记-复制算法把内存按容量划成大小相等的两块每次只用其中一块。这块用完了就把还活着的对象复制到另一块再把已用过的空间一次性清空。解决了碎片问题但代价是内存可用空间直接减半。现在的商业虚拟机都用这个思想来回收新生代但不是按1:1划分而是分成一块较大的Eden空间和两块较小的Survivor空间比例默认是8:1:1这样只有10%的内存会被浪费。这里有个前提新生代对象“朝生夕灭”的特性决定了每次GC存活对象很少复制成本低。如果Survivor空间不够用就需要老年代做分配担保。标记-整理算法老年代的“专属算法”。标记过程跟标记-清除一样但后续不是直接清掉而是让所有存活对象向内存的一端移动然后直接清理掉端边界以外的内存。好处是没有碎片坏处是移动对象需要STW停顿时间更长。这里有个取舍是老年代CMS那种“不移动对象、用空闲列表分配”但回收有碎片好还是G1那种“移动对象、用Region复制”但停顿可控好现代回收器的选择逻辑基本都围绕这个权衡展开。分代收集理论严格来说不算独立算法而是上述算法的组合拳。既然不同生命周期的对象特性不同那就分而治之新生代对象存活率低适合复制算法老年代对象存活率高适合标记-整理或标记-清除。这就像管理仓库临时周转的东西放在门口随取随扔长期存放的货品往仓库深处码放整齐。2. 从Serial到ZGC垃圾回收器的演进与选型逻辑2.1 经典七款回收器一张表看清JVM发展几十年累积了多款垃圾回收器我整理了一下核心差异回收器工作模式适用区域核心算法特点与短板Serial单线程新生代标记-复制简单可靠STW时间长适合客户端或小内存场景ParNew多线程新生代标记-复制Serial的多线程版本常与CMS搭配使用Parallel Scavenge多线程新生代标记-复制追求吞吐量可设置自适应调节策略Serial Old单线程老年代标记-整理Serial的老年代版本CMS的备胎Parallel Old多线程老年代标记-整理与Parallel Scavenge搭配注重吞吐量CMS并发老年代标记-清除并发收集低停顿但碎片多、CPU敏感G1并发新生代老年代标记-复制标记-整理分区化、可预测停顿替代CMS成为默认ZGC并发新生代老年代标记-复制染色指针读屏障停顿控制在10ms以内2.2 CMS的辉煌与谢幕CMSConcurrent Mark Sweep曾经是老年代回收的主流选择它的核心追求是“最短回收停顿时间”广泛应用于互联网场景的Web服务端。它的工作流程分为四步初始标记STW标记GC Roots直接可达的对象、并发标记与用户线程并发执行从已标记对象出发继续标记所有可达对象、重新标记STW修正并发标记期间因用户线程运行导致变动的标记记录、并发清除与用户线程并发真正删除垃圾对象。看起来很美好但CMS有三个硬伤。第一它对CPU资源非常敏感并发阶段虽然不会STW但会占用一部分线程导致应用变慢默认并发线程数是(CPU核数3)/4CPU核数越少影响越大。第二它是标记-清除算法无法避免碎片堆积老年代空间被碎片拆散后分配大对象会提前触发Full GC。第三它无法处理“浮动垃圾”——并发清除阶段用户线程新产生的垃圾只能留到下一次GC处理所以必须预留一部分空间给并发收集期间运行的用户线程使用。我当年运维过一台4核8G的机器跑着CMS一到业务高峰Full GC就频繁因为老年代剩余空间不足CMS并发收集失败后直接退化成Serial Old做全堆STW式回收停顿长达好几秒。后来换了G1才解决。2.3 G1里程碑式的分区化回收器G1Garbage First的设计思路跟传统回收器完全不同。它不再物理划分新生代和老年代而是把整个堆划分为一个个大小相等的Region默认2048个每个1MB到32MB每个Region都可以独立扮演Eden、Survivor或者Old区。它还有一个专门的Humongous区用来存放大对象超过Region容量50%以上的对象。G1最牛的地方在于“可预测停顿模型”。它维护一个优先级列表跟踪每个Region回收的价值大小回收收益、回收成本等然后根据用户设定的目标停顿时间-XX:MaxGCPauseMillis默认200ms优先回收“垃圾最多、回收最快”的Region这就是“Garbage First”名字的由来。在G1中新生代不再是一个连续的内存块而是一组离散的Eden Region集合每次YGC就是把这些Region里的存活对象复制到Survivor Region然后把整块Eden Region直接清空。G1的混合回收Mixed GC也很有意思——它不会只回收老年代而是会选择一部分老年代Region和所有新生代Region一起回收。这让G1能够同时处理新生代和老年代的垃圾但又不至于像传统Full GC那样把整个堆都STW。我这里要分享一个要点G1的停顿预测模型是基于历史统计的“衰减平均值”默认会参考最近10次GC的统计数据。如果实际停顿常常超出目标值可以尝试调整-XX:MaxGCPauseMillis和-XX:G1MixedGCLiveThresholdPercent等参数但要记住停顿目标设得太小会导致GC频率上升吞吐量反而下降得在低延迟和高吞吐之间找平衡。2.4 ZGC面向大堆的超低延迟方案如果你追求的是“不管堆多大停顿都控制在10毫秒以内”那就得上ZGC了。ZGC的核心技术是染色指针Colored Pointer和读屏障Load Barrier。染色指针把64位指针中腾出几位来存放颜色信息Marked0、Marked1、Remapped等GC标记过程直接通过指针上的颜色状态来判断对象是否被标记避免了对对象的访问和修改。读屏障则让应用线程在读取堆中对象引用时可以即时感知GC的移动情况从而让对象的搬运和应用线程的运行并发进行。ZGC的整个收集过程基本都是并发执行的除了初始标记和最终标记等极短阶段需要STW外绝大部分时间应用线程都在运行所以停顿极低。它特别适合超大堆几十GB甚至几百GB的场景比如一些内存型数据库、大数据处理引擎。不过ZGC也不是银弹。它的CPU开销相对较高读屏障每次读取引用都要检查颜色状态这对高频访问的应用来说是有额外成本的。我自己的经验是堆内存小于8GB的常规业务服务G1已经足够优秀没必要上ZGC只有堆上到几十GB、对停顿特别敏感的延迟型业务才值得引入ZGC。3. 内存分配与回收策略对象到底怎么“分房子”3.1 对象优先在Eden分配绝大多数对象在新生代Eden区分配。当Eden区没有足够空间时虚拟机发起一次Minor GC新生代GC。Minor GC非常频繁回收速度也很快因为它只处理新生代而新生代里绝大部分对象都是“用完即弃”的。这里有个关键参数可以看-Xmn设置新生代大小。新生代太小会导致Minor GC频发太多对象提前晋入老年代新生代太大又会压缩老年代空间导致Full GC压力增大。经验值是新生代占堆的1/3到1/2具体要看应用的对象生命周期特征。如果是典型的“请求-响应”型Web应用对象大多是一次性创建、一次请求结束后就变成垃圾新生代可以适当大一点。3.2 大对象直接进入老年代如果一个对象的大小超过-XX:PretenureSizeThreshold参数设定的值该参数只对Serial和ParNew有效或者大对象在G1中超过一个Region容量的50%就会被直接分配到老年代。这样做的目的是避免在Eden区和两个Survivor区之间发生大量内存复制。写代码时要注意避免“朝生夕灭”的大对象比如一次性构造几百KB甚至几MB的byte数组。我之前排查过一个案例某个服务每秒钟都要往一个缓存Map里塞一个巨大的JSON解析结果GC日志显示每次Minor GC都在频繁复制这个对象老年代也在持续增长。后来改成对象复用分页处理GC频率直接降了一半。3.3 长期存活的对象进入老年代每个对象有一个对象年龄计数器每熬过一次Minor GC、被复制到Survivor区年龄就加一默认到15岁-XX:MaxTenuringThresholdCMS里常用6到15就会晋升到老年代。对象晋升年龄只是其中一个条件还有一个动态年龄判定机制——如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半年龄大于或等于该年龄的对象就可以直接进入老年代无需等够设置的最大年龄。这个动态年龄机制很关键。我见过一个场景业务高峰时大量中等生命周期对象涌入Survivor区Survivor区装不下又没达到晋升年龄结果对象在Survivor里反复复制触发过多次“提前晋升”。后来把-XX:TargetSurvivorRatio默认50%适当调低让Survivor区更早“让位”情况才缓解。3.4 空间分配担保在发生Minor GC之前虚拟机会检查老年代最大可用连续空间是否大于新生代所有对象总空间。如果大于Minor GC可以安全执行如果不大于则会检查-XX:HandlePromotionFailure参数JDK 6 Update 24之后该参数已失效规则变为只要老年代连续空间大于新生代对象总大小或历次晋升的平均大小就进行Minor GC否则转为Full GC判断是否允许担保失败。这是复制算法在新生代的核心前提Survivor区装不下的对象需要一个“后盾”来接收这个后盾就是老年代。我在压测一个高并发下单服务时把-Xmn设置得过大结果Minor GC频繁触发老年代担保失败导致大量Full GC吞吐量直接崩了。后来把新生代调回合理范围同时给老年代留足余量问题消失。4. 调优实战一次真实的JVM性能排查与参数优化过程4.1 现象应用卡顿、RT飙高、Full GC频发有一年我们维护一个电商核心交易系统平时稳如老狗一到促销大促就出问题接口平均响应时间从50ms飙升到2秒多监控面板上能看到频繁的Full GC告警。线上配置是-Xmx4g -Xms4g -Xmn1g老年代使用CMS。GC日志显示Full GC平均一秒一次每次停顿1.5秒以上。我用jstat -gcutil和jmap把这些命令跑了一遍# 查看GC统计信息每秒采样一次 jstat -gcutil 12345 1000 10 # 堆内存使用情况和分区占比 jmap -heap 12345 # 导出堆转储文件 jmap -dump:live,formatb,file/tmp/heap.hprof 12345jstat的输出里FGC那一列的数值在快速上涨FCTFull GC耗时也很大。jmap -heap看到老年代使用率已经到95%以上而且老年代里大量空间都是不可用的碎片。4.2 定位内存里到底住着谁用jmap导出的堆转储文件导入分析工具比如某开源的堆分析工具具体名字就不提了发现老年代里占据了将近60%空间的是一个静态Map对象里面保存着大量用户的会话数据。进一步追代码发现这个Map的键是用户ID值是最近一小时内所有操作记录的对象而且这个Map没有做淘汰策略只有一个定期清理任务在跑但清理逻辑写得有Bug有些旧数据永远清不掉。这类问题其实很多见业务代码里静态集合类当缓存用又没控制好容量或者把ThreadLocal和线程池混用导致线程对象无法释放。这些问题在JVM层很难用参数救回来根子还是在代码上。4.3 解决双管齐下代码优化参数调整代码层面做了三件事把静态Map改成带过期淘汰机制的本地缓存组件把历史操作记录从内存中挪到数据库只保留最近30分钟的数据对超大对象增加大小上限判断超过阈值的直接走拆分逻辑。参数层面做了一次“保守优化”-Xmx4g -Xms4g -Xmn1536m -XX:UseConcMarkSweepGC -XX:CMSInitiatingOccupancyFraction70 -XX:UseCMSInitiatingOccupancyOnly -XX:MaxTenuringThreshold6 -XX:PrintGCDetails -XX:PrintGCDateStamps -XX:PrintHeapAtGC -Xloggc:/data/logs/gc.log解释一下关键点CMSInitiatingOccupancyFraction设为70意思是老年代使用率达到70%时就启动CMS的并发收集避免等到快满了才被动Full GCMaxTenuringThreshold从15降到6尽量减少对象在Survivor区里熬的次数PrintGCDetails和PrintGCDateStamps把每次GC的细节和绝对时间打到gc.log里方便后续回溯定位。经过代码优化和参数调整之后Full GC从每秒一次降到两三分钟一次接口RT恢复到80ms以内整个促销期间服务器稳稳当当。这次经历给我最大的启发是JVM调优不是上来就调参数而是先定位是谁在占用内存参数只是辅助手段。4.4 常用调优命令速查平时排查JVM问题我用的最多的就是下面这几个工具命令作用典型场景jps -l列出Java进程及主类确认进程IDjstat -gcutil PID 1000查看GC实时统计判断GC频率和耗时jmap -heap PID查看堆配置和使用率判断堆和分区是否合理jmap -dump:formatb PID导出堆转储OOM时离线分析jstack PID打印线程栈排查线程死锁、CPU飙高jinfo PID查看运行时JVM参数确认生效参数值“CPU飙高”的场景通常用jstack配合top命令先用top -Hp PID找到CPU占用最高的线程ID转成十六进制再到jstack输出里定位线程栈看业务代码卡在哪里。大多数情况都是锁竞争、死循环或者正则回溯导致的。5. 常见故障排查与核心经验速查5.1 四类典型OOM的区分与应对OOMOutOfMemoryError是JVM最经典的问题但很多人分不清OOM和OOM之间的区别。我整理了一张区分表异常类型触发原因排查方向Java heap space堆内存不足对象无法分配堆转储分析查大对象、集合泄漏GC overhead limit exceededGC回收效果极差98%时间在GC但回收不到2%堆基本等同于堆太小或内存泄漏先dump堆Metaspace元空间不足加载类过多查动态代理、反射生成类、热部署场景Unable to create new native thread操作系统线程数达到上限查线程池、JVM参数-Xss是否过大、系统ulimit例子元空间OOM常见于不停用反射生成代理类或频繁部署的应用每次生成新类都会占用Metaspace如果类加载器无法回收就会一直涨。排查时用jstat -class PID可以看加载的类数量如果曲线只增不减基本就是类加载器泄漏。5.2 堆溢出与栈溢出别把两者搞混堆溢出Java heap space是对象太多栈溢出StackOverflowError是线程栈深度不够比如无限递归、无出口的方法调用。栈溢出对应-Xss参数服务端线程栈默认通常是1MB当你需要创建大量线程时比如每线程一个独立任务大栈会导致线程数上限变低但栈太小又会引发深层调用的StackOverflowError两者需要权衡。之前优化过一个线程池应用每开一个线程就占1MB栈空间结果系统支持最大的线程数远低于预期。调小-Xss到512KB之后线程数直接翻倍业务完全不受影响。5.3 我的JVM参数设置基线按个人经验一个常规的Spring Boot服务上线前我至少会确认这些参数-Xms4g -Xmx4g # 初始堆和最大堆保持一致避免扩容时停顿 -Xmn1536m # 新生代大小约占堆的1/3到1/2 -XX:MetaspaceSize256m # 元空间初始大小 -XX:MaxMetaspaceSize512m # 元空间上限防止元空间无限膨胀 -XX:UseG1GC # JDK 8的高版本和JDK 11默认就是G1 -XX:MaxGCPauseMillis100 # 设置期望的最大GC停顿 -XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/data/logs -XX:PrintGCDetails -XX:PrintGCDateStamps -Xloggc:/data/logs/gc.log -XX:MaxTenuringThreshold6 -XX:SurvivorRatio8 -Djava.net.preferIPv4Stacktrue这两个参数值得展开HeapDumpOnOutOfMemoryError在OOM时自动导出堆转储没有这个你只能干瞪眼MaxGCPauseMillis设100是给G1低头认怂告诉它“宁可多GC几次也别停顿太久”可以配合G1HeapRegionSize调整Region大小。5.4 JDK版本选择对JVM参数的巨大影响很多人还在用JDK 8但要知道JDK 8的G1并不像JDK 11、JDK 17里的G1那样成熟。JDK 9之后默认回收器换成了G1JDK 11引入了ZGC实验性JDK 17的ZGC已经相当能打。如果你的应用跑在JDK 8上想用ZGC是没戏的得升到JDK 11。我强烈建议新项目直接上JDK 17不仅GC更先进而且有record、sealed class这些语法糖安全补丁也更及时。老项目如果跑在JDK 8上至少要升级到较新的8u版本因为早期JDK 8的G1有不少Bug线上出问题会很难排查。结尾一点个人实操体会做JVM性能分析这几年最大的体会是“先现象、后定位、再解决”顺序千万不能乱。很多人一上来就改参数结果GC日志更乱了问题却还在。JVM参数只是“做手术的工具”真正决定内存健康的还是代码质量。尤其是高频创建对象、定时器持有内存、集合类缓存不设上限这类经典问题参数怎么调都救不回来。建议每个Java开发者都养成一个习惯线上应用一定要提前开启GC日志和OOM自动转储这两个开关对故障排查的帮助远超你想象。另外有空的时候用压测工具给自己负责的服务做一次“极限测试”看看堆内存和GC表现怎么样比起等线上出故障再急救这个成本低得多。
返回列表