1. 项目概述:为什么“内存泄露”不是一句抱怨,而是系统崩溃前的倒计时
“服务又OOM了”“堆内存涨得比工资还快”“凌晨三点收到GC告警,一查发现老年代满了”——这些话在Java后端团队的晨会、站会、故障复盘会上,出现频率高得让人麻木。但很多人没意识到:绝大多数所谓“内存暴涨”,根本不是流量突增或缓存没设上限导致的,而是典型的、可定位、可修复的内存泄露(Memory Leak)。它不像CPU飙高那样立竿见影,却像温水煮青蛙,悄无声息地蚕食堆空间,直到某次Full GC触发,整个应用卡死数秒,用户请求超时,订单丢失,监控曲线陡然拉直——而此时你翻遍日志,只看到一行冰冷的java.lang.OutOfMemoryError: Java heap space。
jstack,这个JDK自带、零成本、无需重启、不侵入业务代码的命令行工具,恰恰是撕开这类问题伪装的第一把解剖刀。它不测内存占用量,不画对象引用图,但它能精准暴露线程正在执行什么、卡在哪个方法、持有哪些锁、调用栈深度多少——而大量内存泄露的根源,就藏在那些“本该结束却一直活着”的线程里:比如一个未关闭的数据库连接池线程,持续缓存着查询结果;比如一个监听器注册后从未反注册,导致整个Activity(在Android场景)或Spring Bean被强引用无法回收;再比如一个静态Map不断put却从不remove,key是某个瞬时对象,value是大字节数组。这些场景下,jstack输出的线程状态(BLOCKED、WAITING、TIMED_WAITING)和栈帧,就是最直接的线索。
我带过的几个团队里,有位A同学曾花三天排查一个“偶发性内存溢出”,反复dump堆镜像用MAT分析,结论都是“String对象太多”,但始终找不到源头。直到他静下心来,在服务刚启动、内存尚低时执行jstack -l <pid>,对比运行2小时后的jstack输出,发现一个名为CacheRefreshScheduler的线程,其栈顶始终停在java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(),而它持有的this对象,正是那个静态Map的持有者。问题瞬间清晰:定时任务没做幂等控制,重复提交导致多个线程阻塞在同一个条件队列,每个线程都持有一个对Map的强引用副本。修复仅需加一行synchronized和一个if (running) return;判断。这件事让我确信:jstack不是“辅助工具”,而是内存泄露排查链上不可替代的起点——它帮你把“大海捞针”变成“按图索骥”。
这篇指南不讲JVM内存模型理论,不堆砌GC算法名词,也不推荐一堆商业APM工具。它只聚焦一件事:当你面对一台正在缓慢失血的Java进程,如何用jstack这把小刀,切开表象,找到那个正在悄悄吞噬内存的“病灶线程”,并给出可立即验证的修复路径。适合所有能登录生产服务器、有基本Linux操作能力、但未必熟悉JVM底层的Java开发者。无论你是刚转正的应届生,还是带三个项目的TL,只要你的服务还在用HotSpot JVM,这篇内容就能让你少熬两次夜。
2. 核心思路拆解:为什么jstack是内存泄露排查的“第一响应者”
很多开发者一听到“内存泄露”,第一反应是jmap -histo看对象数量,或是jmap -dump:format=b,file=heap.hprof <pid>然后拖进MAT里点来点去。这没错,但效率极低,且容易误判。原因在于:堆直方图(histo)只告诉你“什么对象多”,dump文件只告诉你“此刻谁占内存”,它们都无法回答最致命的问题:“为什么这些对象不被回收?”而jstack的价值,正在于它绕开了“内存快照”的静态视角,直击“对象生命周期失控”的动态现场。
2.1 jstack的底层逻辑:线程即内存持有者
JVM中,对象的生命周期由GC Roots可达性决定。常见的GC Roots包括:虚拟机栈(栈帧中的局部变量表)中的引用、方法区中的类静态属性引用、常量引用、本地方法栈中JNI引用等。其中,虚拟机栈里的局部变量,是最活跃、最易被忽视的GC Root来源。一个线程如果长期存活,且其栈帧中持有对大对象(如List、Map、byte[])的引用,那么这些对象就永远无法被GC回收——哪怕业务逻辑早已“用完即弃”。
jstack的本质,就是将每个Java线程的虚拟机栈(包括本地变量表、操作数栈、动态链接、方法出口等信息)以人类可读的方式打印出来。它不关心对象在堆里占多少字节,只关心“此刻,这个线程的执行指针停在哪,它的局部变量里存着什么引用”。因此,当内存泄露发生时,jstack能快速锁定两类关键线程:
- 异常阻塞线程:状态为
BLOCKED或WAITING,但栈帧显示它正持有某个大对象的引用(如ConcurrentHashMap.put()调用栈中,this指向一个巨型缓存Map); - 无限循环线程:状态为
RUNNABLE,但栈帧显示它在一个无退出条件的while循环里反复创建对象(如new byte[1024*1024]),且循环变量未被及时置null。
我曾处理过一个电商结算服务,高峰期Full GC频繁,但jmap -histo显示char[]数量正常。执行jstack <pid>后,发现一个名为OrderProcessor的线程,状态是RUNNABLE,栈顶是java.util.regex.Pattern.compile(),再往下是java.lang.String.replace()。顺着this引用一路查,发现它正在处理一个超长的、包含大量特殊符号的订单备注字段,每次replace()都会生成新的String和char[],而该线程的局部变量tempStr始终未被置空。问题根源不是缓存,而是正则编译的临时对象堆积。修复方案很简单:将Pattern.compile()提取为static final常量,避免重复编译。
2.2 为什么不用jstat或jconsole?——实时性与上下文的缺失
jstat -gc <pid>能告诉你YGC次数、FGC次数、各代使用率,但它只给数字,不给原因。jconsole图形界面看着直观,但当你在生产环境连上它,看到Eden区曲线飙升时,你依然不知道是哪个业务方法在疯狂new对象。它们缺乏调用栈上下文。
而jstack的优势在于:
- 零开销:
jstack本身不触发GC,不dump堆,对目标JVM性能影响几乎为零,可高频次执行; - 强上下文:每一行栈帧都标注了类名、方法名、行号(若编译时带
-g参数),你能直接定位到源码第几行; - 锁信息集成:
jstack -l会额外打印线程持有的锁(locked <0x0000000712345678>)和等待的锁(waiting to lock <0x0000000787654321>),这对排查因锁竞争导致的线程阻塞进而引发的内存滞留至关重要。
举个真实案例:某支付网关服务,某天开始出现“线程数缓慢上涨”现象,从200涨到500+,最终线程耗尽。jstack -l <pid>输出中,大量线程状态为BLOCKED,且都显示waiting to lock <0x00000007abcd1234>,而只有一个线程locked <0x00000007abcd1234>,栈帧显示它正在执行com.xxx.PaymentService.updateStatus()。顺藤摸瓜,发现该方法内有一个synchronized块,内部调用了外部HTTP接口,而该接口偶发超时(长达30秒)。结果就是:一个慢接口,拖住了所有后续请求线程,它们全部卡在锁外,而每个线程的局部变量里都存着待处理的订单对象——这些对象因线程阻塞而无法释放,最终堆内存被撑爆。jstack在这里,不仅定位了内存问题,更直接暴露了架构层面的同步调用风险。
2.3 jstack的局限性与适用边界:它不是万能的,但它是必经之路
必须坦诚:jstack无法解决所有内存问题。它的盲区很明确:
- 纯堆内对象泄露:比如一个静态集合不断add,但没有任何线程在执行add操作(即操作已完成,线程已退出),此时jstack里看不到任何异常线程,只能靠
jmap -histo或dump分析; - Native内存泄露:如DirectByteBuffer分配的堆外内存,jstack完全不感知;
- Finalizer队列阻塞:对象有finalize()方法且执行缓慢,会导致Finalizer线程积压,jstack能看到Finalizer线程状态,但无法直接看出是哪个对象的finalize在拖慢。
但这恰恰说明了jstack的定位:它是“动态行为分析”的入口,而非“静态结构分析”的终点。它的价值在于快速过滤掉80%的“假性内存泄露”(实为线程阻塞/循环导致的临时内存堆积),将排查范围从“整个堆”缩小到“几个可疑线程”。后续再结合jmap、jhat或MAT,效率会提升数倍。我的经验是:任何内存问题排查,第一步永远是jstack;如果jstack没发现明显异常,再考虑dump。这不是教条,而是用无数个凌晨的故障复盘换来的肌肉记忆。
3. 核心细节解析与实操要点:jstack命令的每一个参数都在说真话
jstack的语法看似简单:jstack [options] pid,但每个选项背后都有深意。很多开发者只用默认命令,错过了关键线索。下面我逐个拆解,结合真实场景说明“为什么这个参数不能省”。
3.1 基础命令与线程状态解读:读懂jstack输出的“生命体征”
执行jstack <pid>,输出以线程为单位,每段以"线程名" #线程ID prio=优先级 os_prio=OS优先级 tid=线程JVM ID nid=线程Native ID开头。关键在后面的java.lang.Thread.State: 状态。JVM定义了6种线程状态,但jstack中真正需要关注的是以下4种:
- RUNNABLE:线程正在JVM中执行(可能在CPU上运行,也可能在操作系统调度队列中等待)。这是最“健康”的状态,但如果它长时间停留在某个耗内存的方法(如
new byte[1024*1024]),就是危险信号。 - BLOCKED:线程试图获取一个被其他线程持有的锁,正在等待。此时要重点看
waiting to lock <0x...>后的地址,再去找哪个线程locked <0x...>了它。 - WAITING / TIMED_WAITING:线程在等待另一个线程执行特定操作(如
Object.wait()、Thread.join()、LockSupport.park())。如果是TIMED_WAITING且超时时间很长(如parking to wait for <0x...>),要检查是否是线程池配置不当(核心线程数过小,任务排队过长)。
提示:
nid=0x...是线程的Native ID,十六进制。在Linux下,可通过printf "%d\n" 0x...转换为十进制,再用ps -T -p <pid>查看该线程的CPU/内存占用,交叉验证。
我曾遇到一个诡异问题:服务内存稳定,但响应延迟忽高忽低。jstack输出里,大量线程状态为TIMED_WAITING,栈帧是sun.misc.Unsafe.park(),且parking to wait for <0x00000007def00000>。起初以为是锁问题,但jstack -l显示没人locked这个地址。后来查资料才明白,这是java.util.concurrent线程池(如ForkJoinPool)的内部工作线程在空闲时的正常park状态。真正的瓶颈在数据库连接池——jstack里另有一批线程卡在com.zaxxer.hikari.pool.HikariPool.getConnection(),状态是WAITING,等待HikariPool的awaitAvailableConnection()。这说明连接池已耗尽,新请求在排队。jstack在这里,用线程状态的“冷热对比”,揭示了资源瓶颈的真实位置。
3.2-l参数:解锁锁信息,让阻塞关系一目了然
jstack -l <pid>是生产环境排查的黄金组合。它会在每个线程块末尾,追加锁持有和等待信息。格式如下:
java.lang.Thread.State: BLOCKED (on object monitor) at com.xxx.Service.doWork(Service.java:45) - waiting to lock <0x00000007abc12345> (a java.lang.Object) - locked <0x00000007def67890> (a com.xxx.CacheManager)这里,“waiting to lock”表示该线程正试图获取0x00000007abc12345这个对象锁,但被别人占着;“locked”表示它自己持有着0x00000007def67890这个锁。关键技巧:全局搜索locked <0x...>,找到持有锁的线程;再搜索waiting to lock <0x...>,找到所有等待它的线程。这就是经典的“锁竞争拓扑图”。
实战案例:某风控服务升级后,CPU使用率从30%飙升至90%,top -H看到一个线程PID占CPU 80%。jstack -l <pid>输出中,该线程状态是RUNNABLE,栈帧是java.util.HashMap.get(HashMap.java:555),且反复出现。再看它locked <0x00000007fedcba98>。全局搜索这个地址,发现另一个线程"CacheRefresher"也locked <0x00000007fedcba98>,且栈帧是java.util.HashMap.put(HashMap.java:611)。真相大白:HashMap非线程安全,两个线程同时读写,导致内部链表成环,get()方法陷入死循环。修复方案:换成ConcurrentHashMap或加synchronized。jstack-l在这里,用一行locked信息,直接定位了并发bug的根因。
3.3-m参数:穿透JNI,定位C/C++层内存问题(慎用)
jstack -m <pid>会打印Java线程的本地方法(Native Method)栈帧,即JNI调用的C/C++函数。这在排查JNI导致的内存泄露时是唯一手段。例如,某个图像处理服务使用OpenCV Java API,jstack -m输出中,一个线程栈顶是org.opencv.core.Mat.n_delete(Native Method),但n_delete调用后,对应的Mat对象在Java堆里仍被引用,而Native内存未释放。这说明JNI层的delete没被正确调用,或存在引用计数错误。
注意:
-m参数会显著增加jstack执行时间,且输出冗长,生产环境非必要不启用。它更适合在开发或测试环境,配合jmap分析Native内存时使用。
3.4-F参数:强制模式,当目标JVM“假死”时的最后手段
正常情况下,jstack需要目标JVM处于响应状态。但如果JVM已严重卡顿(如Full GC卡住10秒以上),jstack可能自身也挂起。此时jstack -F <pid>会绕过JVM的正常通信机制,直接从操作系统层面读取线程信息。它输出的栈帧可能不完整(如显示??代替方法名),但线程状态、锁信息、基本栈结构通常还在。
我处理过一次极端案例:一个大数据ETL任务,因数据倾斜导致单个Mapper内存溢出,JVM进入长时间GC,jstack普通命令超时。启用-F后,输出显示一个线程状态为RUNNABLE,栈帧是java.util.Arrays.sort(),且nid对应top -H里CPU最高的线程。这说明不是GC问题,而是排序算法本身在消耗CPU和内存。后续优化改为Collections.sort()(TimSort,对部分有序数据更优)并增加分片,问题解决。-F参数,就是你在系统“休克”时,还能拿到的那张心电图。
4. 实操过程与核心环节实现:从第一次执行到问题闭环的完整路径
光知道命令不够,实战中每一步操作都有讲究。下面是我总结的标准化七步法,每一步都附带真实命令、预期输出、关键判断逻辑和避坑点。这套流程已在多个团队落地,平均将内存泄露定位时间从8小时缩短至45分钟以内。
4.1 第一步:建立基线——服务刚启动时的“健康快照”
不要等出问题才用jstack!在服务部署完成、压力测试前,先执行:
# 获取当前时间戳,用于后续对比 date +%s > /tmp/jstack_baseline_time.txt # 生成基线jstack jstack -l $(pgrep -f "java.*YourApp") > /tmp/jstack_baseline_$(date +%s).txt保存这个文件。它记录了“干净状态”下所有线程的初始分布:主线程、GC线程、JIT编译线程、应用线程池的初始线程等。后续排查时,对比基线,能一眼识别出“多出来的”或“状态异常的”线程。
实操心得:我习惯在基线文件里手动标注关键线程。比如找到
"main"线程,看它最后调用的是SpringApplication.run(),就在旁边写# Spring Boot 启动入口;找到"http-nio-8080-exec-1",写# Tomcat 工作线程池初始线程。这样后续对比时,不用再猜每个线程是干啥的。
4.2 第二步:捕获异常时刻——当内存告警响起时的三连击
一旦监控触发heap_usage > 80%或FullGC_count > 5/min,立即执行:
# 1. 记录当前内存使用(jstat) jstat -gc $(pgrep -f "java.*YourApp") 1000 3 > /tmp/jstat_alert_$(date +%s).txt # 2. 快速jstack(不加-l,求快) jstack $(pgrep -f "java.*YourApp") > /tmp/jstack_alert_fast_$(date +%s).txt # 3. 详细jstack(加-l,等几秒也值得) jstack -l $(pgrep -f "java.*YourApp") > /tmp/jstack_alert_full_$(date +%s).txtjstat的三次采样(间隔1秒)能确认内存增长趋势是持续还是偶发;jstack快速版用于第一时间粗筛;jstack -l详细版用于深度分析。注意:不要只执行一次!间隔30秒再执行一遍,对比两次输出,看异常线程是“瞬时卡住”还是“持续存在”。持续存在的线程,才是真正的嫌疑人。
4.3 第三步:聚焦筛选——用grep和awk从千行日志中揪出关键线索
jstack输出动辄上千行,人工扫效率低下。我常用的筛选命令:
# 查看所有BLOCKED线程(最常见泄露源头) grep -A 5 "java.lang.Thread.State: BLOCKED" /tmp/jstack_alert_full_*.txt # 查看所有WAITING/TIMED_WAITING线程,并统计数量 grep "java.lang.Thread.State:.*WAITING" /tmp/jstack_alert_full_*.txt | wc -l # 找出持有锁最多的线程(通常是罪魁祸首) grep "locked <0x" /tmp/jstack_alert_full_*.txt | awk '{print $3}' | sort | uniq -c | sort -nr | head -10 # 查找特定关键词,如"Cache"、"Map"、"Queue" grep -A 10 -B 2 "ConcurrentHashMap" /tmp/jstack_alert_full_*.txt避坑点:
grep -A 5的-A参数是“after”,表示匹配行后5行,确保能抓到完整的栈帧。很多新手只grep "BLOCKED",结果只看到状态行,看不到下面的waiting to lock和方法名,功亏一篑。
4.4 第四步:深度追踪——从线程栈到源码的“逆向工程”
假设你通过grep发现一个可疑线程:
"DataSyncWorker-1" #25 daemon prio=5 os_prio=0 tid=0x00007f8b4c001000 nid=0x1a2b waiting for monitor entry [0x00007f8b3a1fe000] java.lang.Thread.State: BLOCKED (on object monitor) at com.xxx.sync.DataSyncService.sync(DataSyncService.java:128) - waiting to lock <0x00000007abc12345> (a java.util.HashMap) at com.xxx.sync.DataSyncService.lambda$init$0(DataSyncService.java:89)接下来要做的:
- 定位源码:
DataSyncService.java第128行,看sync()方法里是否在操作一个HashMap; - 检查锁对象:
<0x00000007abc12345>这个地址,在基线jstack里是否存在?如果基线里没有,说明是运行时新创建的; - 找持有者:全局搜索
locked <0x00000007abc12345>,找到哪个线程在put(),看它的栈帧是否在循环里; - 验证假设:在测试环境,用
jstack模拟相同场景,确认复现路径。
我曾用此法定位一个“伪泄露”:DataSyncService里有个static HashMap<String, List<Data>> cache,sync()方法会cache.get(key).add(data)。问题在于,get()返回null时,add()会抛NullPointerException,而异常被静默吞掉,线程继续循环,但cache里key对应的List始终是null,导致后续所有get()都返回null,add()反复失败。jstack显示线程卡在sync(),但实际是空指针异常导致的逻辑死循环。修复只需加if (list == null) list = new ArrayList<>();。这说明:jstack暴露的是现象,根因还需结合代码逻辑判断。
4.5 第五步:交叉验证——用jmap和jstat给jstack结论“上保险”
jstack指出“线程A持有大对象”,需要用jmap确认这个对象是否真的在堆里:
# 查看线程A的nid(如0x1a2b),转换为十进制(0x1a2b = 6699) # 用jmap找出该线程创建的对象 jmap -histo $(pgrep -f "java.*YourApp") | head -20 # 如果怀疑是某个类实例过多,直接统计 jmap -histo $(pgrep -f "java.*YourApp") | grep "com.xxx.BigObject"如果jmap -histo显示BigObject数量远超预期(如基线是100,现在是10000),且jstack里只有线程A在创建它,证据链就闭合了。
同样,jstat能验证jstack的推断:如果jstack发现大量线程卡在DB连接获取,jstat -gc应该显示Old Gen增长缓慢(因为对象没怎么创建),但jstat -gccapacity显示Metaspace或Compressed Class Space增长——这说明问题在连接池,而非内存本身。
4.6 第六步:修复与验证——改完代码后,如何证明问题真解决了?
修复代码后,绝不能只跑单元测试就上线!必须在预发环境,用相同步骤验证:
- 重新部署,等待服务稳定;
- 执行基线jstack;
- 施加与生产相似的压力(如用
wrk模拟100QPS); - 每隔5分钟执行一次
jstack -l,持续30分钟; - 对比所有jstack输出,确认:
- 异常线程数量为0;
waiting to lock的地址不再出现;jstat -gc显示Old Gen使用率平稳(波动<5%)。
我坚持一个原则:修复的验证标准,必须和发现问题的标准完全一致。你用jstack发现的,就必须用jstack验证消失。任何“感觉好了”“监控没告警”的说法,都是对线上稳定性的不负责任。
4.7 第七步:沉淀归档——把这次排查变成团队的知识资产
每次成功排查,都要做三件事:
- 更新基线文档:在团队Wiki里,更新
jstack基线模板,注明本次新增的正常线程(如"DataSyncWorker")及其预期状态; - 编写Checklist:生成一份《内存泄露排查速查表》,列出本次用到的所有命令、grep关键词、典型栈帧模式,供新人一键套用;
- 代码扫描规则:将本次问题模式(如“静态Map未清理”、“未关闭的流”)加入SonarQube规则,设置为
Blocker级别,从源头拦截。
有一次,我们把“ConcurrentHashMap被当作缓存但未设过期策略”这个模式加入CI流水线,两周内拦截了3个同类PR。这比每次救火高效得多。jstack的价值,不仅在于解决当下问题,更在于它帮你看清系统脆弱点,从而构建防御体系。
5. 常见问题与排查技巧实录:那些年踩过的坑,都成了经验
jstack用得越多,越发现有些“坑”是共性的。下面整理了我在不同团队、不同项目中遇到的12个高频问题,每个都附带真实场景、错误操作、正确解法和独家技巧。
5.1 问题1:jstack输出里全是??,方法名全没了,怎么办?
场景:执行jstack <pid>,输出类似:
"main" #1 prio=5 os_prio=0 tid=0x00007f8b4c001000 nid=0x1a2b runnable [0x00007f8b3a1fe000] java.lang.Thread.State: RUNNABLE at ?? (??) at ?? (??)错误操作:以为jstack坏了,换其他工具。根因:JVM启动时未加-XX:+PrintGCDetails或-g参数,导致调试信息被剥离;或JDK版本太低(< JDK 8u60)。正确解法:
- 检查JVM启动参数:
ps -ef | grep java | grep -o "-g",确认有-g; - 若无,重启JVM时添加
-g(开发/测试环境)或-XX:+UnlockDiagnosticVMOptions -XX:+DebugNonSafepoints(生产环境,影响极小); - 升级JDK到8u202+或11+,现代JDK默认保留足够调试信息。
独家技巧:用
jinfo -flag +PrintGCDetails <pid>动态开启GC日志(需JVM支持),虽不能恢复方法名,但能提供GC上下文辅助判断。
5.2 问题2:jstack -l显示locked <0x...>,但全局搜不到waiting to lock,是死锁吗?
场景:jstack -l输出中,线程Alocked <0x00000007abc12345>,但搜遍所有线程,没有一个waiting to lock <0x00000007abc12345>。错误操作:断定不是死锁,放弃排查。根因:该锁可能被线程A获取后,一直未释放(如线程A在执行一个超长计算,或进入了sleep()),而其他线程还没来得及尝试获取它。这不是死锁,而是“单点阻塞”。正确解法:
- 看线程A的栈帧:如果在
java.lang.Thread.sleep()或java.util.concurrent.locks.LockSupport.park(),说明是主动休眠,问题不大; - 如果在
java.io.FileInputStream.read()或java.net.SocketInputStream.socketRead0(),说明在IO等待,检查网络或磁盘是否慢; - 如果在
java.util.HashMap.put()等计算密集型方法,且nid对应CPU高线程,说明是算法瓶颈。
5.3 问题3:jstack里看到大量"pool-1-thread-X",状态都是WAITING,是泄露吗?
场景:jstack输出里,几十个"pool-1-thread-1"到"pool-1-thread-50",状态全是java.lang.Thread.State: WAITING (parking)。错误操作:以为线程池爆炸,慌忙调小corePoolSize。根因:这是Executors.newCachedThreadPool()或newFixedThreadPool(n)的正常表现。WAITING (parking)表示线程池空闲,线程在LockSupport.park()等待任务,不占用CPU,内存占用也极小(每个线程栈约1MB,但大部分是空的)。正确解法:
- 确认线程池类型:
newCachedThreadPool会创建大量线程,但空闲60秒后自动销毁;newFixedThreadPool则固定数量; - 检查是否有任务提交后未完成:用
jstack看是否有线程在RUNNABLE状态执行任务; - 如果只是空闲线程多,无需处理。真正要警惕的是
RUNNABLE线程数持续增长。
5.4 问题4:jstack找不到问题,但jmap -histo显示byte[]暴增,怎么办?
场景:jstack一切正常,但jmap -histo显示byte[]数量从1000涨到50000,jmap -dump后MAT分析显示byte[]被java.nio.DirectByteBuffer引用。错误操作:在Java堆里找DirectByteBuffer,结果发现数量很少。根因:DirectByteBuffer的byte[]在堆外(Native Memory),jmap -histo统计的是Java堆内的byte[]对象,而DirectByteBuffer本身很小,但它的cleaner关联着大块Native内存。正确解法:
- 用
jmap -histo:live <pid>确认是否是DirectByteBuffer的cleaner没执行; - 检查代码中是否显式调用了
ByteBuffer.allocateDirect(),且未调用cleaner.clean(); - 更好的方案:用
-XX:MaxDirectMemorySize=512m限制Direct内存,或改用ByteBuffer.wrap(byte[])复用堆内数组。
5.5 问题5:生产环境禁止jmap -dump,jstack又没线索,还有别的办法吗?
场景:公司安全策略禁止dump堆,jstack也看不出异常,但内存确实在涨。错误操作:束手无策,只能重启。正确解法:启用JVM内置的-XX:+HeapDumpOnOutOfMemoryError,并配合-XX:HeapDumpPath=/path/to/dumps/。这样当OOM真正发生时,JVM会自动生成dump,且无需人工干预。虽然不能预防,但能确保事后有据可查。同时,开启-XX:+PrintGCDetails -Xloggc:/path/to/gc.log,分析GC日志中的promotion failed、concurrent mode failure等关键词,往往能反推出内存泄露模式。
表格:高频问题速查表
| 问题现象 | 可能根因 | 关键jstack命令 | 验证方式 |
|---|---|---|---|
大量线程BLOCKED,waiting to lock同一地址 | 锁竞争激烈,或锁粒度太大 | jstack -l <pid> | grep "waiting to lock" | head -5 | jstat -gc <pid>看Old Gen是否缓慢增长 |
RUNNABLE线程栈顶是Arrays.sort()或String.split() | 算法复杂度高,CPU和内存双高 | jstack <pid> | grep -A 3 "Arrays.sort" | top -H确认该线程PID CPU > 80% |
WAITING线程栈顶是Object.wait(),且nid对应top里高内存线程 | 线程在等待通知,但通知未发出 | jstack -l <pid> | grep -A 5 "Object.wait" | 检查notify()/notifyAll()调用是否遗漏 |
TIMED_WAITING线程栈顶是parking to wait for <0x...>,且地址在基线中不存在 | ForkJoinPool或自定义线程池空闲 | jstack <pid> | grep "parking to wait" | wc -l | 对比基线 |