JVM内存泄漏与OOM排查实战:从GC日志到Arthas/MAT完整攻略
2026/9/9 20:13:58 网站建设 项目流程

今年3月,我负责的一个订单导出服务在凌晨4点被监控告警打醒。现象非常典型:CPU使用率接近100%,老年代内存从原本稳定的60%一路涨到90%以上,Full GC从每5分钟一次变成每十几秒一次,最后直接抛OutOfMemoryError。我重启了应用,结果二十多分钟后又开始涨,第二天同一时间照样崩。最终定位到根因,靠的是一份堆dump加上几条Arthas命令——一个链路追踪过滤器把每次请求的Span对象塞进了ThreadLocal,finally里忘了remove,日积月累把老年代撑爆了。

那次踩坑之后,我把整条JVM内存排查链路重新梳理了一遍:从内存模型判断OOM类型,到用GC日志确认增长趋势,再到用Arthas抓线程、抓对象、验证代码行为,最后用MAT分析dump定位泄漏根因。这个过程里有些命令是救命的,有些坑是文档里不会写的。这篇文章就是把这些沉淀整理成一套可以直接抄作业的排查方案,适合线上服务出过OOM但不知道怎么系统排查的同学,也适合准备JVM面试时需要把知识串成体系的人。

1. 一次凌晨的OOM事故:先还原排查现场

1.1 事故时间线与应急处置

这个订单导出服务的部署形态很简单,4台8C16G的容器,JVM堆设置4G,JDK 8,G1垃圾回收器,平时老年代占用稳定在50%-60%。出事那天,监控曲线是这样的:

  • 04:00 老年代占用开始从60%爬升,Full GC次数同步增加。
  • 04:15 服务出现大量请求超时,CPU接近100%。
  • 04:22 出现第一次OutOfMemoryError,随后被监控自动重启。
  • 04:50 老年代再次爬升到80%以上,周而复始。

第一次处理时,我直接让运维重启了应用,顺手保留了当时的GC日志,但没来得及dump内存。重启后虽然恢复了,但没过多久又冲到高位——这说明不是流量洪峰带来的瞬时压力,而是有东西在持续累积。这个判断很重要,决定了后面排查方向。

1.2 应急处置中的两个关键取舍

这里有两个经验,是踩了坑才长记性的。

第一,遇到OOM不要急着重启,先判断还有没有抢救的时间。如果服务还能响应,优先执行jmap -dump或Arthas的heapdump,把内存现场留住。重启虽然快,但会丢掉最重要的排查线索。如果连jmap都执行不了,那说明内存已经彻底耗尽,需要用jmap -F或者gcore方式兜底,但这属于极端情况。

第二,保留GC日志比保留业务日志更优先。GC日志记录了老年代的完整增长曲线,能帮你判断内存是持续上升还是周期性波动。JDK 8可以通过-Xloggc:/opt/logs/gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps提前开启,JDK 11+可以用统一的-Xlog:gc*参数。

1.3 复盘时的两个认知偏差

处理完事故后复盘,我发现自己犯了两个典型的认知错误。

第一个认知错误是:一开始怀疑是代码最近上线引入的问题,于是先翻git提交记录、看代码review,花了一个多小时。但实际上,问题的根源是三个月前一个链路追踪组件的小改动,当时根本没引起注意。正确的顺序应该是:先看内存证据,再指向代码。GC日志和dump会直接告诉你哪些对象占了大头,顺着对象找代码,远比凭空看代码高效。

第二个认知错误是:以为Full GC频繁是因为堆太小,于是临时把-Xmx从4G调大到8G。结果反而更糟——因为泄漏的对象变多了,每次Full GC扫描的时间更长,服务直接卡死。堆大小只能缓解症状,不能解决泄漏,反而可能掩盖问题。

2. JVM内存模型与OOM的对应关系:先知道“炸在哪”再谈排查

2.1 运行时数据区与OOM类型的映射

很多人在聊JVM内存模型时能背出堆、栈、元空间,但到了线上遇到OOM却反应不过来。问题出在:没有把内存区域和具体的报错信息对应起来。

我整理了一张对应关系表,排查时可以对照着定位:

内存区域存储内容常见报错出错特征
堆(Heap)对象实例、数组java.lang.OutOfMemoryError: Java heap space最常见,对象持续堆积或瞬时分配过多
元空间(Metaspace)类元数据、方法、常量池java.lang.OutOfMemoryError: Metaspace大量动态生成类、热部署加载类过多
虚拟机栈(VM Stack)栈帧、局部变量java.lang.StackOverflowError无限递归、方法调用层级过深
本地方法栈Native方法调用java.lang.StackOverflowError本地方法递归、过度回调
直接内存(Direct Memory)NIO的DirectByteBufferjava.lang.OutOfMemoryError: Direct buffer memoryNIO缓冲区未释放、未配置MaxDirectMemorySize
本机进程内存线程栈、JVM内部结构java.lang.OutOfMemoryError: unable to create new native thread线程数达到系统上限或进程内存耗尽

JDK 8之后永久代被元空间取代,这是一个老生常谈但极其重要的变化。永久代有固定上限,元空间默认使用本机内存,所以老的-XX:PermSize参数在JDK 8上是无效的。如果你们的服务还在用-XX:PermSize,说明参数白配了。这一点在面试里也经常被问道,回答的要点是:永久代是在堆内分配的,而元空间是本机内存,所以字符串常量池从JDK 7开始就移到堆里了,类元数据在JDK 8后被搬到元空间。

2.2 五种最常见的OOM根因模型

遇到OOM,先别急着查代码,先把报错信息读明白。我总结出五种最常见的根因模型,每种都有对应的排查侧重:

  1. 对象持续堆积型泄漏:某个集合、缓存、ThreadLocal一直持有对象引用,GC无法回收。特征是GC日志里老年代持续增长,dump里能看见某种类型对象占了大头。这是最典型的泄漏形态,也是本文后面案例分析的主线。
  2. 瞬时分配过大:并发量突增,单个请求创建了大量对象,或一次性加载了超大集合(比如把整张表查进内存)。特征是堆曲线呈尖峰状,峰值过后能回落。这种不叫泄漏,处理手段是限流、分页、调整堆大小。
  3. 元空间膨胀:动态代理、反射生成类、Groovy脚本编译、热部署加载了大量Class对象。特征是Metaspace区域持续上涨,dump时堆里可能并不大,但元空间吃满了本机内存。
  4. 线程数耗尽:每次请求都new Thread,或线程池没设置上限,线程栈占满进程内存。特征是unable to create new native thread,这时候就算堆还有空间,也别只顾着调堆。
  5. 直接内存泄漏:NIO的ByteBuffer.allocateDirect分配了堆外内存,但没有释放。特征是进程内存远大于堆内存,堆内占用不高却频繁OOM。

2.3 JVM、JRE与JDK:面试和排查都绕不开的基础

排查过程中经常遇到同事问一个问题:JVM、JRE、JDK到底是啥关系?这个话题在热搜词里也居高不下,连带error invoking method. failed to launch jvm这种启动报错也常被搜索。

用大白话讲:JDK是Java开发工具包,包含编译器javac、调试工具jdb、诊断工具jmap/jstack/jstat;JRE是Java运行时环境,只包含JVM和核心类库;JVM是Java虚拟机,是JRE里的核心执行引擎。JDK里包含了JRE,JRE里包含了JVM。三者的关系可以用俄罗斯套娃来理解——JDK最大,JRE居中,JVM最小。

error invoking method. failed to launch jvm这个报错,绝大多数情况都出在启动参数不兼容或找不到JVM实例上。常见诱因包括:用JDK 8跑JDK 11才有的参数、-Xmx后跟了非法单位、Eclipse等工具在eclipse.ini里配置了无效的虚拟机参数、或工具无法定位到jvm.dll。排查时优先检查启动脚本里的JVM参数是否和当前JDK版本匹配,这是最容易被忽略也最高发的点。

3. 内存泄漏判断与代码层嫌疑定位:别急着开dump

3.1 怎么区分“真泄漏”和“流量增长”

每次讨论OOM,都会有人说“是不是最近流量涨了导致堆不够”。这个疑问很合理,但判断起来并不难。我用的方法有三种,可以在拿到GC日志后快速做区分。

第一种是看老年代增长曲线和流量的相关性。如果是流量增长,内存曲线会跟着请求量波动,高峰期上涨、低峰期回落,整体水平可能上移但不会无限上涨。如果低峰期内存也不回落,那基本可以判定为泄漏。

第二种是压测验证。把服务接入测试环境,用固定QPS压测,观察内存是否线性上涨。如果内存稳定在某个水位,说明堆配置够用;如果持续上涨不回落,就是泄漏。这个办法最直接,但需要具备压测条件。

第三种是看dump里的对象是不是业务相关的。如果dump里大量堆积的对象是历史请求的残留数据,比如一个Map里保存了几天前的请求对象,那妥妥是泄漏。如果dump里全是当前请求的瞬时对象,那大概率是分配过快。

3.2 代码层四大高频泄漏模式

在JVM层面的诊断工具出手之前,我觉得有必要先把代码层的常见泄漏模式列出来。因为在很多情况下,工具只是验证手段,真正的根因要回到代码里去修。根据我的经验,90%以上的生产环境内存泄漏属于以下四种模式:

第一是静态集合持有对象。最常见的是static Map、static List或缓存框架使用不当,往里面放了数据却不清理。尤其在业务代码里,有人图方便把临时数据塞进静态Map当缓存用,但没设淘汰策略,时间一长把老年代撑爆。

第二是ThreadLocal未清理。每个ThreadLocal的Entry是WeakReference指向Key,但Value是强引用链。线程池里的线程是长期存活的,ThreadLocal不remove,Value就永远无法回收。这就是我开头那个事故的直接原因。使用ThreadLocal的场景,包括链路追踪、登录态传递、数据库连接、日期格式化工具类,都必须在finally块里remove。

第三是监听器和回调未注销。Spring的ApplicationListener、Netty的ChannelHandler、观察者模式的Listener,如果注册了没注销,对象会被事件源长期引用。特别是那些用了静态事件总线或全局事件管理的代码,一旦注册,整个生命周期内都无法被GC。

第四是IO和数据库连接未关闭。这种严格来说不算“内存泄漏”,但Netty的ByteBuf、数据库ResultSet、文件流如果未关闭,底层堆外内存或NIO缓冲区会一直占据内存。堆内dump可能看不出明显问题,但进程内存会持续增长。

3.3 前端内存泄漏排查的对照参考

今年热搜里“前端 内存泄漏怎么排查”也是一个高频问题。虽然场景不同,但排查思路和JVM是相通的。浏览器DevTools的Performance面板录制内存时间线,原理等同于JVM的GC日志;Heap Snapshot等同于堆dump;Detached DOM Tree的分析等同于MAT里的Dominator Tree。常见泄漏模式也类似:未remove的全局事件监听、闭包持有大对象、定时器没清理、DOM节点删了但JS还在引用。

我提这个的原因是:如果你同时负责前后端,可以复用一套排查心智模型——先看生命周期曲线,再拿快照,再找持有引用链。语言和运行时变了,方法骨架是一致的,面试时也能体现系统化思考。

4. Arthas实战命令集:从进场到抓到元凶的命令编排

4.1 进场准备:启动与连接的三种姿势

Arthas是Alibaba开源的Java诊断工具,核心价值在于无需重启服务、无需改代码,就能在运行时对JVM进行在线诊断。我用Arthas的频率非常高,它在整个排查链路里承担的角色是“现场目击者”。

启动方式通常有三种:

bash java -jar arthas-boot.jar

这种方式会自动列出当前机器上的Java进程,输入序号即可attach。第二种是指定进程ID:

bash java -jar arthas-boot.jar 12345

第三种是在容器环境里使用,需要先进入容器,再执行同样的jar包命令。需要注意Arthas需要与目标进程相同的用户权限,否则attach会失败。线上容器如果做了权限限制,这条命令会直接抛错,需要提前联系运维开放权限。

attach成功后,Arthas会占用一个telnet端口,默认3658。如果你所在环境的网络策略禁止telnet,启动时可以指定--telnet-port--http-port换个端口。我遇到过安全策略比较严格的环境,所有非标准端口都被封了,最后是让Arthas通过-c以批处理模式执行命令,把结果写入日志文件,再拉回来看。

4.2 第一轮全局扫描:dashboard与memory

进场后的第一条命令一定是dashboard。它会以3秒为周期打印全局状态,包括:

  • JVM内存各区域的使用率。
  • GC次数与GC耗时。
  • 线程概况,包括RUNNABLE、WAITING、BLOCKED线程数量。
  • 各线程CPU占用排行。

处理OOM时,我第一眼看的是老年代占比和GC情况。如果老年代占比接近100%,或者持续飙升,情况就基本清晰了。如果看到大量线程处于BLOCKED状态,说明可能出现了线程死锁或锁竞争,那是另一个方向的排查路径。

dashboard的示例如下:

ID NAME GROUP PRIORITY STATE %CPU TIME INTERRUPTED DAEMON 45 nioEventLoopGroup-2-1 main 5 RUNNABLE 67.5 540 false true 23 http-nio-8080-exec-10 main 5 RUNNABLE 22.3 330 false true ... Memory used total max usage() heap 3.9G 4.0G 4.0G 99.11% g1_old_gen 3.7G 3.8G 3.8G 99.43% g1_young_gen 180M 190M 1.9G 9.99%

看到g1_old_gen的usage高达99.43%,不需要再做其他判断,老年代确实被灌满了。这时候执行memory命令看更详细的内存区域数据,确认Metaspace和Direct Buffer是否正常。如果只有老年代异常,元空间和直接内存都正常,基本可以聚焦到堆内对象泄漏。

4.3 第二轮线程与GC:thread和jvm命令

在内存泄漏的场景中,thread主要用于两个目的:一是看CPU占用最高的线程在干什么,因为Full GC频率高会让某些线程陷入频繁GC停顿;二是看线程栈里是否有可疑的长期存活对象。

命令格式:

# 查看CPU占用最高的前3个线程 thread -n 3 # 查看所有处于WAITING状态的线程 thread --state WAITING # 查看指定线程的栈和状态 thread 45

当老年代被撑爆时,thread -n 3的前几名经常是GC线程或执行Full GC触发的业务线程。这本身不直接定位泄漏,但能帮你判断当前的CPU是消耗在业务计算上还是GC回收上。如果CPU几乎被GC线程吃光,说明内存回收已经进入恶性循环。

jvm命令可以查看JVM的各种运行时信息,包括启动参数、类加载统计、GC算法等。排查时我会重点看启动参数是否包含了-XX:+HeapDumpOnOutOfMemoryError,如果没有,要提醒自己后续通过Arthas手动dump。

4.4 第三轮对象定位:heapdump、sc与vmtool

如果dashboard确认老年代接近打满,下一步就是生成堆dump。Arthas的heapdump命令可以随时执行,不需要等到进程OOM崩溃,也不需要重启服务:

heapdump /opt/logs/heap.hprof

这里有个细节值得注意:heapdump会触发一次Full GC,在堆很大的情况下会让服务卡顿几秒。所以我通常建议在业务低峰期执行dump,或者配合环境流量调度先把这台机器的流量摘掉再dump。摘流可以通过注册中心的上下线接口来做,或者通过负载均衡规则临时踢掉节点。摘流后再dump,不会影响用户,也不会被大流量干扰。

dump文件拿到手是一回事,但线上排查往往等不到下载文件再分析。Arthas的vmtool可以在线查看对象实例数量,这是定位泄漏嫌疑类的高效手段:

# 查看某个类的实例数量(只显示数量) vmtool --action getInstances --className com.example.OrderService # 查看实例的字段值,比如某个SessionService的sessions Map大小 vmtool --action getInstances --className com.example.SessionService \ --express '#instances[0].sessions.size()'

这招非常实用。如果怀疑某个缓存工具类或Session管理器泄漏,可以直接通过vmtool查看它在JVM里活着的实例数量,以及内部集合的大小。如果size在持续增长且远超出合理范围,元凶基本上锁定了。

再配合sc命令查看类的加载信息:

sc -d com.example.TraceFilter

可以查看这个类的类加载器、类定义、注解等。如果类加载器异常,比如同一个类被加载了几十次,说明存在类加载器泄漏,这在热部署场景里非常典型。

4.5 第四轮代码行为验证:trace、watch与monitor

定位到嫌疑类后,需要验证它的行为是否符合预期。这一步Arthas的命令设计得非常好,可以做到不改代码就观察方法执行。

trace命令可以打印方法内部的调用路径和耗时:

trace com.example.TraceFilter doFilter '#cost>100'

这个命令会跟踪doFilter方法执行,打印每个子调用的耗时。如果某个方法单次执行耗时特别长,或者某条调用路径异常,输出会直接暴露问题。

watch命令可以观察方法的入参、返回值和异常:

watch com.example.TraceFilter doFilter '{params, returnObj}' -x 3

-x 3表示展开对象的深度为3层。如果方法返回值或参数对象里包含一个不断膨胀的集合,watch出来后就能直观看到size的变化。

monitor命令可以对方法调用进行统计:

monitor -c 5 com.example.TraceFilter doFilter

每5秒输出一次该方法的调用次数、成功次数、失败次数、平均耗时。如果某些可疑方法调用次数异常多,或者平均耗时在内存压力增大时明显变长,都可以作为排查参考。

4.6 更深入的手段:ognl与retransform

Arthas还有一个大杀器是ognl命令,可以直接在运行时执行表达式,访问静态字段、调用对象方法。我在定位ThreadLocal泄漏时用过这样一个表达式:

ognl '#thread=@java.lang.Thread@currentThread(), #thread.getThreadLocals()'

但这需要目标线程是当前执行线程,实际场景中更常用的是通过vmtool拿到实例后,再配合ognl来读取实例内部的集合内容:

vmtool --action getInstances --className com.example.TraceContext \ --express '#instances[0].traceMap.keySet()'

ognl能力很强,可以直接修改静态字段的值,所以要谨慎使用,线上只建议做读取操作,不建议通过ognl在线改数据。

retransform命令可以热更新类,把修改后的class文件替换运行时行为。但它不是排查命令,而是修复手段,而且有Java Instrumentation的限制,也容易出现方法签名不一致的问题。我不建议在排查阶段碰它,风险大于收益。

4.7 Arthas使用过程中的避坑清单

Arthas好用是好用,但也有几个坑,我列一个避坑清单:

  • 生产环境attach前确认ACL。Arthas的attach机制可以attach到同用户进程,如果服务以root运行,Arthas也需要以root启动。权限不足时,attach会静默失败或直接抛错。
  • 注意防火墙对3658端口和8888端口的限制。如果没有放通,客户端无法连接,可以用--telnet-port换个端口,或者用批处理模式执行命令。
  • 不要在大堆Full GC执行中做dump。heapdump会触发GC,在堆很大时加剧服务停顿,配合摘流执行是稳妥的做法。
  • 不要用ognl修改线上数据。读取足够,改数据的行为容易引发副作用。
  • Arthas本身会占用一定资源,不要在容器内存极紧的环境下长时间挂机。用完就stopquit退出。

5. dump文件分析:MAT里最值钱的三个视图

5.1 拿到dump后先别急着看Dominator Tree

很多人在拿到hprof文件后,第一件事就是打开MAT看Dominator Tree,然后盯着最大的几个对象发呆。这是不高效的。我建议的顺序是:先看Leak Suspects Report,再看Histogram,最后才是Dominator Tree。

Leak Suspects Report是MAT自动分析泄漏嫌疑的报告,它会根据对象的保留大小,给出几个可能的问题路径。这个报告有误报,但作为方向提示非常有效。报告里会展示一条引用链:从GC Root到嫌疑对象之间的路径。这条路径会直接告诉你对象是被谁持有着。

打开MAT后,点击菜单Open Heap Dump,选择hprof文件,MAT会自动执行分析。分析完成后,在Reports里选择Leak Suspects Report,就能看到完整报告。

5.2 Shallow Heap、Retained Heap与Dominator Tree

如果Leak Suspects Report给出的信息还不够,再深入看Histogram和Dominator Tree。这里有两个概念必须搞清楚,不然很容易看错方向。

Shallow Heap是对象本身占用的内存,不包括它引用的其他对象。Retained Heap是对象被回收后,连带能被回收的所有对象的内存总和。简单说,Retained Heap才是“如果删掉这个对象,真正能释放多少内存”。

在Dominator Tree里,我习惯按Retained Heap从大到小排序。一个Retained Heap很大的对象,说明它直接或间接持有了一大片对象图。如果是业务对象持有了一堆历史数据,排查方向就清楚了。

比如,线程池里的线程对象,Retained Heap通常不会小,因为它的线程栈里保存了执行上下文。但如果某个线程Retained Heap特别大,并且线程栈指向一个业务对象,那就要怀疑这个线程是否长期处理完请求但没释放上下文。

5.3 Histogram与OQL:直接查可疑类型的实例

Histogram按类统计对象数量和占用空间。排查泄漏时,我会先看Class Name里是否有容器类或业务类异常突出。比如HashMap$Node几百万个,或者是自己业务包下的某个数据对象几十万个。

定位到可疑类后,可以用OQL在MAT里直接查询:

SELECT * FROM com.example.TraceSpan WHERE queue != null

OQL的语法类似SQL,支持字段访问和条件过滤。用它来筛查“某个字段长期为非空”的对象,或者“创建时间早于某个时间点”的对象,非常高效。

5.4 ThreadLocal泄漏的dump特征:一个完整案例

回到开头那个事故,我当时分析的dump具备非常典型的ThreadLocal泄漏特征。在MAT的Dominator Tree里,看到大量java.lang.Thread对象的Retained Heap非常大。展开后,发现它们的threadLocals字段下挂着一个ThreadLocalMap,Map里的Entry数量达到几百万。

具体链路是这样的:每次请求进来,TraceFilter创建Span对象,塞到ThreadLocal里。线程池中的线程长期存活,线程不销毁,ThreadLocalMap里的Entry就不会清理。虽然Entry的Key(ThreadLocal对象本身)是WeakReference,但Value(Span对象)是强引用。而且这个Span对象内部有一个List记录子跨度,整个对象图非常大。几个月累积下来,几百万个Entry把老年代撑爆。

修复方案很简单:在TraceFilter的finally块里调用remove。但这类问题的排查成本远高于修复成本。Debug到这一步后,我强烈建议顺便检查一下项目里所有使用ThreadLocal的地方,统一排查是否都做了清理。

5.5 dump文件读完之后:把结论带回代码

dump分析的本质是回答一个问题:什么对象占用了大量内存,谁持有着它。完成这一步后,要回到代码里,找到持有链上真的不合理的逻辑。我见过有人因为dump分析做得不够透,看到一个Map很大就认定是Map的问题,结果Map只是容器,持有Map的东西才是根因。所以分析时一定要追到GC Root路径的顶层,别在中间层停住。

6. JVM参数调优与GC选型:把结论落成参数模板

6.1 堆参数与常见配置模板

排查完泄漏,修完代码之后,OOM事故的另一个重要收尾是审视JVM参数是否合理。不是说参数能解决泄漏,但合理的参数可以减少误伤、提高吞吐、降低GC停顿。

以下是我基于JDK 8和G1给一个常规8G容器服务整理的模板,堆大概占用一半:

bash -Xms4g -Xmx4g -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m -Xss512k -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:ParallelGCThreads=8 -XX:ConcGCThreads=4 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/opt/logs/dump -Xloggc:/opt/logs/gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:InitiatingHeapOccupancyPercent=45

几个设计点说明一下:

  • -Xms-Xmx设为相同值,避免堆在运行时动态伸缩带来的性能抖动。容器内存为8G时,堆分配4G是相对保守的,还给堆外内存、线程栈、Metaspace留了余量。
  • -XX:MetaspaceSize=256m是触发元空间GC回收的初始阈值,不是最小容量。MaxMetaspaceSize=512m是硬上限,防止元空间无限占用本机内存。
  • -Xss512k对绝大多数业务足够。每个线程栈512K,如果线程池有200个线程,大约占100M内存。如果栈过深导致StackOverflowError,再单独评估调大。
  • InitiatingHeapOccupancyPercent=45表示老年代占用达到45%时触发混合回收。这个值设低了回收频繁,设高了容易堆积。如果是G1,这个参数很关键,G1的回收节奏就是靠它把控的。
  • HeapDumpOnOutOfMemoryError和GC日志必须开,这是事故后追溯的唯一凭证。

JDK 11及更高版本,GC日志参数改为统一的-Xlog

bash -Xlog:gc*,gc+age=trace:file=/opt/logs/gc.log:time,uptime,level,tags:filecount=10,filesize=50m

filecountfilesize表示日志按50M滚动,保留10个文件,避免单个GC日志无限膨胀。

6.2 GC选型:CMS退役后的选择逻辑

JDK 8时代,很多团队还在用CMS。CMS在JDK 9被标记废弃,JDK 14被正式移除。还在用JDK 8并且堆在6G以上的服务,我建议优先考虑G1,原因有三个:

  • G1基于Region布局,支持可预测的停顿时间模型,-XX:MaxGCPauseMillis可以设定停顿目标。
  • G1适合大堆(4G以上),而CMS在堆过大时Full GC会非常可怕。
  • G1从JDK 9开始成为默认垃圾收集器,团队后续升级JDK不会有明显的GC算法断层。

如果服务延迟非常敏感、堆在几十G级别,可以考虑ZGC。ZGC的停顿时间能控制在十几毫秒以内,但它需要JDK 11或17配合,且对内存占用更“贪心”——ZGC需要额外的堆外内存来存放染色指针和转发表。我们的一个低频但对延迟要求极高的对账服务用的ZGC,堆10G,停顿基本稳定在5ms左右,效果让我意外。

CMS还在JDK 8存量场景里大量存在,我的建议是:短期不升级JDK也行,但要把-XX:+UseConcMarkSweepGC替换成-XX:+UseG1GC并做好压测回归。CMS的Concurrent Mode Failure一旦出现,带来的Serial Old Full GC停顿可以长达几十秒,这是线上不可接受的。

6.3 参数调优的三个常见坑

参数调优有几个坑,我单独挑出来说。

第一个坑是盲目调大-Xmx。容器内存有限,堆设得过大,留给堆外内存和线程栈的空间不足,反而可能触发unable to create new native thread或Direct buffer memory。我见过一个案例,16G容器设了14G堆,结果线程稍一增长就OOM。堆占容器内存的比例,根据我的实践,常规业务50%-60%是合理区间,如果你想设更高,必须评估堆外内存的占用。

第二个坑是启动参数写错被人忽略。error invoking method. failed to launch jvm就是一个典型症状。排查时打开启动脚本检查,大概率会发现以下某个问题:JDK版本不支持某个-XX参数、-Xmx的值格式写错(比如-Xmx 4g多了空格)、或者同时指定了不兼容的GC参数。JVM参数校验是很严格的,任何不认识的参数都会导致启动失败。

第三个坑是G1的MaxGCPauseMillis设得过于激进。有人为了追求低延迟把值设成50ms,结果G1为了满足停顿目标,频繁进行年轻代回收,吞吐量大幅下降。我通常从200ms起步,根据压测结果逐步下调,找到停顿与吞吐的平衡点。

6.4 调优后的验证方法:压测与GC日志

参数调完不能直接上线,必须验证。验证手段有两个:压测和GC日志分析。

压测时我会关注三个数:吞吐量、P99延迟、Full GC次数。参数调整前后的数据做对比。如果Full GC次数明显下降、延迟没有恶化,参数就是有效的。如果P99延迟显著上升,说明GC停顿变多了,需要回退。

GC日志是另一个验证工具。JDK 8的gc.log日志通过-XX:+PrintGCDetails输出,可以看到每次年轻代回收和老年代回收的耗时。注意看两个指标:Full GC的次数和每次Full GC的耗时。如果调参后Full GC频率从每小时10次降到每8小时1次,说明堆配置和回收策略对路了。

7. 监控与预防:真正该投入时间的地方

7.1 容器与虚拟化环境里的特殊注意事项

现在大部分服务都在容器里跑,这里有个容易被忽略的细节:容器默认不感知宿主机资源限制。如果JVM启动时没有显式指定-Xmx,在JDK 8下默认是宿主机物理内存的1/4,而不是容器的内存限制。这会导致JVM认为自己可以分配很大的堆,实际却超出了容器的内存上限,被cgroup杀掉。

JDK 8u191之后引入了-XX:+UseContainerSupport,默认开启,可以感知容器内存限制。如果你们的JDK版本比较旧,或者使用了自定义的启动脚本,建议确认一下这个参数的状态。更稳妥的做法是显式配置-Xmx,而不是依赖JVM自动适配。

另一个容器场景的坑是CPU限制。G1的线程数默认会按宿主机的CPU核数计算,但容器可能只分配了2核。如果不显式设置ParallelGCThreadsConcGCThreads,JVM会创建远超容器资源的GC线程,导致CPU争抢。显式指定这两个参数是必要的。

7.2 GC日志与监控告警的正确配置

预防OOM的第一步,是把GC日志和监控指标做好。GC日志是事后分析的第一手证据,没有它,一切排查都是盲猜。

监控层面,我建议至少盯四个指标:堆内存使用率、老年代使用率、Full GC次数、线程数。这四个指标加上基础的业务异常告警,已经能覆盖大部分内存风险。

告警阈值的设定,我结合实践给一个参考:

指标建议阈值说明
老年代使用率连续15分钟超过85%触发排查,避免走到OOM边缘
Full GC频率10分钟内超过5次说明内存堆积严重,需立即处理
堆内存使用率连续30分钟超过90%告警但不一定紧急,先看是否流量高峰
线程数超过基线值的2倍排查线程池配置、连接池泄漏

监控永远比人肉排查先行半步。如果能在老年代涨到85%的时候收到告警,就还有充足的时间用Arthas抓状态、用heapdump保留现场,而不是等OOM把服务打崩。

7.3 发布前的排查检查清单

在经历了几次线上OOM之后,我在团队里固化了一份发布前的检查清单,每次涉及内存敏感的功能都过一遍:

  • 是否使用了ThreadLocal?如果是,是否所有路径都有finally remove?
  • 是否新增了静态集合或缓存?如果是,是否设置了容量上限和过期策略?
  • 是否引入了新的动态代理或反射生成类?如果是,关注Metaspace增长。
  • 是否使用了NIO或Netty?如果是,确认ByteBuf内存有释放逻辑。
  • 是否修改过线程池参数?确认最大线程数、队列容量不会把内存打爆。
  • 是否调整了JVM参数?确认与JDK版本、容器资源匹配。
  • 是否本地压测过内存曲线?确认内存在压测后能回落。

这份清单不复杂,但每一行背后都有一次或多次线上事故的教训。

7.4 从救火到体系:调优的最大收益在事后

说实话,Arthas的命令集再全、排查链路再清晰,本质上都属于“救火”范畴。一次OOM事故的最有价值产出,不是修复了几行代码,而是把问题沉淀成预防措施。我的习惯是每处理完一次内存问题,都更新一遍监控指标和检查清单,把新踩到的坑固化进去。这样半年之后,团队再遇到内存问题,第一反应不再是“谁去重启一下”,而是“先dump再查Arthas”的标准化流程。

结合我个人的体会,处理JVM内存问题最关键的并不是掌握多少花哨的命令,而是保持一个稳定的排查节奏:先看内存模型判断OOM类型,再用GC日志确认增长趋势,然后通过Arthas抓现场、用MAT分析对象图,最后回到代码修复根因,并补上监控和预防措施。这一套流程走熟之后,不管堆大小、GC算法、JDK版本怎么变,面对OOM时你都不会慌。

最后再分享一个小技巧:每次排查完,把用到的Arthas命令、关键GC日志截图、MAT的分析结论整理成一份带时间线的文档。下次再遇到类似问题时,这份文档比任何教科书都管用,因为它记录的是你自己环境里的真实数据和排查路径。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询