
搞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 最常见的三种OutOfMemoryErrorjava.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加上这个参数后只要发生OutOfMemoryErrorJVM会先把当前堆的完整快照写到指定路径然后再崩溃。这个文件可能很大几百MB到几个GB都很正常但它是定位问题的金矿。我们排查的第一步永远是去找这个hprof文件而不是漫无目的地猜。如果服务还没挂只是堆占用率一直很高也可以用jmap手动导出一份堆快照。假设进程ID是12345jmap -dump:live,formatb,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文件后我习惯先用MATEclipse 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也能在“线程”页签里直接看到死锁检测它会用红色标记死锁线程。但线上环境往往没条件开GUIjstack命令更实用。另外线程dump文件里如果大量线程都在WAITING状态且集中在某个park方法上可能是LockSupport.park导致的逻辑等待如果大量线程BLOCKED且锁对象集中则更像资源竞争不一定是死锁这时要看CPU占用和锁持有时间才能判断。4. 防患于未然参数调优与代码习惯4.1 常见JVM参数怎么配很多人一谈到JVM调优就急着问-Xmx该设多大。我的经验是参数配置要依据应用的实际内存画像而不是拍脑袋。默认情况下HotSpot的堆大小是物理内存的四分之一但服务器上往往跑着多个进程必须显式指定。推荐一组相对稳健的基础参数-Xms1024m -Xmx1024m -XX:MetaspaceSize256m -XX:MaxMetaspaceSize512m-Xms和-Xmx设成一样可以避免堆大小动态伸缩带来的性能抖动集合大小频繁变化会触发额外的GC。元空间设置固定范围也很重要尤其对于动态加载类比较多的框架元空间默认上限是物理内存大小不设上限容易让类元数据无限膨胀。如果你用的是JDK 8及以上可以开启G1垃圾回收器-XX:UseG1GC -XX:MaxGCPauseMillis200G1适合大堆场景能控制GC停顿时间。但如果堆只有几百MBG1并不比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关闭不必要AOPunable to create new native thread线程数创建过多或系统限制jstack统计线程数ps -eLf | wc -l检查线程池配置调低栈大小-XssFull GC频繁但老年代持续增长内存泄漏jmap -dump MAT对比两次堆转储修复引用持有者及时清理ThreadLocal接口无响应CPU不高死锁或锁竞争jstack搜deadlock看BLOCKED线程统一加锁顺序合理设置锁超时Excel导出时堆溢出XSSFWorkbook一次性构建大对象MAT找XSSFWorkbook的Retained Heap改用SXSSFWorkbook流式写入这张表我贴在工位旁边的白板上不只是给新人看也是给自己提个醒。遇到问题先按表格流程走通常五分钟内能确定大方向。最后再分享一个小技巧想真正理解死锁不要只看文档自己去写一个会死锁的程序跑起来后手动jstack一次。当你亲眼看到两把锁的等待关系出现在堆栈里以后再遇到类似问题你就会感觉是在跟老朋友打招呼不会慌。至于内存溢出我个人的心得是留堆转储是底线对象引用链是突破口别迷信参数调优代码本身的对象生命周期才是根源。希望这篇长文能帮你在下次排障时少走几步弯路。