ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

JVM调优实战:从Full GC排查到垃圾收集器选型

JVM调优实战:从Full GC排查到垃圾收集器选型 先说个场景。某天下午我刚把手头一个需求提测线上告警群突然炸了核心服务的Full GC频率从每小时几次飙到每10分钟一次接口P99延迟从50ms涨到3秒用户侧开始出现超时重试。当时团队第一反应是加内存堆从8GB加到16GB之后Full GC倒是少了但每次GC停顿时间反而更长服务卡得更难受。那次之后我才真正意识到JVM调优不是拍脑袋调参数而是一整套从观测、定位到验证的闭环流程。这篇东西我会把JVM调优从基础到实战完整串一遍适合刚接触JVM的Java工程师、准备面试的开发者以及做微服务和分布式架构时被线上GC问题困扰的同学。内容会覆盖运行时数据区到底怎么影响调优决策、垃圾收集器选型逻辑、从一台卡死的JVM开始怎么一步步排查再加上两个线上典型案例复盘最后聊聊分布式和微服务场景下JVM调优的特殊性。文章不会堆砌所有参数只讲那些你真正用得上、并且理解了就能举一反三的部分。1. 先把地基打牢JVM调优到底在调什么很多人一聊JVM调优就张口就是Xmx、Xms、G1、ZGC但问一句这些参数作用在哪个区域、影响了什么行为就卡壳了。这不怪你——网上大部分文章都在讲怎么设很少有人讲为什么设。在动参数之前先把运行时数据区的分工搞清楚后面所有决策都会自然长出来。1.1 运行时数据区堆、栈、元空间各自管什么JVM的内存按职责分成几块区域但真正需要你日常关心的是三个Java堆、虚拟机栈、元空间。堆是最大的那块内存几乎所有的Java对象实例都在这里分配。用一个仓库来类比仓库里堆放着各种货物对象货物有生命周期有的周转快短命对象有的常年放着长命对象。仓库空间有限满了就得清理GC。调优的大部分工作本质上都是在调整这个仓库的容量、分区分隔方式以及清理策略。虚拟机栈是线程私有的每个线程执行方法时都会创建栈帧栈帧里保存局部变量表、操作数栈、方法返回值等。栈的大小有限方法嵌套太深会抛出StackOverflowError这个一般不算调优主战场但要清楚递归深度和线程数量会挤压内存。如果给每个线程的栈设成2MB300个线程就占掉600MB内存这部分是在堆之外的。元空间在JDK 8之后取代了永久代存放类的元数据、方法信息、常量池等。它不占用堆内存默认使用本地内存上限取决于操作系统。听起来无限很爽但实际上类加载过多比如频繁热部署、动态代理生成大量类会把元空间撑爆抛出OutOfMemoryError: Metaspace。所以生产环境一定要设置MaxMetaspaceSize这不是可有可无的。1.2 对象的分配与回收路径决定了参数设置的依据JVM把堆分成新生代和老年代新生代内部又分成Eden区和两个Survivor区默认比例是8:1:1。新对象出生在Eden区Eden满了触发Minor GC存活下来的对象进入Survivor区每经历一次Minor GC存活下来年龄就加一达到阈值默认15后晋升到老年代。大对象比如很大的数组或字符串不会走Survivor区绕路直接在老年代分配。这条路径和GC参数是直接绑定的NewRatio控制新生代和老年代的比例SurvivorRatio控制Eden和Survivor的比例MaxTenuringThreshold控制晋升年龄阈值PretenureSizeThreshold控制大对象阈值。这些参数不是随便拍脑袋定的而是由你的对象分配特征决定的——如果你的服务大量产生短命对象那新生代就得给足空间避免对象过早晋升到老年代引发Full GC如果你的服务偶尔会出现大对象那要评估是否用PretenureSizeThreshold把它直接送进老年代减少新生代拷贝开销。Full GC是整个调优最怕遇到的事情因为它会STWStop The World暂停所有业务线程。老年代空间不足、元空间不足、或者调用System.gc()都会触发Full GC。调优的目标就是尽量让对象在新生代完成生老病死减少对象晋升到老年代的频率从而降低Full GC次数。1.3 调优目标不是不卡了而是三个指标的权衡JVM调优的终极指标有三个吞吐量、停顿时间、启动时间。吞吐量是指CPU用于运行业务代码的时间占总时间的比例停顿时间是GC导致的应用暂停时长启动时间是指应用从启动到对外可服务的时间。这三者不可兼得。追求低停顿时间就得用并发收集器它们在GC过程中并发执行一部分工作但本身会占用CPU导致业务吞吐量下降。追求高吞吐量往往意味着更大的新生代、更长的GC间隔但单次GC停顿时间会更长。启动时间则和堆大小、类加载数量、初始化逻辑相关。调优之前先问自己这个服务的核心痛点是什么如果是高并发交易系统用户不能等那停顿时间是第一优先级如果是离线批处理任务跑得越快越好吞吐量是第一优先级如果是微服务快速弹性扩容场景启动时间是第一优先级。目标都不定义清楚调出来的参数就是碰运气。2. 垃圾收集器选型别再装了就完选错收集器等于白调上面搞清楚了内存长什么样接下来就是谁来清理的问题。垃圾收集器选型是JVM调优中最有技术含量、也最容易被跟风带偏的一个环节。很多人一听G1好就上G1一听ZGC新就上ZGC结果在4GB堆的小服务上折腾半天收益几乎为零。2.1 主流收集器一览从Serial到ZGC的演进脉络JVM发展到现在主流收集器就这么几个搞清楚它们之间的差异你就明白为什么会有选型问题了。Serial是最古老的收集器单线程GCGC期间必须STW。它简单可靠适合单核CPU、堆很小几百MB、对停顿时间无所谓的场景比如一些客户端程序。Parallel并行收集器在JDK 8里是默认的多线程并行GC注重高吞吐量适合批处理和科学计算这类追求吞吐的场景但GC期间同样STW。CMS是JDK 7时代的主流全称Concurrent Mark Sweep并发标记清除。它做到了GC线程和业务线程大部分时间并发执行大幅降低了停顿时间但它有两个经典问题标记清除算法会产生内存碎片碎片多到一定程度触发Full GC升代价严重CMS并发失败Concurrent Mode Failure时也会退化为Serial Old做Serial全堆GC那停顿直接上秒级。CMS在JDK 9被废弃JDK 14被移除生产环境不建议再用。G1Garbage First是JDK 9之后的服务端默认收集器它把堆划分成一个个Region优先回收垃圾最多的Region能做到可预测的停顿时间。G1同时兼顾吞吐量和低停顿在4GB到32GB的堆上表现良好。ZGC是最新的低延迟收集器目标是停顿时间控制在10ms以下它通过染色指针、读屏障等技巧让GC几乎不影响业务线程适合超大堆几百GB级别和超低延迟场景。2.2 收集器选型的三条判断标准我的经验是别看网上吹什么只看你的堆大小、停顿目标和硬件资源。三条标准如下第一条堆大小。如果堆在4GB以下Parallel GC是首选因为它的吞吐量最高G1在小堆上的优势不明显反而可能因为维护Region表增加开销。堆在4GB到32GB之间G1最稳这是G1的设计甜点区。堆超过32GBZGC是更合理的选择——超大堆用G1Full GC的停顿时间会让人崩溃。第二条停顿时间目标。如果业务要求GC停顿不能超过10ms那就直接考虑ZGCG1很难做到这个级别。如果停顿容忍到100ms级别G1完全够用。如果对停顿完全不敏感Parallel GC性价比最高。第三条硬件资源。ZGC和G1都会占用更多CPU做并发标记、并发清理如果CPU核数不多比如只有2核4线程并发收集反而会和业务线程抢CPU导致吞吐量下降。这时宁可选择Parallel GC用短暂停顿换吞吐量。2.3 一个很常见的选型误区我见过最多的选型错误是小堆上强行上G1。有一个内部管理系统的例子堆只有2GB团队因为G1是默认就直接上了结果GC停顿没降下来CPU使用率反而上升了10%。后来改回Parallel GC一切恢复正常。调优一定要记住收集器没有绝对的好坏只有匹配不匹配。堆小、对停顿不敏感、CPU资源有限的场景Parallel就是最优解。堆大、延迟敏感的互联网后端服务G1或ZGC才是应该考虑的。还有个误区是以为用了G1就不需要关心老年代了。G1虽然把堆分成了Region但逻辑上依然有新生代和老年代的概念对象晋升逻辑没有变。该出现的Full GC还是会出现只是在G1里叫Mixed GC混合回收回收部分老年代Region和Full GC后者依然会STW要命的是混合回收失败的Full GC停顿时间通常不低。3. 实战步骤从一个卡死的JVM开始排查工具和参数都聊完了现在进入正题线上出现GC问题到底怎么一步步排查我给自己总结了一套固定流程每次遇到JVM问题都这么走基本不会乱。3.1 第一步用工具看清现状别靠猜排查JVM问题的第一步不是改参数而是看数据。JDK自带的命令行工具在关键时刻比任何可视化监控面板都靠谱因为它们不依赖Agent不会因为监控脚本本身出问题而失真。jstat是第一个要用的工具它是JVM统计信息工具直接看GC情况。命令格式是jstat -gcutil pid 间隔毫秒 次数输出S0、S1、E、O、M几个区的使用率以及YGCMinor GC次数、YGCTMinor GC总耗时、FGCFull GC次数、FGCTFull GC总耗时、GCTGC总耗时。实战中我见过最典型的输出S0 S1 E O M CCS YGC YGCT FGC FGCT GCT 0.00 31.20 45.30 96.20 97.10 95.20 1283 45.123 827 120.876 166.999这个输出代表什么老年代使用率96.2%元空间使用率97.1%Full GC次数827次且总耗时120秒平均每次145ms但FGCT已经远超YGCT说明这个JVM的瓶颈就在老年代和元空间。看到这种数据方向就有了不用瞎猜。jmap用来查看堆内存的整体情况和对象统计。jmap -heap pid可以看到Heap内存各区域的最大值、当前使用量、参数配置jmap -histo:live pid把存活对象按占用内存排序前面几名基本就是问题线索jmap -dump:formatb,fileheap.hprof pid导出一份堆转储快照配合MAT或JProfiler做离线分析。注意生产环境导出堆转储会暂停应用务必在低峰期操作或者用jmap -dump:live只导出存活对象减小体积和影响。jstack用来打印线程快照看线程在干什么。Full GC期间业务线程普遍处于阻塞状态线程栈上大量线程卡在等待锁或者等待GC死循环的话能看到CPU跑满的线程栈死锁的话能看到两个线程互相持有锁等待。配合top -Hp查看线程CPU占用基本能定位代码层面的问题。还有一个jcmd工具集合了JVM大部分诊断功能比如jcmd pid GC.heap_info、jcmd pid VM.command_line可以替代部分jmap功能且更安全新版JDK推荐优先用它。3.2 第二步读懂关键参数把想要的配置写清楚看清楚了现状下一步才是调整参数。JVM调优参数非常多但真正高频使用的就这么一组我按功能分类讲清楚。堆大小是最基础的。-Xms设置初始堆大小-Xmx设置最大堆大小。生产环境强烈建议把两者设成一样这样可以避免JVM运行时反复扩容和缩容的抖动。堆大小怎么定不是越大越好要根据业务对象分配速率、GC频率和物理内存综合判断。我的经验公式是先压测观察默认配置下的老年代增长速度如果4小时老年代只涨到30%那堆大小按当前值的2到3倍设就够了留出峰值余量又不浪费资源。新生代相关参数里-XX:NewRatio控制新生代和老年代的比例默认是2即新生代为堆的1/3。如果业务对象绝大多数是短命的把比例调成3或4新生代占堆的一半甚至六成能有效减少对象晋升老年代的频率。-XX:SurvivorRatio控制Eden和Survivor的比例默认8Eden占新生代的8/10如果Minor GC后存活对象比例较高可以调到6或4给Survivor更多空间避免对象因Survivor空间不足直接晋升老年代。对象晋升参数里-XX:MaxTenuringThreshold控制对象最多经历多少次Minor GC后晋升老年代默认15。如果日志里显示对象在Age1或Age2时就大量晋升说明Survivor太小或者阈值太低需要调整。-XX:PretenureSizeThreshold控制大对象直接在老年代分配的阈值值单位是字节默认0表示不启用。举个例子如果业务里有大量1MB以上的数组而新生代Eden区只有256MB每次Minor GC拷贝这个大对象代价很高设置-XX:PretenureSizeThreshold1048576让它直接进老年代更划算。元空间参数-XX:MetaspaceSize是触发元空间GC的初始阈值-XX:MaxMetaspaceSize是元空间最大上限。前面说过生产环境一定设置MaxMetaspaceSize避免无限使用本地内存拖垮整台机器。常见配置是512M或1G具体看应用类加载规模。还有一个容易忽略的参数是-XX:HeapDumpOnOutOfMemoryError加上它JVM在OOM时会自动导出堆转储文件这对排查OOM问题是救命稻草。没有它OOM现场一消失你就等着靠猜排查了。GC日志参数不同JDK版本命令不一样。JDK 8用-XX:PrintGCDetails -XX:PrintGCDateStamps -Xloggc:/path/to/gc.logJDK 9以后统一用-Xlog:gc*:file/path/to/gc.log。建议所有生产JVM都开启GC日志不仅排查问题要用日常监控GC频率的基线也靠它。3.3 第三步验证调整效果不能只看不那么卡了改完参数不是结束验证才是关键。很多人调优以后说感觉不那么卡了这不行。要有数据要能对比。验证的第一个层面是GC日志数据。调整前后各跑一段压测压测场景要尽可能模拟真实流量。对比YGC次数、FGC次数、平均停顿时间、最大停顿时间这几个指标形成表格。比如调整前FGC每小时12次、平均停顿350ms调整后FGC每小时2次、平均停顿80ms这才叫有效的调整。验证的第二个层面是业务指标。接口P99延迟、成功率、CPU使用率、内存使用率这些要在监控面板上看到明确变化。GC指标变好了但业务指标没变化说明你的调整方向可能错了——比如你降低了GC频率但增加了每次GC的时间整体到业务上的效果被抵消了。验证过程中有个重要原则一次只改一个变量。如果同时改了堆大小、收集器、晋升阈值出问题了不知道是哪个参数导致的出正面效果了也不知道是哪个参数贡献的。一次只调一个跑一段时间观察再调下一个这是调优的基本纪律。4. 线上案例复盘Full GC频繁和内存溢出的完整排查链路理论讲再多不如看两个真实案例。这两个案例是我在实际项目中处理过的一个典型的老年代打满问题一个典型的容器化环境OOM问题各自的排查思路值得完整拆一遍。4.1 案例一8GB堆的老服务Full GC每10分钟一次现象一个运行了两年的老服务某天起FGC频率从每小时几次变成每10分钟一次每次停顿时间150ms以上接口成功率明显下降。排查第一步用jstat看了下GC情况发现老年代使用率持续在95%以上Full GC之后只能降到80%左右然后很快又涨上去。这说明老年代里有东西一直在堆积而且堆积速度很快。Minor GC次数其实不高新生代压力不大问题集中爆发在老年代。排查第二步用jmap -histo:live看了存活对象Top10排在前面的除了正常的业务对象还有一个自定义的缓存对象占了2GB多。顺着代码查发现这是个静态集合类型的缓存往里面写数据的入口没有设置过期时间只增不减。业务高峰期每秒钟往缓存里塞大量数据老年代自然被打满。排查第三步为了确认缓存的引用关系导出了一份堆转储文件用MAT打开看支配树确认这个缓存对象确实被一个单例服务持有着而且持有者已经把清理操作注释掉了。这部分是线上数据说话不用猜。解决方案分两步代码层面给缓存加容量上限和过期淘汰策略用LinkedHashMap的removeEldestEntry实现LRU或者直接换成成熟缓存框架JVM层面把堆从8GB调到12GB给业务增长留出缓冲。上线后观察一周FGC降到每天几次接口P99从2秒回到60ms。这个案例的教训是GC调优只能缓解症状代码里的内存泄漏不修掉参数调得再好也只是推迟爆炸时间。4.2 案例二容器里部署的Java服务频繁OOM现象一个微服务跑在Kubernetes集群里容器内存限制设了1GB但Java应用频繁OOM日志显示Java heap space。运维第一反应是把容器内存限制调到2GB结果OOM更频繁了。这里有个非常经典的坑JVM不会自动感知容器内存限制。如果启动参数里没有配置容器感知JVM默认把物理机内存当作可用内存然后按物理机内存的1/4来设置默认最大堆。在一台64GB物理机上JVM默认最大堆可能算出来是16GB但容器只允许用1GB堆还没用满内存就被容器OOM Killed了表现就是进程直接消失或者抛出各种分配失败。解决方法是显式告诉JVM容器限制。JDK 10之后JVM默认开启-XX:UseContainerSupport可以感知容器内存和CPU限制但这只是感知实际还要配合比例参数使用。推荐的方式是不要设置固定Xmx而是设置-XX:MaxRAMPercentage75.0意思是JVM最大堆使用容器内存的75%留出一部分给元空间、线程栈和堆外内存。有同事习惯设成90%觉得堆越大越好但忽略了JVM除了堆还占其他内存结果堆没打满容器先OOM了教训深刻。案例里我们把-Xmx1024m删掉改成-XX:MaxRAMPercentage70.0并调低MetaspaceSize上限到256M同时减少线程栈大小到512K一段时间的运行就稳定了。这个案例的教训是容器化环境下JVM调优的第一课是理解你踩的内存地板是容器限制而不是物理机资源。4.3 复盘时的几条通用原则这两个案例放在一起能总结出几条通用原则第一先看数据再猜原因。没有GC日志、没有堆转储之前不要动参数。我见过很多团队在问题还没定位时就把堆翻倍、换收集器结果问题依旧还丢了原始排查线索。第二堆转储是定位内存问题的银弹。OOM或者内存持续上涨时导出一份堆转储用MAT看支配树和泄漏报告比自己翻代码高效得多。前提是启动参数里加上-XX:HeapDumpOnOutOfMemoryError让系统自动留档。第三参数调整是最后一步代码和架构才是根本。绝大部分GC问题本质是代码问题缓存无上限、数据库连接不关闭、一次性加载大量数据到内存、线程池创建过于频繁。修掉这些参数不用调问题自解。5. 从单机到架构JVM调优在分布式与微服务场景下的不同侧重上面聊的场景基本都是单机视角的JVM调优但现阶段大部分Java服务都跑在分布式架构里。容器化、服务拆分、多实例部署这些因素会实实在在改变JVM调优的决策逻辑。很多人还拿单体时代的调优思路套微服务效果自然好不了。5.1 容器化给JVM调优带来的变量容器改变了两个关键资源内存和CPU。内存方面前面案例二已经讲了JVM需要显式感知容器限制用-XX:MaxRAMPercentage替代固定Xmx。CPU方面容器如果限制为2核而JVM默认的GC线程数可能按宿主机核心数计算比如宿主机32核GC线程默认可能是8个甚至更多这些线程在一个2核容器里互相抢CPUGC效率反而下降。这时候需要显式设置-XX:ParallelGCThreads和-XX:ConcGCThreads让GC线程数和容器的CPU配额匹配。还有一点容易被忽略容器内存限制看的是RSS常驻内存不只是堆。JVM的DirectBuffer、线程栈、Metaspace、Compressed Class Space都会占内存。我在实践中通常把MaxRAMPercentage控制在70到80之间给非堆内存留足余量。5.2 微服务拆分后堆变小了反而更难调微服务架构里每个服务看着都不大内存也都限额但JVM调优反而比单体时代更细碎。原因在于堆变小了GC停顿的抖动对整体影响变大。举一个实际例子一个订单服务的堆从8GB被限制到2GB原本用G1挺稳堆变小后G1的优势发挥不出来Full GC反而多了后来换回Parallel GC好了一阵但随着业务量上涨2GB堆还是不够最终解决方案是把一个2GB的堆拆成4个512MB的堆通过多副本和负载均衡分摊流量让单个实例的GC压力显著下降。这里透露了一个架构层面的思路当单个实例的JVM调优到瓶颈时横向扩容和拆分比继续压榨堆空间更有效。分布式环境下一个问题可能有多个解决维度JVM调优只是其中一个杠杆。另外微服务环境下要注意线程池和连接池对内存的实际占用。一个服务里塞了多个线程池每个线程池200个线程每个线程栈1MB光线程栈就吃掉几百MB内存这部分在堆之外但真实存在。把线程栈从1MB降到512K理论上能省出一半线程栈内存在堆大小受限的容器场景里非常划算。5.3 架构设计阶段就该考虑的JVM问题很多内存问题在架构设计阶段就已经埋下伏笔了。举三个最常见的例子第一缓存放在堆内还是堆外。堆内缓存比如用ConcurrentHashMap做本地缓存实现简单、访问快但会占用堆内存直接影响GC。当一个服务缓存了上GB数据堆再大也扛不住GC停顿也会明显增加。更好的方案是引入分布式缓存Redis、或者使用堆外缓存比如MapDB、Chronicle Map把内存压力从JVM堆里移走给GC减负。第二大对象的序列化和传输。业务接口如果一次性返回几MB的JSON这些大对象在堆里分配、拷贝、GC都要承受较高代价。架构上应该尽量做数据裁剪、流式处理、分页拉取避免大对象在堆里到处飘。第三批量任务的调度方式。定时大批量处理任务如果每个任务都无节制地加载数据到内存堆会快速上涨。架构上应该分页分批处理配合PretenureSizeThreshold让大对象直接进老年代减少新生代拷贝。这些都属于架构决定JVM的例子比调参更治本。6. 最后聊聊面试JVM调优怎么答才能不踩坑JVM调优是Java面试的高频话题而且问法越来越刁。以前能说清楚Xmx和Xms就算及格现在面试官更关注你有没有一套完整的分析框架。我后来跟几个做过技术面试官的朋友聊过我们一致认为答JVM调优题最重要的不是背参数而是看候选人脑子里有没有一套清晰的思路。6.1 面试官真正想听什么面试官问线上Full GC频繁怎么办想听到的不是一个答案而是一条链路先通过jstat看GC数据再通过jmap看堆内存分布和对象统计必要时导出堆转储用MAT分析拿到证据后判断是对象分配速率过快、内存泄漏还是配置不合理最后针对性调整参数并验证。能按这个链路讲出来的候选人说明真的处理过线上问题有实战经验。一个通用答题框架是现象描述 - 工具采集 - 数据分析 - 假设定位 - 方案验证。把每一次调优都按这个套路讲比背一百个参数都管用。6.2 三个高频问题怎么答第一个高频问题线上Full GC频繁你会怎么排查直接答先用jstat -gcutil看老年代和元空间使用率确认是哪个区域引发了Full GC再用jmap -histo:live看对象分布锁定大对象来源如果是老年代持续打满导出堆转储用MAT看支配树大概率能定位到缓存无上限、连接未关闭、或者单次任务加载数据过多这类根因。修根因优先调参数为辅。第二个高频问题堆大小怎么设置答的时候体现出权衡先看业务对象分配速率和存活数据量压测观察老年代增长速度堆不是越大越好太大导致GC停顿时间过长太小导致GC频率过高生产环境Xms和Xmx设成一致容器环境用MaxRAMPercentage而不是固定Xmx给非堆内存留余地。第三个高频问题遇到过OOM吗怎么处理的答的时候要体现出你分得清OOM的类型Heap Space OOM大概率是对象泄漏或分配过快看堆转储Metaspace OOM大概率是动态类加载太多调MaxMetaspaceSize并且检查加载逻辑Direct buffer memory OOM大概率是堆外内存泄漏检查Netty或DirectByteBuffer使用。把类型分清楚回答的深度完全不一样。6.3 我自己的一点体会带过这么多实习生我发现大家学JVM调优很容易陷入两个极端要么只背参数不碰原理要么只研究原理不跑实战。我个人的建议是自己用本地开发环境搭个服务用-Xmx64m人为制造OOM用jmap导出堆转储用MAT打开亲眼看看一个OOM现场的堆里都有什么。这个过程走上几遍你对JVM调优的理解会超过看十篇文章。参数会忘但排查的思路和先看数据再动手的纪律不会忘。这大概就是JVM调优最值钱的部分了。
返回列表