ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

JVM内存泄漏与OOM排查实战:从GC日志到Arthas/MAT完整攻略

JVM内存泄漏与OOM排查实战:从GC日志到Arthas/MAT完整攻略 今年3月我负责的一个订单导出服务在凌晨4点被监控告警打醒。现象非常典型CPU使用率接近100%老年代内存从原本稳定的60%一路涨到90%以上Full GC从每5分钟一次变成每十几秒一次最后直接抛OutOfMemoryError。我重启了应用结果二十多分钟后又开始涨第二天同一时间照样崩。最终定位到根因靠的是一份堆dump加上几条Arthas命令——一个链路追踪过滤器把每次请求的Span对象塞进了ThreadLocalfinally里忘了remove日积月累把老年代撑爆了。那次踩坑之后我把整条JVM内存排查链路重新梳理了一遍从内存模型判断OOM类型到用GC日志确认增长趋势再到用Arthas抓线程、抓对象、验证代码行为最后用MAT分析dump定位泄漏根因。这个过程里有些命令是救命的有些坑是文档里不会写的。这篇文章就是把这些沉淀整理成一套可以直接抄作业的排查方案适合线上服务出过OOM但不知道怎么系统排查的同学也适合准备JVM面试时需要把知识串成体系的人。1. 一次凌晨的OOM事故先还原排查现场1.1 事故时间线与应急处置这个订单导出服务的部署形态很简单4台8C16G的容器JVM堆设置4GJDK 8G1垃圾回收器平时老年代占用稳定在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 MemoryNIO的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先别急着查代码先把报错信息读明白。我总结出五种最常见的根因模型每种都有对应的排查侧重对象持续堆积型泄漏某个集合、缓存、ThreadLocal一直持有对象引用GC无法回收。特征是GC日志里老年代持续增长dump里能看见某种类型对象占了大头。这是最典型的泄漏形态也是本文后面案例分析的主线。瞬时分配过大并发量突增单个请求创建了大量对象或一次性加载了超大集合比如把整张表查进内存。特征是堆曲线呈尖峰状峰值过后能回落。这种不叫泄漏处理手段是限流、分页、调整堆大小。元空间膨胀动态代理、反射生成类、Groovy脚本编译、热部署加载了大量Class对象。特征是Metaspace区域持续上涨dump时堆里可能并不大但元空间吃满了本机内存。线程数耗尽每次请求都new Thread或线程池没设置上限线程栈占满进程内存。特征是unable to create new native thread这时候就算堆还有空间也别只顾着调堆。直接内存泄漏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/jstatJRE是Java运行时环境只包含JVM和核心类库JVM是Java虚拟机是JRE里的核心执行引擎。JDK里包含了JREJRE里包含了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. 内存泄漏判断与代码层嫌疑定位别急着开dump3.1 怎么区分“真泄漏”和“流量增长”每次讨论OOM都会有人说“是不是最近流量涨了导致堆不够”。这个疑问很合理但判断起来并不难。我用的方法有三种可以在拿到GC日志后快速做区分。第一种是看老年代增长曲线和流量的相关性。如果是流量增长内存曲线会跟着请求量波动高峰期上涨、低峰期回落整体水平可能上移但不会无限上涨。如果低峰期内存也不回落那基本可以判定为泄漏。第二种是压测验证。把服务接入测试环境用固定QPS压测观察内存是否线性上涨。如果内存稳定在某个水位说明堆配置够用如果持续上涨不回落就是泄漏。这个办法最直接但需要具备压测条件。第三种是看dump里的对象是不是业务相关的。如果dump里大量堆积的对象是历史请求的残留数据比如一个Map里保存了几天前的请求对象那妥妥是泄漏。如果dump里全是当前请求的瞬时对象那大概率是分配过快。3.2 代码层四大高频泄漏模式在JVM层面的诊断工具出手之前我觉得有必要先把代码层的常见泄漏模式列出来。因为在很多情况下工具只是验证手段真正的根因要回到代码里去修。根据我的经验90%以上的生产环境内存泄漏属于以下四种模式第一是静态集合持有对象。最常见的是static Map、static List或缓存框架使用不当往里面放了数据却不清理。尤其在业务代码里有人图方便把临时数据塞进静态Map当缓存用但没设淘汰策略时间一长把老年代撑爆。第二是ThreadLocal未清理。每个ThreadLocal的Entry是WeakReference指向Key但Value是强引用链。线程池里的线程是长期存活的ThreadLocal不removeValue就永远无法回收。这就是我开头那个事故的直接原因。使用ThreadLocal的场景包括链路追踪、登录态传递、数据库连接、日期格式化工具类都必须在finally块里remove。第三是监听器和回调未注销。Spring的ApplicationListener、Netty的ChannelHandler、观察者模式的Listener如果注册了没注销对象会被事件源长期引用。特别是那些用了静态事件总线或全局事件管理的代码一旦注册整个生命周期内都无法被GC。第四是IO和数据库连接未关闭。这种严格来说不算“内存泄漏”但Netty的ByteBuf、数据库ResultSet、文件流如果未关闭底层堆外内存或NIO缓冲区会一直占据内存。堆内dump可能看不出明显问题但进程内存会持续增长。3.3 前端内存泄漏排查的对照参考今年热搜里“前端 内存泄漏怎么排查”也是一个高频问题。虽然场景不同但排查思路和JVM是相通的。浏览器DevTools的Performance面板录制内存时间线原理等同于JVM的GC日志Heap Snapshot等同于堆dumpDetached 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。第二种是指定进程IDbash 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 第二轮线程与GCthread和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 #cost100这个命令会跟踪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与retransformArthas还有一个大杀器是ognl命令可以直接在运行时执行表达式访问静态字段、调用对象方法。我在定位ThreadLocal泄漏时用过这样一个表达式ognl #threadjava.lang.ThreadcurrentThread(), #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本身会占用一定资源不要在容器内存极紧的环境下长时间挂机。用完就stop或quit退出。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 ! nullOQL的语法类似SQL支持字段访问和条件过滤。用它来筛查“某个字段长期为非空”的对象或者“创建时间早于某个时间点”的对象非常高效。5.4 ThreadLocal泄漏的dump特征一个完整案例回到开头那个事故我当时分析的dump具备非常典型的ThreadLocal泄漏特征。在MAT的Dominator Tree里看到大量java.lang.Thread对象的Retained Heap非常大。展开后发现它们的threadLocals字段下挂着一个ThreadLocalMapMap里的Entry数量达到几百万。具体链路是这样的每次请求进来TraceFilter创建Span对象塞到ThreadLocal里。线程池中的线程长期存活线程不销毁ThreadLocalMap里的Entry就不会清理。虽然Entry的KeyThreadLocal对象本身是WeakReference但ValueSpan对象是强引用。而且这个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:MetaspaceSize256m -XX:MaxMetaspaceSize512m -Xss512k -XX:UseG1GC -XX:MaxGCPauseMillis200 -XX:ParallelGCThreads8 -XX:ConcGCThreads4 -XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/opt/logs/dump -Xloggc:/opt/logs/gc.log -XX:PrintGCDetails -XX:PrintGCDateStamps -XX:InitiatingHeapOccupancyPercent45几个设计点说明一下-Xms和-Xmx设为相同值避免堆在运行时动态伸缩带来的性能抖动。容器内存为8G时堆分配4G是相对保守的还给堆外内存、线程栈、Metaspace留了余量。-XX:MetaspaceSize256m是触发元空间GC回收的初始阈值不是最小容量。MaxMetaspaceSize512m是硬上限防止元空间无限占用本机内存。-Xss512k对绝大多数业务足够。每个线程栈512K如果线程池有200个线程大约占100M内存。如果栈过深导致StackOverflowError再单独评估调大。InitiatingHeapOccupancyPercent45表示老年代占用达到45%时触发混合回收。这个值设低了回收频繁设高了容易堆积。如果是G1这个参数很关键G1的回收节奏就是靠它把控的。HeapDumpOnOutOfMemoryError和GC日志必须开这是事故后追溯的唯一凭证。JDK 11及更高版本GC日志参数改为统一的-Xlogbash -Xlog:gc*,gcagetrace:file/opt/logs/gc.log:time,uptime,level,tags:filecount10,filesize50mfilecount和filesize表示日志按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核。如果不显式设置ParallelGCThreads和ConcGCThreadsJVM会创建远超容器资源的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的分析结论整理成一份带时间线的文档。下次再遇到类似问题时这份文档比任何教科书都管用因为它记录的是你自己环境里的真实数据和排查路径。
返回列表