搞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 12345live参数的意思是只导出存活对象,这样文件会小一些,也更接近“当前真的被引用着”的对象。导出期间进程会短暂停顿,线上服务要谨慎操作。更温和的方式是用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=200G1适合大堆场景,能控制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一次。当你亲眼看到两把锁的等待关系出现在堆栈里,以后再遇到类似问题,你就会感觉是在跟老朋友打招呼,不会慌。至于内存溢出,我个人的心得是:留堆转储是底线,对象引用链是突破口,别迷信参数调优,代码本身的对象生命周期才是根源。希望这篇长文能帮你在下次排障时少走几步弯路。