news 2026/9/26 7:29:15

JVM内存溢出与死锁排查实战:从OutOfMemoryError到jstack定位

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
JVM内存溢出与死锁排查实战:从OutOfMemoryError到jstack定位

搞JVM的人,早晚都要撞上内存溢出和死锁这两堵墙。我这两年处理过的线上事故里,八成和它们有关——不是应用莫名其妙重启,就是接口突然卡死,查日志发现线程全堵在锁上。很多同事一听到OutOfMemoryError就懵,拿着日志不知道从哪儿下手;遇到死锁更是只会上网搜答案,搜完还是不懂为什么死锁。所以我打算把这几年排查内存溢出和死锁的经验整理一遍,不写教科书式的理论,直接讲我们在实际项目中怎么定位、怎么解决。这篇内容适合正在写Java、跑过线上服务、或者准备面试时被问到JVM调优的朋友看,尤其适合那种“日志里已经出现java.lang.OutOfMemoryError,但不知道下一步干嘛”的读者。

我先说一个总体感受:内存溢出和死锁,看起来是两个独立的问题,但在真实事故里经常一起出现。接口卡死可能是死锁,也可能是GC长期停顿;Full GC频繁可能是内存泄漏,也可能是代码里临时分配了一个超大对象。要快速定位,靠的不是拍脑袋,而是一套固定的排查思路。下面我就把这两个问题拆开讲,每一步都给出可以直接照做的命令、参数和工具操作。

1. 内存溢出的本质与常见场景

1.1 先搞清楚JVM内存模型再谈溢出

很多人一听到“内存溢出”就只想到堆,实际上JVM里有好几块独立区域,各有各的溢出方式。常用的HotSpot JVM主要分为堆(Heap)、元空间(Metaspace)、虚拟机栈(VM Stack)和本地方法栈(Native Method Stack)。平时说的Java heap space溢出,指的是堆里放不下新对象了;Metaspace溢出,指的是类元数据占了太多空间;还有一种很常见的Unable to create new native thread,是操作系统线程创建失败,本质上是原生内存不够用了。

我习惯用一个生活类比去理解这些区域:堆就像一个大仓库,专门放你创建的对象实例;元空间是货架上的标签区,放类和方法的元信息;虚拟机栈则是每个线程干活时用的临时工作台。仓库堆满了会报堆溢出,标签区堆满了会报元空间溢出,而线程工作台太多,连操作系统分配线程的内存都没了,就会报无法创建线程。

另外还要区分一下JRE和JVM的关系。JVM是Java虚拟机,负责运行字节码;JRE里包含JVM和运行Java程序所需的核心类库。你装一个JDK,里面就自带JRE和JVM。排查内存溢出时,我们实际上操作的是JVM运行时数据区,和JRE的关系不大,但如果是程序启动时报No suitable JVM was found to start the application,那通常是环境变量里找不到JVM,属于环境配置问题,和运行时溢出是两码事。

1.2 最常见的三种OutOfMemoryError

java.lang.OutOfMemoryError: Java heap space是最常见的,线上服务如果堆设置太小,或者代码里一次性加载大文件、批量查询返回了上百万条记录,堆就会顶不住。其次是java.lang.OutOfMemoryError: Metaspace,多见于反复热部署、动态生成大量代理类或CGLIB类的场景,Spring项目里如果开了过多的AOP、频繁reload,很容易踩到。第三种是java.lang.OutOfMemoryError: unable to create new native thread,常见于线程数创建过多,或者操作系统对进程的线程数有限制,虽然堆还没满,但线程栈占用的原生内存已经耗尽。

判断是哪一种溢出,不能只看错误名字,还要结合日志时机。如果Full GC日志频繁出现,且堆时不时被清空又立刻填满,说明对象在不断产生且无法回收。如果错误发生在某次导出操作、报表查询、Excel上传时,那么很可能是临时创建了大对象。比如热词里有个XSSFWorkbook内存溢出,就是典型的Apache POI写Excel时,XSSFWorkbook会在内存里构建整个工作簿结构,一个大文件可能轻松吃掉几百MB堆内存,这时候堆设置再大也扛不住频繁的大型导出。这类问题后面我会专门说怎么处理。

2. 内存溢出排查方法与工具实操

2.1 从日志到堆转储:先把案发现场留下来

排查内存溢出的第一原则:不要重启服务,先保留现场。很多人一看服务挂了就急着重启,结果堆转储文件没生成,什么证据都没了。正确做法是在JVM启动参数里提前开启内存溢出时的自动堆转储:

-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/home/app/logs/heapdump.hprof

加上这个参数后,只要发生OutOfMemoryError,JVM会先把当前堆的完整快照写到指定路径,然后再崩溃。这个文件可能很大,几百MB到几个GB都很正常,但它是定位问题的金矿。我们排查的第一步永远是去找这个hprof文件,而不是漫无目的地猜。

如果服务还没挂,只是堆占用率一直很高,也可以用jmap手动导出一份堆快照。假设进程ID是12345:

jmap -dump:live,format=b,file=/home/app/logs/heap.dump 12345

live参数的意思是只导出存活对象,这样文件会小一些,也更接近“当前真的被引用着”的对象。导出期间进程会短暂停顿,线上服务要谨慎操作。更温和的方式是用jcmd GC.heap_dump,效果类似,但对JVM的侵入性相对小一点,不过同样会有停顿。

拿到堆转储后,很多人第一反应是打开日志看什么,其实日志能提供的信息非常有限。真正有价值的是转储里每个对象的引用关系。另外跑几个常用命令也有助于快速判断:jmap -heap 12345可以看当前堆各区使用情况,包括Eden、Survivor、Old代的占用比例;jstat -gcutil 12345 1000可以每秒输出一次GC统计,观察Young GC和Full GC的频率。如果Full GC每几十秒一次,且Old代占用只增不减,基本可以判定为内存泄漏,而不是一次性的大对象分配。

2.2 用MAT和VisualVM定位大对象

拿到hprof文件后,我习惯先用MAT(Eclipse Memory Analyzer)打开。MAT加载完堆转储后,会生成一个Leak Suspects报告,它自动筛选出疑似导致内存泄漏的根对象。这个报告不一定准,但它能给你一个切入点。

看MAT有几步是固定的:第一步看Histogram,按对象实例数或Retained Heap排序。Retained Heap表示如果这个对象被回收,能够释放多少内存,这个值非常关键。第二步看Dominator Tree,找到占用内存最大的对象树,顺着引用链往上爬,就能找到是哪个类持有的这些对象。第三步看GC Roots引用链,确认这个引用是来自静态变量、ThreadLocal、还是某个缓存容器。

我处理过一个真实case:服务每隔一段时间就卡顿,Full GC频繁,但代码看起来没什么问题。用MAT打开堆转储后,发现java.lang.ThreadLocal下面的ThreadLocalMap里挂着一个巨大的HashMap,里面存了大量历史查询条件。原来是业务代码把查询条件塞进了线程局部变量,却从没清理,线程池里的线程一直存活,于是Map越来越大,最终把堆撑爆。这就是典型的“看起来不是泄漏,实际是生命周期过长”的例子。

VisualVM对于不熟悉命令行的人更友好,它能直接打开堆转储,显示饼图和对象占用排行。但它分析和对比能力比MAT弱,定位复杂问题我还是推荐MAT。另外,如果怀疑是Excel导出导致的内存溢出,像XSSFWorkbook这种大对象,在MAT的Histogram里搜org.apache.poi.xssf.usermodel.XSSFWorkbook就能看到它的Retained Heap,通常高得吓人。解决办法很简单:改用SXSSFWorkbook流式写入,它会把部分数据写到磁盘,而不是全部放内存。这是POI官方推荐的低内存模式,也是我后来彻底告别导出溢出的关键。

3. 死锁的原理与检测手段

3.1 死锁是怎么产生的

死锁是多个线程互相持有对方需要的资源,谁都不肯放手,最后所有线程都卡在原地。经典的四个必要条件是:互斥、持有并等待、不可剥夺、循环等待。四个条件同时满足,就会死锁。

我经常用银行转账的例子来讲:线程A持有账户1的锁,想去拿账户2的锁;线程B持有账户2的锁,想去拿账户1的锁。两个线程都在各自持有的锁上等待对方释放锁,于是一个都走不动。代码层面最常见的死锁场景是嵌套synchronized、ReentrantLock加锁顺序不一致,还有分布式锁和本地锁混用导致锁顺序错乱。

光看四个条件容易忘,我建议你写代码时自己造一个死锁出来看看。很简单:两个线程,两个Object对象作为锁,线程1先锁A再锁B,线程2先锁B再锁A。线程1和线程2同时启动,过一会儿程序就卡死了。为什么需要两个线程?因为单线程不可能自己和自己死锁,JVM的锁是可重入的,同一个线程可以反复获取同一把锁。死锁必然发生在多线程、多把锁之间。

细想一下,死锁其实比内存溢出更隐蔽。内存溢出至少报错,日志里能看到异常信息;死锁很多时候不报错,只是接口无响应,CPU占用可能很低,线程全部处于BLOCKED状态。如果没人查看线程栈,这个问题可能一直持续到服务被外部探活发现异常、然后重启为止。

3.2 用jstack快速定位死锁

排查死锁最好的工具就是jstack。它打印出JVM所有线程的当前状态和栈信息。假设进程ID是12345,执行:

jstack 12345 > thread_dump.txt

然后打开这个文件,搜索Found one Java-level deadlock。如果存在死锁,jstack会在文件末尾直接给出结论,并列出死锁涉及的线程和锁的循环。下面是一个简化的输出示例:

Found one Java-level deadlock: ============================= "Thread-B": waiting to lock monitor 0x00000000010b3a80 (object 0x00000000d8d7c920, a java.lang.Object), which is held by "Thread-A" "Thread-A": waiting to lock monitor 0x00000000010b3a68 (object 0x00000000d8d7c960, a java.lang.Object), which is held by "Thread-B" Java stack information for the threads listed above: ...

看到这个输出,基本可以确认死锁了。接下来要做的是对照源码,找出两个线程的锁获取顺序。通常罪魁祸首就是某个公用方法里对多个资源的加锁顺序不一致,比如一个方法先锁A再锁B,另一个方法先锁B再锁A。解决办法通常有三种:一是让所有线程按照同一顺序加锁,二是使用tryLock并设置超时,拿不到锁就释放已有锁再重试,三是用锁的哈希值排序来统一加锁顺序。

还有一种排查方式,如果你有jvisualvm,也能在“线程”页签里直接看到死锁检测,它会用红色标记死锁线程。但线上环境往往没条件开GUI,jstack命令更实用。另外,线程dump文件里如果大量线程都在WAITING状态,且集中在某个park方法上,可能是LockSupport.park导致的逻辑等待;如果大量线程BLOCKED且锁对象集中,则更像资源竞争,不一定是死锁,这时要看CPU占用和锁持有时间才能判断。

4. 防患于未然:参数调优与代码习惯

4.1 常见JVM参数怎么配

很多人一谈到JVM调优就急着问-Xmx该设多大。我的经验是:参数配置要依据应用的实际内存画像,而不是拍脑袋。默认情况下,HotSpot的堆大小是物理内存的四分之一,但服务器上往往跑着多个进程,必须显式指定。推荐一组相对稳健的基础参数:

-Xms1024m -Xmx1024m -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m

-Xms和-Xmx设成一样,可以避免堆大小动态伸缩带来的性能抖动,集合大小频繁变化会触发额外的GC。元空间设置固定范围也很重要,尤其对于动态加载类比较多的框架,元空间默认上限是物理内存大小,不设上限容易让类元数据无限膨胀。如果你用的是JDK 8及以上,可以开启G1垃圾回收器:

-XX:+UseG1GC -XX:MaxGCPauseMillis=200

G1适合大堆场景,能控制GC停顿时间。但如果堆只有几百MB,G1并不比ParallelGC快。调优原则是:先监控,再调整。你至少跑一天业务,记录下Full GC次数、Old代占用趋势,再看要不要改参数。

这里还要注意一个问题:堆设得太大不一定是好事。我们遇到过把-Xmx从8G调到16G后,反而出现长时间Full GC的情况,因为一次Full GC要扫描的堆区域更大,停顿时间更长。内存分配不是越多越好,关键是让对象生命周期更短、晋升到Old代的更少。

4.2 我在实际项目里踩过的坑

第一个坑:ThreadLocal不清理。ThreadLocal看起来是线程私有,但如果用的是线程池,线程不销毁,ThreadLocal里的对象就会一直存活。尤其是往里面放了一个List或Map,随着请求不断增加,这个线程的数据越攒越多,最终OOM。正确做法是每次用完在finally中调用remove(),或者至少把ThreadLocal对象本身声明为static final,避免被误当作普通实例变量重复创建。

第二个坑:静态集合当成缓存用。很多人把数据放到static Map里,说这是“缓存”,但它既没有过期策略也没有容量上限。在低并发场景下看起来没事,一旦数据量涨上去,这个静态Map就变成了内存黑洞。我见过一个案例:静态Map里存了所有订单的快照,每天几百万条,一周后服务直接堆溢出。后来改成Caffeine本地缓存,设置最大容量和过期时间,问题立刻解决。

第三个坑:锁的加锁顺序不一致。只要在代码里有两个地方分别以不同顺序获取同一批锁,死锁风险就存在。哪怕当前没触发,也要在code review时提前暴露。一个我至今印象深刻的排查经历:两个服务通过HTTP互相调用,每个服务内部都持有本地锁,调用对方时又去竞争对方的本地锁,结果形成跨服务的分布式死锁。这种情况jstack只能看到本地线程状态,还要结合调用链日志分析请求在等待什么资源。

5. 常见问题速查表

现象可能原因优先排查手段解决参考
日志出现Java heap space堆设置过小或对象大量堆积jmap -heap看Old代占用;MAT看Retained Heap调大-Xmx;优化对象引用;检查泄漏
Metaspace溢出动态生成类过多、热部署频繁jstat -gcutil看Meta区占用;查动态代理代码适当调大MaxMetaspaceSize;关闭不必要AOP
unable to create new native thread线程数创建过多或系统限制jstack统计线程数;ps -eLf | wc -l检查线程池配置;调低栈大小-Xss
Full GC频繁但老年代持续增长内存泄漏jmap -dump+ MAT对比两次堆转储修复引用持有者;及时清理ThreadLocal
接口无响应,CPU不高死锁或锁竞争jstack搜deadlock;看BLOCKED线程统一加锁顺序;合理设置锁超时
Excel导出时堆溢出XSSFWorkbook一次性构建大对象MAT找XSSFWorkbook的Retained Heap改用SXSSFWorkbook流式写入

这张表我贴在工位旁边的白板上,不只是给新人看,也是给自己提个醒。遇到问题先按表格流程走,通常五分钟内能确定大方向。

最后再分享一个小技巧:想真正理解死锁,不要只看文档,自己去写一个会死锁的程序,跑起来后手动jstack一次。当你亲眼看到两把锁的等待关系出现在堆栈里,以后再遇到类似问题,你就会感觉是在跟老朋友打招呼,不会慌。至于内存溢出,我个人的心得是:留堆转储是底线,对象引用链是突破口,别迷信参数调优,代码本身的对象生命周期才是根源。希望这篇长文能帮你在下次排障时少走几步弯路。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/26 7:28:33

西电A测语音识别机械臂方案:从硬件选型到联调避坑全解析

1. 项目缘起与整体方案拆解1.1 这个项目到底在做什么“西电25年A测 语音识别机械臂方案”这个标题,第一次看到的时候我就知道,这大概率是西安电子科技大学某门实践类课程(A测通常指阶段性能力测试或综合测评)的题目。核心任务很明…

作者头像 李华
网站建设 2026/9/26 7:28:25

磁悬浮系统调试实战:起浮、PID整定与振荡排除

做磁悬浮系统调试,第一次上电就敢直接猛推PID增益的,基本都是奔着炸管子去的。我见过不少新手卡在"起浮就振、浮起了就啸叫、跑起来就掉负载"这三个坎上,其实这三件事分别对应的是起浮调试、PID参数现场整定、振荡问题排除&#xf…

作者头像 李华
网站建设 2026/9/26 7:28:01

AI Agent文档安全实战:五类风险与防护基线

前几年大家聊AI,聊的是“这个模型能写诗、能答题”;现在聊AI,画风已经变成“让Agent替我把合同审了”“让Agent自动把周报写了再抄送所有人”。AI Agent确实是这一轮技术浪潮里最能落地的东西之一,它能调用工具、查阅文档、拆解任…

作者头像 李华
网站建设 2026/9/26 7:28:01

使用 AWS SDK for Kotlin 操作 Amazon API Gateway 的完整实践指南

示例工程教程后端 【免费下载链接】aws-doc-sdk-examples Welcome to the AWS Code Examples Repository. This repo contains code examples used in the AWS documentation, AWS SDK Developer Guides, and more. For more information, see the Readme.md file below. 项目地…

作者头像 李华
网站建设 2026/9/26 7:27:32

专利代理提效实战:三款AI工具破解检索撰写翻译难题

做专代人这行,圈里人都懂,就是专利代理。我入行快十年,案头永远堆着交底书、对比文件、审查意见、补正书……一天下来真正留给自己的时间没几个小时。前两年我还在硬扛,后来想明白了:那些重复检索、初稿搭建、格式打磨…

作者头像 李华
网站建设 2026/9/26 7:27:12

C语言核心三件套:常量、变量与运算符深度解析

1. 为什么C语言绕不开这3类对象学C语言的人大致都会经历两个阶段:头一个月觉得语法琐碎、指针难啃,过了一阵子突然开窍,发现C语言翻来覆去就那几样东西——常量、变量、运算符和表达式。这不是错觉,C语言这门语言从设计之初就没打…

作者头像 李华