🛠️ 工具概念入门 — 看不懂后面的命令?先看这里
后面的8个场景会用很多命令(jmap、jstat、jstack、Arthas…),如果你第一次见这些词完全不知道干嘛的,先花5分钟看这一节。后面看到命令你就知道"哦,这个是看内存的""那个是看线程的"。
💭 一句话类比:医院检查
JVM 排查工具就像医院的检查设备——病人(Java程序)不舒服了,你不会上来就做CT,而是先量体温、抽血,再根据异常决定做哪项深度检查。
工具
全称
相当于医院里的…
干嘛的
什么时候用
jps
Java Process Status
挂号台 — 查看病历号
列出正在运行的 Java 进程 PID
第一步,先知道你要查哪个进程
jstat
JVM Statistics Monitoring Tool
体温计 — 快速量一下
看 JVM 各区域(堆、元空间)使用率和 GC 情况
想快速知道"内存用了多少""GC频不频繁"
jmap
JVM Memory Map
CT — 拍个切片看看内部
看堆内存里哪个类的对象最多(-histo),或导出整个堆快照(-dump)
OOM 了,想看是什么对象占满了内存
jstack
Java Stack Trace
心电图 — 看心脏在干嘛
打印所有线程的调用栈,看每个线程正在执行什么
CPU 100%,想看哪个线程在干什么
jinfo
Java Configuration Info
病历 — 查病人的基本资料
查看和修改 JVM 运行参数(如 -XX:+UseG1GC)
想确认"线上到底用的什么GC""堆大小是多少"
Arthas
非缩写 ,阿里开源诊断工具名(取自魔兽世界角色)
微创手术 — 不停机就能看能改
在线诊断工具,可以看方法调用、修改日志级别、反编译等
线上出问题但不能重启,想在不影响服务的情况下查看
MAT
Eclipse Memory Analyzer Tool
病理分析 — 把CT片拿给专家看
Eclipse Memory Analyzer,分析 jmap dump 出来的堆快照文件
OOM 后 dump 下来的 .hprof 文件,用 MAT 找泄漏根因
top / top -Hp
Table Of Processes (约定俗称)/ -Hp = show threads per process
分诊台 — 看哪个病人最紧急
Linux 命令,看整体 CPU 和每个线程的 CPU 占用
服务器 CPU 飙了,先定位是哪个进程/哪个线程
🧭 排查决策流程 — 出问题了先做什么?
🚨 出问题了!
↓
🔍 先判断类型:内存?CPU?GC?
↓
内存问题jmap → MAT
CPU问题top → jstack
GC问题jstat → GC日志
1️⃣ jps — 找到你要查的 Java 进程
排查第一步永远是先知道进程号(PID)。jps 就是 Java 版的 ps,只显示 Java 进程。
$ jps -l
28456 com.order.OrderApplication
1234 sun.tools.jps.Jps
$ ps -ef | grep java
root 28456 1 2 ... java -jar order-service.jar
什么时候用: 每次排查的第一步。没有 PID,后面 jmap/jstack/jstat 全都用不了。
2️⃣ jstat — 快速看 JVM 健康状态(体温计)
jstat 就像量体温——不用深入检查,快速看一眼各区域使用率和 GC 情况。参数不同看的东西不同:
命令
看什么
什么时候用
jstat -gc <pid>
各代内存使用量(字节)+ GC 次数和时间
想知道 Eden/Survivor/Old/Metaspace 各用了多少字节
jstat -gcutil <pid>
各代内存使用率(百分比)
想知道"Old 区用了 99%"这种一目了然的百分比
jstat -gc <pid> 1000 10
每1秒打印1次,共打印10次
想看 GC 的动态变化趋势(在不在持续涨)
jstat -gccause <pid>
GC 原因(是什么触发了这次 GC)
想知道"为什么突然 Full GC 了"
$ jstat -gcutil 28456
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
0.00 23.45 67.89 99.12 96.34 91.22 234 1.234 47 8.654 9.888
💭 类比理解 jstat 输出的各列:
Eden (E) = 新生代的"婴儿区"——新对象出生的地方,满了就触发 Young GC
Survivor (S0/S1) = 新生代的"幼年区"——熬过一次 Young GC 的对象暂存这里
Old (O) = 老年代——熬过多次 GC 的"老人"住这里,满了就触发 Full GC(最危险!)
Metaspace (M) = 元空间——存类信息、方法信息的地方(不是堆内存!)
YGC/FGC = Young GC/Full GC 次数。Full GC 是 Stop-The-World 的,越多越危险
什么时候用: 收到内存告警但还没OOM时,先用 jstat 看看哪个区满了、GC 频率如何,决定接下来用 jmap 还是看 GC 日志。
3️⃣ jmap — 看堆内存里住了什么对象(CT扫描)
jstat 告诉你"哪个区满了",jmap 告诉你"里面具体住了谁"。就像体温计量出37.8度(jstat),但不知道哪里发炎,需要拍CT(jmap)看细节。
命令
看什么
类比
注意事项
jmap -histo <pid>
按对象数量排序,看哪个类的对象最多
CT快速扫描,看看哪个器官最大
线上安全,推荐先用这个
jmap -histo:live <pid>
只统计存活对象(会触发Full GC)
先让死的细胞排出去,再看剩下的
会触发 Full GC!线上慎用
jmap -dump:format=b,file=heap.hprof <pid>
导出整个堆快照到文件
把CT片完整保存下来,拿给专家看
⚠️ 文件很大(可能几个G),会影响性能
$ jmap -histo 28456 | head -20
num #instances #bytes class name
----------------------------------------------
1: 2847536 546331712 [Ljava.lang.Object;
2: 1423768 227802880 java.util.ArrayList
3: 2847536 136681728 com.order.entity.OrderItem
4: 5632 89724800 com.order.service.ExportService
5: 256890 41102400 java.lang.String
💭 类比理解输出:
#instances(实例数) = 这种"房间"住了多少个人
#bytes(字节数) = 这些人一共占了多大面积
class name(类名) = 什么类型的房间
排查重点看:
① 实例数异常多(比如 OrderItem 有284万个,正常吗?)
② 占字节大但实例数少(比如 ExportService 只有5632个但占了89MB,每个实例平均15KB!)
③ 自定义类名(com.xxx 开头的)出现在前10,往往就是你的业务代码出了问题
什么时候用: OOM 或内存持续增长时。先用 -histo 快速看,如果发现可疑对象再用 -dump 导出快照给 MAT 分析。
⚠️ 重要警告:
① jmap -dump 在 JVM 内存很大时会暂停很久(可能十几秒到几分钟),线上慎用!
② 更安全的做法:启动时加 -XX:+HeapDumpOnOutOfMemoryError,OOM 时自动 dump
③ 如果必须手动 dump,建议在低峰期操作
4️⃣ jstack — 看每个线程在干什么(心电图)
CPU 100% 时,jstat 和 jmap 帮不了你——它们看的是内存。jstack 看的是线程,能告诉你"哪个线程在忙什么"。
$ jstack 28456
"http-nio-8080-exec-3" #32 daemon prio=5 os_prio=0 tid=0x00007f8a1400b800 nid=0x6f60 runnable
java.lang.Thread.State: RUNNABLE
at java.util.regex.Pattern$BmpCharProperty.match(Pattern.java:4023)
at java.util.regex.Pattern$GroupHead.match(Pattern.java:4845)
at com.search.service.SearchValidator.validate(SearchValidator.java:23)
💭 配合 top -Hp 使用:
① top -Hp 28456 → 看到线程 28512 吃了98% CPU
② printf "%x\n" 28512 → 转成16进制得到 6f60
③ jstack 28456 | grep -A 20 "6f60" → 找到这个线程在干什么
就像先看心电图哪个波形异常(top),再用放大镜看那个波形的细节(jstack)。
什么时候用: CPU 100%、接口卡死(线程 BLOCKED)、死锁排查。和 top -Hp 配合使用是标准套路。
5️⃣ Arthas — 不停机在线诊断(微创手术)
jmap/jstack/jstat 都只能"看",不能"动"。Arthas 能在不重启服务的情况下,实时观察方法调用、查看类信息、甚至修改日志级别。阿里开源的,Java 排查神器。
Arthas 命令
干嘛的
类比
dashboard
实时看线程、内存、GC 概览
生命体征监护仪
thread
查看线程状态,thread -n 3 看CPU最高的3个
找出心率最快的病人
sc -d *ClassName*
搜索已加载的类,看类加载器信息
查病历——这个人有没有来过
classloader -t
查看类加载器树
查科室——哪个科室收了这个病人
watch 类名 方法名 参数
实时观察某个方法的调用参数和返回值
在病房装了个摄像头
trace 类名 方法名
追踪方法调用链路和耗时
跟踪病人从挂号到看完病的每一步花了多久
ognl 表达式
执行任意表达式,查看/修改运行时数据
直接给药——小剂量调整看看效果
$ java -jar arthas-boot.jar
* [1]: 28456 com.order.OrderApplication
$ 1
Arthas is attached.
arthas> thread -n 3
"http-nio-8080-exec-3" Id=32 RUNNABLE
at java.util.regex.Pattern.match(Pattern.java:4023)
arthas> watch com.order.Service createOrder params+returnObj
method=com.order.Service.createOrder params=[OrderDTO{id=1}] returnObj=Order{id=1}
什么时候用:
① OOM-Metaspace 类加载器泄漏时——用 classloader/sc 查看动态代理类
② 想在不重启服务的情况下看某个方法的调用情况——用 watch/trace
③ 线上出了问题但不能重启——Arthas 就是你的远程手术刀
6️⃣ MAT — 堆快照深度分析(病理分析报告)
jmap -histo 告诉你"谁最大",但不知道"谁引用了它导致释放不了"。MAT(Eclipse Memory Analyzer)就是干这个的——它能自动找出"垃圾回收器为什么回收不了这个对象"。
MAT 功能
干嘛的
类比
Leak Suspects
自动分析泄漏嫌疑对象
AI辅助诊断——系统帮你圈出最可疑的地方
Dominator Tree
按"支配"关系看谁占内存最大
看谁是"大户"——他占的内存最大,他释放了其他人也能释放
Shallow Heap
对象自身占的大小
这个人自己占了1张病床
Retained Heap
对象和它引用的所有对象的总大小
这个人加上他带来的家属一共占了10张病床
GC Roots
从根到对象的引用链
谁把这个病人送来的——顺着找责任人
💭 Shallow vs Retained 对比:
ArrayList 自身只占 48 字节(Shallow Heap),但它里面装了 10000 个 Order 对象
→ 如果没人引用这个 ArrayList,GC 能回收 48 + 10000×128 = 1.28MB(Retained Heap)
→ 所以 Retained Heap 才是判断"谁最占内存"的真正指标!
什么时候用: jmap -dump 导出了 .hprof 文件后,用 MAT 打开分析。这是"事后分析"工具,不是实时的。
📋 问题→工具 速查表
你遇到的问题
第一步
第二步
第三步
❌ OOM 内存溢出
jstat -gcutil 看哪个区满
jmap -histo 看什么对象多
jmap -dump + MAT 分析
❌ CPU 100%
top -Hp 找高CPU线程
printf %x 转十六进制
jstack 看线程在干什么
⚠️ 接口变慢
jstat -gcutil 看GC是否频繁
jstack 看有没有 BLOCKED 线程
Arthas trace 看方法耗时
⚠️ 频繁 Full GC
jstat -gcutil 确认频率
jmap -histo 看大对象
看 GC 日志分析原因
🤔 线上不能重启但要看数据
Arthas watch 观察方法
Arthas trace 追踪调用
Arthas ognl 查看变量
🤔 类加载器泄漏
Arthas sc -d 搜索代理类
Arthas classloader -t
jstat -gc 看 MC/MU
✅ 看完这一节,你应该知道了:
• jps = 挂号台,找PID
• jstat = 体温计,快速看整体健康
• jmap = CT扫描,看内存里谁占最多
• jstack = 心电图,看线程在干什么
• Arthas = 微创手术,不停机在线诊断
• MAT = 病理分析,事后深度分析dump文件
• 遇到问题先判断是"内存问题"还是"CPU问题",再选对应的工具链
🗺️ 生产问题排查全景图 — 从症状到根因的完整路径
上面你学了每个工具是干嘛的,但遇到线上问题时,你不会先想"我要用jmap"——你会先看到症状 (告警、报错、监控异常),然后顺着症状往下追。这张全景图就是把你脑中的排查思路画出来。
🚨 线上出问题了!
🔍 你看到了什么症状?
❌ 日志有 OutOfMemoryError
❌ CPU 飙到 100%
⚠️ 接口卡住不动
⚠️ 接口越来越慢
🤔 内存慢慢涨没报错
第1步:看OOM类型
grep OOM 日志看具体报错
heap space? Metaspace? GC overhead?
第1步:top -Hp
找到吃CPU最高的线程
记下线程TID
第1步:jstack
看所有线程状态
BLOCKED? WAITING? DEADLOCK?
第1步:jstat -gcutil
看各代使用率+GC频率
Old区满? FGC频繁?
第1步:jstat -gc 1000 30
看30秒趋势:Old区
持续涨? 涨完GC能降回来?
Java heap space
→ jmap -histo
Metaspace
→ Arthas classloader
printf "%x" TID
转16进制 → jstack | grep
看jstack里线程状态
BLOCKED=等锁 | WAITING=等IO
deadlock=死锁
判断原因
FGC频繁?→内存 | 否→Arthas trace
GC后能降回来吗?
能降→正常波动 | 降不回来→泄漏
降一点又涨→缓存没上限
jmap -dump + MAT
找Retained Heap最大的
sc -d *Proxy*
找动态代理类泄漏
看栈顶在什么方法
Pattern.match=正则 | 业务方法=死循环
三种可能
等锁→死锁 | 等IO→线程池耗尽 | 等GC→FGC
Arthas trace
追踪方法调用链耗时
jmap -histo
看哪个类的实例在持续增长
→ 场景1
根因:单例存数据
/ 大查询没分页
→ 场景2
根因:动态代理
没缓存
→ 场景5
根因:正则
回溯
→ 场景6
根因:代码
死循环
→ 场景9
根因:
死锁
→ 场景10
根因:线程
池耗尽
→ 场景7
根因:FGC
频繁
→ 场景3
根因:GC
Overhead
→ 场景8
根因:ThreadLocal
没remove
→ 场景11
根因:本地缓存
没有淘汰策略
💡 排查口诀:先分型,再选工具,最后看细节
① 看到症状 → 先判断是 内存/CPU/线程/GC 哪类问题
② 用 jstat/top/jstack 快速确认类型 → 再选深入工具(jmap/Arthas/MAT)
③ 找到根因 → 修复 → 验证 → 加监控预防下次
📌 特殊:日志有 StackOverflowError
直接看异常堆栈!不需要 jmap/jstack
→ 找到递归/循环引用的代码位置 → 场景4
📌 特殊:日志有 GC Overhead Limit Exceeded
本质上还是内存问题:jstat看Old区 → jmap看大对象 → 场景3
从你看到的第一条告警开始,沿着路线走,每一步该做什么、用什么工具、看什么指标,都有答案
📖 6大排查路线 — 每条路线的完整命令链
上面的决策树告诉你"方向",下面6条路线给你"具体怎么走"——从第一条命令敲下去开始,到找到根因为止。
🔴 路线1:日志有 OutOfMemoryError: Java heap space
你看到的现象: 接口返回500,日志里一堆OOM错误
Step 1 →
jps -l 拿到PID(第一步永远是这个)
Step 2 →
jstat -gcutil <pid> 确认:O(老年代) = 99%+,FGC在持续增长
Step 3 →
jmap -histo <pid> | head -20 看哪个类的对象最多、占字节最大
↳ 如果看到自定义类(com.xxx)排在前面 → 就是你的业务代码有问题
↳ 如果看不出来 →
jmap -dump:format=b,file=heap.hprof <pid> 导出快照
Step 4 → 用 MAT 打开 .hprof,看 Leak Suspects / Dominator Tree / GC Roots
Step 5 → 找到根因(比如单例Bean里用List存数据没清理、大查询没分页…)
对应场景: 场景1 | 根因类型:内存泄漏 / 一次性加载太多数据
🟣 路线2:日志有 OutOfMemoryError: Metaspace
你看到的现象: 服务启动一段时间后突然OOM,但不是堆内存的问题
Step 1 →
jstat -gc <pid> 看 MC/MU 列:MC(元空间容量)和MU(元空间已用)是否持续增长
Step 2 →
jinfo <pid> | grep MaxMetaspaceSize 确认元空间上限设置
Step 3 → 启动 Arthas,
classloader -t 看类加载器树,
sc -d *$Proxy* 搜索动态代理类
↳ 如果代理类数量在持续增长 → 就是动态代理没缓存,每次请求都创建新的
Step 4 → 在代码里找到创建代理的地方,加上缓存逻辑
对应场景: 场景2 | 根因类型:动态代理/CGLIB反复生成类没有缓存
🔴 路线3:服务器 CPU 飙到 100%
你看到的现象: 监控告警CPU使用率>90%,接口响应变慢甚至超时
Step 1 →
top -Hp <pid> 找到CPU占用最高的那个线程,记下TID
Step 2 →
printf "%x\n" <TID> 把十进制TID转成十六进制
Step 3 →
jstack <pid> | grep -A 30 "<hex-tid>" 看这个线程在干什么
↳ 栈顶是
java.util.regex.Pattern.match → 正则回溯!→
场景5
↳ 栈顶是你的业务方法且一直 RUNNABLE → 死循环!→
场景6
↳ 栈顶是
Unsafe.park /
Object.wait → 线程在等,不是CPU问题,可能是锁竞争
对应场景: 场景5 正则回溯 |
场景6 代码死循环
🟠 路线5:接口越来越慢(没有报错但逐渐变卡)
你看到的现象: 接口RT从100ms慢慢变成500ms、1s、2s…但不会报错
Step 1 →
jstat -gcutil <pid> 1000 10 看GC趋势
↳ FGC列持续增长、FGCT(总耗时)越来越大 → Full GC越来越频繁 →
场景7
↳ Old区(O列)持续增长,GC后降不回来 → 内存泄漏 →
场景11
Step 2 (如果不是GC问题)→ Arthas
trace 类名 方法名 追踪方法调用链耗时
↳ 哪个方法调用耗时长,哪个就是瓶颈
对应场景: 场景7 Full GC频繁 |
场景11 内存缓慢增长 |
场景3 GC Overhead
🟢 路线6:Grafana上看到内存缓慢上涨,还没报错
你看到的现象: 内存曲线像上楼梯一样一级一级涨,每次GC只降一点点
Step 1 →
jstat -gc <pid> 1000 30 看30秒内Old区走势
↳ GC后Old区能降回原位 → 正常波动(大对象分配后回收)
↳ GC后Old区降了一点但没回原位 → 有对象泄漏,GC回收不了 →
场景11
↳ Old区降不回来 + jstack看到线程池线程数异常 → ThreadLocal泄漏 →
场景8
Step 2 →
jmap -histo <pid> 对比前后两次输出,看哪个类的实例数在持续增长
对应场景: 场景11 内存缓慢增长 |
场景8 ThreadLocal泄漏
⌨️ 排查命令链速查 — 从第一条命令开始
不用记每个命令,记住这几条链路就行。每条链路就是一个排查"套路":
$ jps -l
$ jstat -gcutil 28456
$ jmap -histo 28456 | head -20
$ jmap -dump:format=b,file=heap.hprof 28456
$ top -Hp 28456
$ printf "%x\n" 28512
$ jstack 28456 | grep -A 30 "6f60"
$ jstack 28456
$ jstat -gcutil 28456 1000 10
$ jstat -gccause 28456
🎯 排查三原则
① 先分型再选工具 :别上来就 jmap——如果是CPU问题,jmap帮不了你。先判断是内存/CPU/线程/GC哪类问题
② 从轻到重 :jstat(轻量,秒出)→ jmap -histo(中等)→ jmap -dump(重量级,慎用)→ MAT(事后分析)
③ 每步都有目的 :不是"试试看",而是"我执行这个命令是为了确认/排除什么"
🃏 11个JVM问题速查卡 — 特征·原理·排查·排除
上面全景图教你怎么从症状选路线 ,这里教你每种问题长什么样、为什么会发生、怎么一步步排查、跟别的问题怎么区分 。每张卡读完你就知道"如果遇到X,我该做什么"。
👀 你看到什么特征?
• 日志出现 java.lang.OutOfMemoryError: Java heap space
• 接口大面积返回 HTTP 500
• Grafana 堆内存曲线只涨不降 (没有锯齿状起伏)
• Full GC 执行了很多次但回收不了东西
🔬 为什么会这样?
JVM 堆内存(-Xmx)有上限。对象不断创建且被强引用持有,GC 回收不了,堆就被撑满了。关键机制 :当 Old 区(老年代)使用率接近 100%,Young GC 回收的存活对象晋升到老年代放不下,JVM 就抛出 OOM。
本质 :有对象你还在用(强引用),GC 无法回收 → 堆被撑爆
🔍 排查步骤
① jps -l 拿到PID
② jstat -gcutil <pid> 确认:O(老年代) = 99%+,FGC持续增长
③ jmap -histo <pid> | head -20 看哪个类的实例数最多/占字节最大 ——自定义类(com.xxx)排前面往往就是你的业务代码问题
④ 看不出来?jmap -dump:format=b,file=heap.hprof <pid> 导出快照
⑤ 用 MAT 打开 → Leak Suspects → 找到"谁引用了这个对象导致释放不了"
⚡ 跟其他问题怎么区分:
如果 OOM 报的是 Metaspace 而不是 Java heap space → 去看问题2 ;
如果报的是 GC overhead limit exceeded → 去看问题3 ;
如果没有 OOM 但内存慢慢涨 → 去看问题11
👀 你看到什么特征?
• 日志出现 java.lang.OutOfMemoryError: Metaspace
• 周期性复现 :重启后恢复,运行1-2天又OOM
• 堆内存(Heap)使用率正常!不是堆的问题
• jstat 中 MC/MU 列持续增长不回落
🔬 为什么会这样?
Metaspace 存的是类信息 (不是对象实例!)。每个被加载的 class 都在 Metaspace 占空间。CGLIB/JDK动态代理每次创建代理会动态生成新类 ,如果没缓存就会不停生成新类把 Metaspace 撑满。
本质 :类被加载后不会被卸载(类加载器不释放)→ Metaspace 只增不减
🔍 排查步骤
① jstat -gc <pid> 1000 5 看 MC/MU 是否持续增长
② jinfo <pid> | grep MaxMetaspaceSize 确认有没有设上限(默认无上限!)
③ 启动 Arthas → classloader -t 看类加载器树 → sc -d *$Proxy* 或 sc -d *$$EnhancerByCGLIB$$* 搜索代理类数量
④ 如果代理类数量持续增长 → 找到代码中创建代理的地方,加缓存
⚡ 跟其他问题怎么区分:
OOM 报的是 Metaspace 而不是 heap space → 堆内存没问题,是类加载 出了问题;
如果堆也满了 → 两个问题同时存在,先处理堆
⚠️ 问题3:GC Overhead Limit Exceeded
查看完整故事 →
👀 你看到什么特征?
• 日志出现 GC overhead limit exceeded(不是 heap space!)
• 服务没挂但越来越慢 ,几乎不响应
• GC 日志:Full GC 间隔只有3-4秒,每次只回收十几MB
• jstat:Old 区 99%,FGC 持续增长,GC 总耗时占大部分运行时间
🔬 为什么会这样?
JVM 有一个保护机制:如果连续5次 GC 中,超过 98% 的时间在做 GC 且回收不足 2% 内存 → 抛出此 OOM。
本质 :不是"一次就用满了",而是"GC 忙了一整天却几乎回收不到东西"——老年代里有大量强引用对象,GC 反复尝试回收但收效甚微,CPU 全花在 GC 上了
🔍 排查步骤
① 看 GC 日志:确认 Full GC 频率(间隔秒级)和每次回收量(极少)
② jstat -gcutil <pid> 确认 Old 区 99%+、FGC 持续增长
③ jmap -histo <pid> | head -15 看什么对象占内存 → 跟问题1的排查一样
④ 重点看缓存类 :HashMap/ConcurrentHashMap 占内存大 → 没设上限的缓存
⚡ 跟其他问题怎么区分:
GC overhead = 堆还没完全满,但 GC "努力了半天白干";而 heap space = 堆彻底满了,连新对象都分配不下。排查方法相同 (都是 jmap + MAT),只是 GC overhead 的对象通常是被缓存 Map 持有,而 heap space 可能是泄漏
👀 你看到什么特征?
• 日志出现 java.lang.StackOverflowError
• 偶发 :不是每次都出,只有特定数据才触发
• 异常堆栈里同一段代码反复出现 (A→B→A→B→A…)
• 接口报错但服务没挂(其他接口正常)
🔬 为什么会这样?
每个线程有自己的方法调用栈 (-Xss,默认1MB)。每次方法调用压入一帧,方法返回弹出。如果方法A调B、B又调A(循环引用),栈帧只入不出,就溢出了。
本质 :不是内存问题!是调用深度超限。常见于 Jackson 序列化双向引用、递归没终止条件
🔍 排查步骤
① 直接看异常堆栈! 不需要 jmap/jstack——异常信息已经告诉你了
② 找到堆栈中反复出现的那两行 (比如 Order.getOrderItems → OrderItem.getOrder → 又回到 Order.getOrderItems)
③ 去代码里看这两个方法之间的引用关系 → 加 @JsonIgnore 打断循环
⚡ 跟其他问题怎么区分:
StackOverflow 是栈溢出 (调用太深),不是堆溢出!jmap/jstat 看不出问题。只需看异常堆栈就能定位。如果是 OOM 那是堆的问题,如果是 StackOverflow 那是调用链的问题
👀 你看到什么特征?
• 某个接口一调 CPU 就飙到接近 100%
• 不是一直高 ,只有特定输入才触发
• 其他接口也变慢(CPU 被吃光了)
• jstack 看到高CPU线程卡在 Pattern.match
🔬 为什么会这样?
正则引擎用回溯 来尝试所有可能的匹配路径。嵌套量词如 ([a-z]+)* 在匹配失败时,内层 [a-z]+ 可以切分出无数种组合,组合数是 O(2ⁿ)。30个字符就超过10亿次回溯。
本质 :正则看起来正常,但特定输入下回溯次数爆炸,CPU 被单个正则匹配吃满
🔍 排查步骤
① top -c 确认是 Java 进程吃CPU
② top -Hp <pid> 找到CPU最高的线程 TID
③ printf "%x\n" <TID> 转十六进制
④ jstack <pid> | grep -A 20 "<hex>" → 栈顶是 Pattern.match / Pattern$Loop.match → 确认是正则回溯
⚡ 跟问题6(死循环)怎么区分:
都是 CPU 100%,但 jstack 栈顶不同:正则回溯 栈顶是 Pattern.match → 死循环 栈顶是你的业务方法。如果栈顶是 Pattern 相关类 → 正则问题;如果是 while 循环里的业务代码 → 死循环
👀 你看到什么特征?
• 部署后 CPU 持续 100%(不是偶发)
• 重启也没用 (说明代码逻辑本身就有问题)
• 所有接口都变慢
• jstack 看到线程 RUNNABLE 卡在某个 while 循环
🔬 为什么会这样?
while 循环里没有 sleep/wait 也没有退出条件 → 空转。看似在"工作"(调数据库/发请求),但查询返回空结果后直接回到 while 开头,每秒执行几万次。
本质 :循环体执行太快又没有等待 → CPU 永远被这个线程占满
🔍 排查步骤
① top -Hp <pid> 找高CPU线程 TID
② printf "%x\n" <TID> + jstack <pid> | grep -A 15 "<hex>"
③ 看栈顶 → 如果是你的业务方法且 RUNNABLE (不是 Pattern.match)→ 死循环
④ 看线程名(比如 scheduler-thread-1)→ 去代码里找这个定时任务的 while 循环
⚡ 跟问题5(正则回溯)怎么区分:
排查步骤完全相同(top -Hp → jstack),区别在于 jstack 栈顶:Pattern.match = 正则 ;你的业务方法 = 死循环 。死循环的特征是"部署后就持续100%,重启也没用"
👀 你看到什么特征?
• 接口整体变慢 (没有报错,就是慢)
• GC 日志每几秒就一次 Full GC
• Grafana GC 暂停时间:平均 500ms+,最大 1s+
• jstat:FGC 持续增长,Old 区 85%-90% 波动
🔬 为什么会这样?
大对象频繁晋升到老年代 。年轻代太小(Eden 区放不下),新创建的大对象或大量存活对象直接进老年代。老年代很快填满 → 触发 Full GC(Stop-The-World)→ 暂停几百毫秒 → 重复。
本质 :不是内存泄漏,是"对象产生速度 > 年轻代容量",大对象直接进老年代频繁触发 Full GC
🔍 排查步骤
① jstat -gcutil <pid> 1000 5 确认 FGC 频率、Old 区使用率
② jmap -histo <pid> | head -15 看什么对象最多 → 如果是业务 DTO(每个20KB+)→ 大对象问题
③ 看年轻代大小:jinfo <pid> | grep NewRatio → 如果 NewRatio=4(Young 只占1/5)→ 年轻代太小
④ 调参:增大堆/增大年轻代(NewRatio=2)/ 加 PretenureSizeThreshold
⚡ 跟问题3(GC Overhead)和问题11(内存缓慢增长)怎么区分:
问题7 Full GC频繁 :GC 后 Old 区能降下来一些,但很快又满了 → 是"频率高"的问题
问题3 GC Overhead :GC 后 Old 区几乎降不下来(99%→98.5%)→ 是"GC白干了"的问题
问题11 内存缓慢增长 :Old 区慢慢涨,几天后才明显 → 是"温水煮青蛙"的问题
👀 你看到什么特征?
• 运行几天后内存持续增长(每天约200MB)
• 重启恢复,过几天又涨上来
• 没有 OOM ,只是内存越用越多
• jmap:ThreadLocal$Entry 数量 >> 线程池大小
🔬 为什么会这样?
线程池中线程被复用 ,每个线程有自己的 ThreadLocalMap。每次请求 set 了值但没 remove → 线程处理下一个请求时,上一个请求的 Value 还在 ThreadLocalMap 里。ThreadLocal 的 Key 是弱引用(GC 可回收),但 Value 是强引用(GC 不回收)→ Key 被回收后 Value 变成"孤儿"永远释放不了。
本质 :线程池 + ThreadLocal.set() 不 remove = 每个请求的上下文都残留在线程里
🔍 排查步骤
① jmap -histo <pid> | head -20 看有没有 ThreadLocal$Entry / UserContext 等数量异常
② 对比:Entry 数量(比如124万)vs 线程池大小(200)→ 如果远超 → 泄漏
③ 搜代码:全局搜 ThreadLocal.set → 检查对应的 finally 里有没有 .remove()
⚡ 跟问题11(内存缓慢增长)怎么区分:
都是"内存慢慢涨",但 ThreadLocal 泄漏的标志 是 jmap 中 ThreadLocal$Entry 数量远超线程数。如果 jmap 没有大量 Entry → 看问题11(本地缓存没淘汰)
👀 你看到什么特征?
• 多个接口同时超时 ,RT > 30s
• CPU 和内存都正常 !
• 日志无报错 (线程在等锁,不会抛异常)
• 重启恢复,但过几小时又卡住
🔬 为什么会这样?
两个线程各持有一把锁,又去等对方的锁 → 永远等下去。经典场景:A方法先锁1再锁2,B方法先锁2再锁1。
本质 :synchronized 嵌套且锁顺序不一致 → 互相等待 → 永久阻塞
🔍 排查步骤
① jstack <pid> → 输出中直接有 "Found one Java-level deadlock:"(JVM 自动检测死锁!)
② 看 jstack 报告:哪个线程等哪把锁、谁持有这把锁 → 代码里找到两把锁的获取顺序
⚡ 跟问题10(线程池耗尽)怎么区分:
都是"接口卡住不动",但死锁:CPU正常、内存正常、jstack直接报告deadlock 。线程池耗尽:线程全卡在 socketRead0(等网络IO),不是等锁。看 jstack 的线程状态就能区分
👀 你看到什么特征?
• 新请求越来越慢,CPU 很低 、内存正常
• 接口 RT 从 50ms 涨到 5s+
• Tomcat 线程全部在工作(200/200)
• jstack:几乎所有线程卡在 socketRead0(等网络响应)
🔬 为什么会这样?
Tomcat 有固定数量工作线程(默认200)。如果某个外部调用 (如银行接口)响应很慢(5秒+),每个请求都要等5秒才能释放线程。200个线程全在等 → 新请求只能排队。
本质 :慢的外部调用占满了线程池 → 不是CPU/内存问题,是"线程资源被慢操作占光了"
🔍 排查步骤
① jstack <pid> | grep "http-nio-8080" | wc -l 确认工作线程全被占满
② jstack <pid> | grep -A 3 "http-nio-8080" 看线程卡在哪 → socketRead0 = 等网络IO
③ Arthas trace 外部Service方法名 追踪耗时 → 确认哪个外部调用最慢
⚡ 跟问题9(死锁)怎么区分:
都是"接口卡住",但线程池耗尽:线程全卡在 socketRead0 (等外部响应),不是等锁。jstack 没有 "Found deadlock"。死锁的线程是 BLOCKED 等锁,线程池耗尽是 WAITING 等IO
👀 你看到什么特征?
• 服务刚重启很快,运行几天后越来越慢
• 内存没有 OOM,但 GC 暂停时间越来越长
• 每次重启能恢复几天
• jstat:Old 区持续缓慢增长(每天涨几百MB)
🔬 为什么会这样?
用 static HashMap/ConcurrentHashMap 做缓存,没有大小限制也没有过期时间 → 只 put 不淘汰 → 缓存对象被 static 强引用持有,Young GC 回收不了 → 慢慢全部晋升到老年代 → 老年代越来越大,GC 越来越慢。
本质 :本地缓存没有淘汰策略 = 缓慢的内存泄漏。不是一次性的,是"温水煮青蛙"
🔍 排查步骤
① jstat -gcutil <pid> 5000 观察 Old 区缓慢增长趋势
② jmap -histo <pid> | head -15 对比前后两次输出,看哪个类的实例数在持续增长
③ 如果看到 ConcurrentHashMap$Node / CacheEntry 大量堆积 → 找代码中的 static Map
④ 确认这个 Map 有没有淘汰策略(maximumSize? expireAfterWrite?)
⚡ 跟问题1(OOM-Heap)和问题8(ThreadLocal泄漏)怎么区分:
问题1 :内存暴涨 + OOM报错 → 一次性大量数据
问题8 :内存慢涨 + jmap 有大量 ThreadLocal$Entry → ThreadLocal没remove
问题11 :内存慢涨 + jmap 没有大量 Entry → 看本地缓存(static Map 没淘汰策略)
🧠 排查心法:特征定位法
① 先看报错类型 :OOM 看 heap/Metaspace/GC overhead 哪种;StackOverflow 直接看异常栈;CPU高看栈顶是正则还是业务代码
② 再看 jstack 线程状态 :BLOCKED=锁问题、WAITING on socket=外部调用、RUNNABLE=CPU计算
③ 最后用 jmap 看对象 :只看自定义类(com.xxx),排前面的就是问题所在
④ 记住排除法 :CPU正常 → 不是CPU问题;内存正常 → 不是内存问题;jstack没deadlock → 不是死锁
场景 1
OOM - Java Heap Space
场景: 线上接口突然返回500,日志里 java.lang.OutOfMemoryError: Java heap space
🚨 P0 告警 — 生产环境
服务 order-service 连续返回 HTTP 500,错误率飙升至 78%。日志出现 java.lang.OutOfMemoryError: Java heap space
—— 2026-05-18 14:23 ——
运
运维老刘
告警!order-service 大量 500,错误率 78%,看了眼日志全是 OOM
张
后端小张
收到,我先看下 Grafana 内存监控
场景描述: 下午两点,没有任何预兆,线上开始大面积报错。这不是流量高峰,没有任何部署变更,但内存就是突然用满了。这种"无征兆OOM"往往是最让人头疼的。
事件时间线
14:20
Grafana 内存监控开始出现异常上涨趋势
14:22
首次出现 OOM 异常,日志打印 java.lang.OutOfMemoryError: Java heap space
14:23
错误率飙升至 78%,大量接口返回 HTTP 500
15:10
定位到 ExportService 内存泄漏,修复并上线
1
第一步:看 Grafana 监控,确认内存趋势
先确认是不是真的内存问题,还是别的原因导致的500
Grafana
JVM Memory — order-service (prod)
Last 6 hours ↻ 30s
JVM Heap Memory Used
1.92 GB / 2.0 GB
Heap Used
Max Heap (2GB)
GC Count (Last 1h)
Full GC: 47
Full GC
内存曲线一直在涨,完全没回落!正常的GC应该有锯齿状起伏,现在是一条斜线到顶。Full GC 47次但完全回收不了东西。这是典型的内存泄漏,不是简单的内存不够。
2
第二步:jmap -histo 看对象分布
先看哪个类的对象最多,定位泄漏的大方向
[prod-server ~]# jmap -histo 28456 | head -30
num #instances #bytes class name
----------------------------------------------
1: 2847536 546331712 [Ljava.lang.Object;
2: 1423768 227802880 java.util.ArrayList
3: 2847536 136681728 com.order.entity.OrderItem
4: 5632 89724800 com.order.service.ExportService
5: 256890 41102400 java.lang.String
6: 128445 20551600 java.util.HashMap$Node
7: 64223 10273280 byte[]
8: 34211 5473760 java.lang.Long
Total 7809571 1066430656
OrderItem 有 284 万个实例!而且 ExportService 虽然只有 5632 个实例但占了 89MB。正常情况下订单不可能同时加载这么多条。导出服务可疑——它是不是把查询结果全放内存了?
3
第三步:jmap -dump 导出堆,用 MAT 分析
对象分布只能看到"谁多",但看不到"谁持有了谁"。需要dump做引用链分析。
[prod-server ~]# jmap -dump:format=b,file=heap.hprof 28456
Dumping heap to /tmp/heap.hprof ...
Heap dump file created [1876543210 bytes]
Eclipse MAT
Memory Analyzer — heap.hprof (1.75 GB)
Leak Suspects
Suspect 1: ExportService 占用 72.3% 的堆内存
One instance of com.order.service.ExportService
occupies 1.38 GB (72.3%) of the heap.
The instance is referenced by
java.util.concurrent.ThreadPoolExecutor$Worker@0x7a3f2c18.
Shallow size: 1.2 KB |
Retained size: 1.38 GB
■ ExportService (1.38 GB)
■ Other Objects (244 MB)
■ Free (56 MB)
Dominator Tree (Top 5)
ThreadPoolExecutor$Worker → 1.38 GB
ExportService → 1.38 GB
ArrayList → 1.36 GB
OrderItem[2847536] → 1.28 GB
java.lang.Object[] → 1.28 GB
MAT的泄漏报告直接指向了 ExportService!它的 retained size 是 1.38 GB。引用链是:ThreadPoolExecutor Worker → ExportService → ArrayList → OrderItem[2847536]。一个ArrayList里放了284万个OrderItem!这绝对是问题所在。
IntelliJ IDEA — order-service
ExportService.java
OrderController.java
📦 com.order
📁 controller
📁 service
📄 ExportService
📁 entity
📄 OrderItem
📁 mapper
1 package com.order.service;
2
3 import java.util.ArrayList;
4 import java.util.List;
5 import org.springframework.stereotype.Service;
6
7 @Service
8 public class ExportService {
9
10 // 问题根源:这个List没有大小限制!
11 private List <OrderItem > allItems = new ArrayList <>();
12
13 public void exportOrders (OrderQuery query) {
14 // 每次导出都往同一个List里追加!
15 List <OrderItem > items = orderMapper.queryAll(query);
16 allItems.addAll (items); // ← 灾难!不断追加,从不清理
17 writeExcel (allItems);
18 }
19 }
⚠ 1 warning
UTF-8
Java 17
问题根因:ExportService 是单例Bean,allItems 是实例变量,每次调用 exportOrders 都往里追加,从不清理
修复方案
将 allItems 改为方法内局部变量,不用实例变量存数据
如果必须分批导出,限制单次加载数量,用分页查询
短期应急:增大 -Xmx 从 2G 到 4G
长期:接入流式导出,不在内存中持有全部数据
IntelliJ IDEA — ExportService.java (修复后)
1 @Service
2 public class ExportService {
3
4 // ✅ 不再用实例变量,改为方法局部变量
5 public void exportOrders (OrderQuery query) {
6 // 分页查询,每批1000条
7 int pageSize = 1000 ;
8 int pageNo = 1 ;
9 List <OrderItem > batch;
10 do {
11 batch = orderMapper.queryPage (query, pageNo, pageSize);
12 writeExcelBatch (batch);
13 pageNo++;
14 } while (batch.size () == pageSize);
15 }
16 }
场景 3
OOM - GC Overhead Limit Exceeded
场景: 服务变慢,GC日志显示频繁Full GC但回收很少,最终 GC overhead limit exceeded
—— 2026-06-02 09:15 ——
运
运维老刘
user-service 接口响应从 50ms 涨到 3s+,监控显示不停在 Full GC
张
后端小张
看下GC日志,我猜是Old区满了回收不了
场景描述: 服务没直接挂,但是越来越慢。GC Overhead Limit 的意思是:JVM 花了超过 98% 的时间在做GC,但只回收了不到 2% 的内存。这时候JVM觉得"我再怎么回收也没用了",干脆抛 OOM。
2026-06-02T09:12:03.456+0800: [Full GC (Allocation Failure)
[Old: 1812M->1798M(1848M), 3.2456789 secs]
[Metaspace: 84562K->84562K(112432K)], 3.2457891 secs]
[Times: user=12.56 sys=0.34, real=3.25 secs]
2026-06-02T09:12:06.789+0800: [Full GC (Allocation Failure)
[Old: 1805M->1796M(1848M), 2.9876543 secs]
[Metaspace: 84568K->84568K(112432K)], 2.9877654 secs]
2026-06-02T09:12:10.123+0800: [Full GC (Allocation Failure)
[Old: 1810M->1799M(1848M), 3.1234567 secs]
2
第二步:jstat -gcutil 确认各代使用率
[prod-server ~]# jstat -gcutil 24856
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
0.00 0.00 98.7 99.12 75.2 68.4 823 12.456 347 1024.567 1037.023
Old区 99.12% 几乎满了,Full GC 347 次但几乎回收不了什么东西。说明老年代里的对象都是存活的(被引用着),GC没法回收。这是典型的"大对象长期占着老年代"问题。需要dump看看到底是什么东西。
Grafana
GC Frequency — user-service (prod)
Last 1 hour ↻ 30s
Full GC Count
347 in 1h
Full GC/min
[prod-server ~]# jmap -histo 24856 | head -15
num #instances #bytes class name
----------------------------------------------
1: 1248 746496000 [Ljava.lang.Object;
2: 1248 596096000 java.util.HashMap
3: 1248 298048000 com.user.cache.UserDataCache
4: 128890 41102400 java.lang.String
5: 64445 20551600 java.util.HashMap$Node
IntelliJ IDEA — UserDataCache.java
1 @Component
2 public class UserDataCache {
3
4 // 每个用户一个缓存Map,不设上限!
5 private Map <String , Object > dataMap = new HashMap <>();
6
7 public void put (String key, Object value) {
8 dataMap.put (key, value); // 无限put,从不清理
9 }
10 }
问题根因:UserDataCache 的 HashMap 没有大小限制,持续 put 不清理,大Map对象进入老年代后无法回收
修复方案
使用 LinkedHashMap + LRU 淘汰,或直接用 Caffeine/Guava Cache
设置缓存过期时间和最大容量
优化 JVM 参数:增大堆内存,调整 -XX:NewRatio 增大年轻代比例
GC Overhead Limit 的判断逻辑
JVM 在以下条件同时满足 时抛出 GC Overhead Limit Exceeded:
1. 连续 5 次 Full GC 中,超过 98% 的时间用于 GC
2. 连续 5 次 Full GC 中,回收的内存不足 2%
3. 可通过 -XX:-UseGCOverheadLimit 关闭此检查(不推荐,治标不治本)
本质上这是 JVM 的"自我保护"——发现 GC 徒劳无功时提前报错,而不是让系统彻底卡死。
场景 4
StackOverflowError
场景: 接口偶发 StackOverflowError,不是每次都出
—— 2026-04-25 11:30 ——
测
测试小李
订单详情接口报错了,但不是必现的。有订单能返回,有的就500
张
后端小张
偶发的StackOverflow?看下日志
场景描述: 偶发的 StackOverflow 比必现的更难查。必现的说明代码一定有死循环,偶发的说明在某些特定数据条件下才会触发。典型场景:循环引用导致序列化无限递归。
StackOverflowError 是什么?
每个线程有一个调用栈(Stack),每次方法调用都会压入一个栈帧。栈空间有限(默认约 512KB~1MB),当栈深度超过限制时抛出 StackOverflowError。常见原因:
1. 递归调用无终止条件 — 方法A调A,无限递归
2. 循环引用序列化 — A→B→A→B... Jackson无限递归
3. 相互调用链过深 — A→B→C→D→...→Z 层级太深
-Xss 可调整栈大小,但治标不治本
java.lang.StackOverflowError: null
at com.fasterxml.jackson.databind.ser.BeanPropertyWriter.serializeAsField(BeanPropertyWriter.java:645)
at com.fasterxml.jackson.databind.ser.std.BeanSerializerBase.serializeFields(BeanSerializerBase.java:735)
at com.fasterxml.jackson.databind.ser.BeanSerializer.serialize(BeanSerializer.java:164)
at com.fasterxml.jackson.databind.ser.BeanPropertyWriter.serializeAsField(BeanPropertyWriter.java:645)
at com.fasterxml.jackson.databind.ser.std.BeanSerializerBase.serializeFields(BeanSerializerBase.java:735)
at com.fasterxml.jackson.databind.ser.BeanSerializer.serialize(BeanSerializer.java:164)
at com.order.entity.Order.getOrderItems(Order.java:45) ← 重复出现!
at com.order.entity.OrderItem.getOrder(OrderItem.java:32) ← 重复出现!
at com.order.entity.Order.getOrderItems(Order.java:45) ← 循环!
at com.order.entity.OrderItem.getOrder(OrderItem.java:32) ← 循环!
... 1024 more
堆栈里反复出现 Order.getOrderItems → OrderItem.getOrder → Order.getOrderItems → ... 这就是典型的循环引用!Jackson在序列化Order时遍历orderItems,每个OrderItem又引用了Order,Order又引用了OrderItems...死循环!
IntelliJ IDEA — order-entity
Order.java
OrderItem.java
📦 com.order.entity
📄 Order
📄 OrderItem
1 @Entity
2 @Table (name = "t_order" )
3 public class Order {
4
5 @Id
6 private Long id;
7
8 // Order → List<OrderItem>:一对多
9 @OneToMany (mappedBy = "order" )
10 private List <OrderItem > orderItems; // ← 反向引用!
11 }
IntelliJ IDEA — OrderItem.java
Order.java
OrderItem.java
1 @Entity
2 @Table (name = "t_order_item" )
3 public class OrderItem {
4
5 // OrderItem → Order:多对一
6 @ManyToOne
7 @JoinColumn (name = "order_id" )
8 private Order order; // ← 反向引用!形成环路!
9 }
JSON 序列化循环引用示意图
Jackson序列化 Order
→
遍历 orderItems
→
序列化 OrderItem
→
序列化 Order(回去了!)
→
∞ 死循环
JPA 实体的双向关联是标准写法,但 JSON 序列化无法处理这种循环引用
问题根因:Order ↔ OrderItem 双向引用,Jackson序列化时无限递归
修复方案
在 OrderItem.order 上加 @JsonIgnore,序列化时忽略反向引用
或使用 @JsonManagedReference + @JsonBackReference 成对标注
使用 VO/DTO 模式返回数据,不直接序列化实体类
IntelliJ IDEA — OrderItem.java (修复后)
1 public class OrderItem {
2
3 // ✅ 序列化时忽略反向引用,打断循环
4 @ManyToOne
5 @JoinColumn (name = "order_id" )
6 @JsonIgnore
7 private Order order;
8 }
场景 5
CPU 100% — 正则表达式回溯
场景: 某个搜索接口一调CPU就飙到100%,其他接口也跟着变慢
🚨 P0 告警 — CPU 飙升
生产服务器 CPU 使用率 99.3%,所有接口响应变慢。触发时间与搜索接口调用时间吻合。
场景描述: CPU 100% 不一定是死循环。正则回溯是一种更隐蔽的性能杀手——代码看起来正常,但在特定输入下,正则引擎的回溯次数会呈指数级增长,吃满CPU。
CPU 100% 排查通用流程
top 找进程
→
top -Hp 找线程
→
printf %x 转换
→
jstack 看栈
→
定位代码
这5步是排查 CPU 100% 的标准套路,无论原因是死循环、正则回溯还是其他,都能定位到。
[prod-server ~]# top -c
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
28456 root 20 0 4096m 2.1g 125m S 99.3 13.2 245:32.1 java -jar search-service.jar
1234 root 20 0 512m 48m 12m S 2.1 0.3 0:12.3 nginx
5678 mysql 20 0 2048m 450m 32m S 1.8 2.8 45:12.6 mysqld
[prod-server ~]# top -Hp 28456
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
28512 root 20 0 4096m 2.1g 125m R 98.7 13.2 120:45.3 java -jar search-service.jar
28457 root 20 0 4096m 2.1g 125m S 0.3 13.2 0:01.2 java -jar search-service.jar
28458 root 20 0 4096m 2.1g 125m S 0.1 13.2 0:00.8 GC Thread#0
3
第三步:线程ID转16进制 + jstack 定位
[prod-server ~]# printf "%x\n" 28512
6f60
[prod-server ~]# jstack 28456 | grep -A 20 "6f60"
"http-nio-8080-exec-3" #32 daemon prio=5 os_prio=0 tid=0x00007f8a1400b800 nid=0x6f60 runnable
java.lang.Thread.State: RUNNABLE
at java.util.regex.Pattern$BmpCharProperty.match(Pattern.java:4023)
at java.util.regex.Pattern$GroupHead.match(Pattern.java:4845)
at java.util.regex.Pattern$Loop.match(Pattern.java:4941)
at java.util.regex.Pattern$GroupTail.match(Pattern.java:4901)
at java.util.regex.Pattern$Branch.match(Pattern.java:4923)
at java.util.regex.Pattern$BmpCharProperty.match(Pattern.java:4023)
at java.util.regex.Pattern$GroupHead.match(Pattern.java:4845)
...
at com.search.service.SearchValidator.validate(SearchValidator.java:23)
线程栈全部卡在正则匹配里!Pattern$Loop.match 反复出现——这是正则回溯的典型特征。问题代码在 SearchValidator.java:23。让我去看那个正则。
IntelliJ IDEA — SearchValidator.java
1 @Component
2 public class SearchValidator {
3
4 // 危险正则!嵌套量词导致指数级回溯
5 private static final Pattern DANGEROUS =
6 Pattern.compile ("([a-z]+)*([0-9]+)*" );
7
8 public boolean validate (String input) {
9 return DANGEROUS.matcher (input).matches (); // ← 在此卡死
10 }
11 }
正则回溯原理 — 嵌套量词的灾难
正则 ([a-z]+)*([0-9]+)* 遇到输入 "aaaaaa!"(不匹配的字符):
// 正则引擎尝试所有可能的分组方式
尝试1: ([a-z]+) 匹配 "aaaaaa" → ([0-9]+)* 失败
尝试2: ([a-z]+)="aaaaa" + ([a-z]+)="a" → ([0-9]+)* 失败
尝试3: ([a-z]+)="aaaa" + ([a-z]+)="aa" → 失败
尝试4: ([a-z]+)="aaaa" + ([a-z]+)="a" + ([a-z]+)="a" → 失败
... 组合数 = O(2^n) ,n=字符长度
// 30个字符 = 超过10亿次回溯!
问题根因:嵌套量词 ([a-z]+)* 导致正则回溯复杂度爆炸,特定输入下CPU被吃满
修复方案
避免嵌套量词,改为更精确的匹配:^[a-z0-9]+$
使用占有量词 ([a-z]++)* 或原子组减少回溯
设置正则匹配超时,防止无限卡死
对输入做预校验,不把用户输入直接丢给复杂正则
正则表达式安全检查清单
1. 避免嵌套量词:(a+)+ (a*)* (a+)* 都是危险模式
2. 能用字符类 [a-z] 就不用 (a|b|c|...)
3. 量词后的匹配内容尽量互斥:用 \d+\.\d+ 不用 .+\..+
4. 测试时用超长字符串 + 不匹配的模式来验证回溯
5. 在线工具推荐:regex101.com 可以看到回溯步骤数
场景 6
CPU 100% — 代码死循环
场景: 部署后CPU持续100%,重启也没用
—— 2026-05-10 22:05 ——
运
运维老刘
刚发完版,CPU直接打满了,重启了还是100%
张
后端小张
重启也没用说明是启动逻辑里就有死循环,我来查
场景描述: 重启也100%,说明不是某个请求触发的,而是初始化或定时任务里的代码就有问题。部署就死循环,这是最不"阴险"的一种——至少你知道一定是最近提交的代码搞的。
CPU 100% 的三种常见原因
原因
jstack 特征
线程状态
可复现性
正则回溯
Pattern.match 在栈顶反复出现
RUNNABLE
特定输入触发
代码死循环
业务方法在栈顶,循环调用
RUNNABLE
必现或条件触发
锁竞争
BLOCKED,等待获取锁
BLOCKED
高并发时出现
[prod-server ~]# top -Hp 31245
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
31301 root 20 0 4096m 1.8g 118m R 99.1 11.4 45:12.3 java -jar report-service.jar
[prod-server ~]# printf "%x\n" 31301
7a25
[prod-server ~]# jstack 31245 | grep -A 15 "7a25"
"scheduler-thread-1" #18 prio=5 os_prio=0 tid=0x00007f8c2400c800 nid=0x7a25 RUNNABLE
java.lang.Thread.State: RUNNABLE
at com.report.task.DataSyncTask.syncData(DataSyncTask.java:28)
at com.report.task.DataSyncTask.run(DataSyncTask.java:15)
at java.lang.Thread.run(Thread.java:829)
scheduler-thread-1,RUNNABLE 状态,卡在 DataSyncTask.java:28。名字看起来是个定时同步任务。线程状态是 RUNNABLE 不是 WAITING,说明它在一直执行,没有阻塞。看看代码。
IntelliJ IDEA — DataSyncTask.java
1 @Component
2 public class DataSyncTask implements Runnable {
3
4 private volatile boolean running = true ;
5
6 @Override
7 public void run () {
8 while (running) {
9 syncData ();
10 }
11 }
12
13 private void syncData () {
14 // 当没有数据时,直接跳过,不等待
15 List <Data > dataList = dataMapper.queryNewData ();
16 if (dataList.isEmpty ()) {
17 return ; // ← 直接return!没有任何等待!
18 }
19 // 处理数据...
20 dataList.forEach (this ::process );
21 }
22 }
找到了!while(running) 循环里调用 syncData(),当没有数据时直接 return 回到 while——没有任何 sleep 或 wait!这意味着当没有数据要同步时,这个线程就会疯狂地 while→query→return→while→query→return...每秒执行几万次数据库查询,CPU直接打满。
问题根因:while(true) 循环在无数据时不等待,形成空转死循环
修复方案
空转时加 Thread.sleep(5000) 或 LockSupport.parkNanos
更好的方案:用 BlockingQueue 实现生产者-消费者模式
或直接用 Spring @Scheduled 定时任务替代 while 循环
IntelliJ IDEA — DataSyncTask.java (修复后)
1 @Component
2 public class DataSyncTask {
3
4 // ✅ 用定时任务代替 while 循环
5 @Scheduled (fixedDelay = 5000 )
6 public void syncData () {
7 List <Data > dataList = dataMapper.queryNewData ();
8 if (!dataList.isEmpty ()) {
9 dataList.forEach (this ::process );
10 }
11 // fixedDelay=5000 保证每次执行完等5秒再下次
12 }
13 }
场景 7
Full GC 频繁
场景: 服务响应慢,GC日志每几秒就一次Full GC
—— 2026-03-28 16:40 ——
测
测试小王
报表接口压测 P99 到了 4.5s,之前只要 200ms
场景描述: Full GC 频繁不一定是 OOM,但服务会变慢。每次 Full GC 都会 Stop-The-World(暂停所有业务线程),如果几秒就来一次,接口自然就慢了。常见原因:大对象直接进老年代,或者年轻代太小导致对象过早晋升。
JVM 内存分代与 GC 关系
JVM 堆内存分为年轻代(Young)和老年代(Old)。新对象在 Eden 区创建,Young GC 回收年轻代,Full GC 回收整个堆。
┌──────────────── JVM Heap ────────────────┐
│
┌── Young Gen ──────────────────┐
┌ Eden ┐ ┌ S0 ┐ ┌ S1 ┐
│ 新对象 │ │存活│ │ │ ← Young GC 回收
└──────────────────────────────┘
│
┌── Old Gen ──────────────────────┐
│ 长期存活对象 / 大对象 │ ← Full GC 回收
└────────────────────────────────┘
└──────────────────────────────────────────┘
对象晋升条件:1) Young GC 后仍存活 2) 对象年龄达到阈值 3) 大对象超过 PretenureSizeThreshold 直接进老年代
1
第一步:jstat -gcutil 看各代使用率
[prod-server ~]# jstat -gcutil 19532 1000 5
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
0.00 32.5 89.2 87.6 62.1 55.3 1245 8.923 156 89.456 98.379
45.2 0.00 76.8 89.3 62.1 55.3 1248 8.945 157 89.912 98.856
0.00 28.1 92.1 88.1 62.2 55.4 1251 8.978 158 90.234 99.212
38.7 0.00 84.5 90.2 62.2 55.4 1254 9.012 159 90.567 99.579
0.00 41.6 91.8 89.8 62.3 55.5 1257 9.045 160 91.012 100.057
每秒一次Full GC,这不正常。Young GC 也很频繁(每秒3次),说明Eden区很快就被填满了。对象在年轻代来不及回收就晋升到老年代,老年代很快就满了又触发 Full GC。问题可能是:1)年轻代太小 2)有大对象直接进老年代。
Grafana
JVM GC Monitor — report-service
Last 30 min ↻ 10s
GC Pause Time (ms)
Avg: 580ms
Young Gen vs Old Gen
Young Gen (Eden + S0 + S1)
92% full
Old Gen
89% full
[prod-server ~]# jmap -histo 19532 | head -15
num #instances #bytes class name
----------------------------------------------
1: 12456 398592000 byte[]
2: 12456 298944000 com.report.dto.ReportDataDTO
3: 24892 99648000 java.util.ArrayList
4: 62456 19904000 java.lang.String
[prod-server ~]# jmap -finalizerinfo 19532
Number of objects pending for finalization: 0
每个ReportDataDTO 24KB,超过默认的PretenureSizeThreshold(按JDK8默认约1MB以下都走年轻代,但如果年轻代Eden区只有几十MB,这些对象Eden放不下就直接晋升到老年代了)。大对象频繁创建→老年代快速填满→频繁Full GC。
IntelliJ IDEA — ReportGenerator.java
1 @Service
2 public class ReportGenerator {
3
4 public byte [] generateReport (ReportQuery query) {
5 // 每次生成报表都在内存中构建完整数据
6 List <ReportDataDTO > allData = reportMapper.queryAll (query);
7 // ReportDataDTO包含大量字段+嵌套对象,每个约24KB
8 byte [] pdfBytes = pdfRenderer.render (allData); // ← 大byte数组
9 return pdfBytes;
10 }
11 }
问题根因:频繁创建大对象(24KB的DTO和大byte数组),年轻代太小导致对象快速晋升到老年代,老年代频繁满触发Full GC
修复方案
优化数据结构,ReportDataDTO 只保留必要字段,减少单个对象体积
增大年轻代:-Xmn 或调整 -XX:NewRatio=2(年轻代:老年代=1:2)
调大 -XX:PretenureSizeThreshold 让真正的大对象直接进老年代,避免中小对象因Eden不够而提前晋升
改用流式处理,不在内存中同时持有所有数据
JVM 内存调整前后对比
调整前
-Xms2g -Xmx2g
-XX:NewRatio=4 (Young=400MB)
Eden ≈ 320MB
Full GC: 160次/5s
调整后
-Xms4g -Xmx4g
-XX:NewRatio=2 (Young=1.3GB)
Eden ≈ 1.1GB
Full GC: 2次/h
场景 8
ThreadLocal 内存泄漏
场景: 使用线程池处理请求,运行几天后内存持续增长
⚠️ P2 告警 — 内存缓慢增长
服务 api-gateway 内存使用量每天增长约 200MB,已连续5天,从 1.2GB 涨到 2.2GB。预计3天后将触发 OOM。
场景描述: 这种"温水煮青蛙"式的内存泄漏最难发现。不像OOM那样突然炸掉,而是每天涨一点,重启就好了,过几天又涨上来。运维已经习惯性重启了,但问题始终没解决。今天终于有人问了:为什么每次都要重启?
ThreadLocal 为什么会泄漏?
ThreadLocal 的内部实现:每个 Thread 对象持有一个 ThreadLocalMap,Key 是 ThreadLocal 的弱引用,Value 是强引用。当 ThreadLocal 对象被回收后,Key 变成 null,但 Value 仍然被强引用,无法被 GC。
Thread 对象
└─ ThreadLocalMap
└─ Entry[] table
├─ Entry[0]: Key=null(弱引用被回收) → Value=UserContext{Alice} ← 泄漏!
├─ Entry[1]: Key=threadLocal1 → Value=data1
└─ Entry[2]: Key=null → Value=UserContext{Bob} ← 泄漏!
// 线程池中线程不销毁 → ThreadLocalMap 不释放 → Value 永远无法 GC
// 解决方案:finally 中调用 threadLocal.remove(),清除整个 Entry
—— 2026-04-15 10:00 ——
运
运维老刘
api-gateway 又快 OOM 了,上个月重启3次了。这次能查一下根因吗?
李
Tech Lead 老李
每天涨200MB一定是泄漏,小张你来排查一下
[prod-server ~]# jmap -histo 32456 | head -20
num #instances #bytes class name
----------------------------------------------
1: 1245600 299024000 java.lang.ThreadLocal$Entry
2: 622800 149200000 com.gateway.context.UserContext
3: 622800 99400000 java.util.HashMap$Node
4: 248560 49712000 java.lang.String
5: 124560 39859200 byte[]
[prod-server ~]# jmap -histo 32456 | grep "ThreadLocal"
1: 1245600 299024000 java.lang.ThreadLocal$Entry
3: 622800 149504000 java.lang.ThreadLocal$ThreadLocalMap$Entry[]
ThreadLocal$Entry 124万个!但线程池只有200个线程。每个线程的 ThreadLocalMap 里存了很多 Entry。UserContext 有 62 万个——正常情况下同时只有200个线程在跑,UserContext 不应该超过 200 个。一定是线程池的线程被复用,但 ThreadLocal 的值没有被清理,每次请求 set 了新的 UserContext 但从不 remove,旧的 Entry 还留在 ThreadLocalMap 里。
2
第二步:查看 ThreadLocal 使用代码
IntelliJ IDEA — api-gateway
UserContextHolder.java
AuthFilter.java
📦 com.gateway
📁 context
📄 UserContextHolder
📄 UserContext
📁 filter
📄 AuthFilter
1 package com.gateway.context;
2
3 public class UserContextHolder {
4
5 private static final ThreadLocal <UserContext > contextHolder =
6 new ThreadLocal <>();
7
8 public static void set (UserContext ctx) {
9 contextHolder.set (ctx);
10 }
11
12 public static UserContext get () {
13 return contextHolder.get ();
14 }
15 }
IntelliJ IDEA — AuthFilter.java
UserContextHolder.java
AuthFilter.java
1 @Component
2 public class AuthFilter implements Filter {
3
4 @Override
5 public void doFilter (ServletRequest req, ServletResponse resp, FilterChain chain) {
6 UserContext ctx = parseUser (req);
7 UserContextHolder.set (ctx); // ← 设置了,但从不清除!
8 try {
9 chain.doFilter (req, resp);
10 } finally {
11 // ❌ 没有 remove!线程池线程复用,旧的 UserContext 残留
12 }
13 }
14 }
线程池 + ThreadLocal 泄漏原理
线程池的线程是复用的,一个线程处理完请求A后会被用来处理请求B。如果 ThreadLocal 不 remove,线程A的 ThreadLocalMap 里就会积累越来越多的 Entry。
Thread-1 (线程池线程,复用)
┌─ ThreadLocalMap
├─ Entry[0]: UserContext{user=Alice} ← 请求1设置,未remove
├─ Entry[1]: UserContext{user=Bob} ← 请求2设置,未remove
├─ Entry[2]: UserContext{user=Carol} ← 请求3设置,未remove
├─ Entry[3]: UserContext{user=Dave} ← 请求4当前值
├─ ...
└─ Entry[N]: 持续增长... 💣
每个线程都在积累 Entry,200个线程 × N次请求 = 海量 Entry
原来如此!AuthFilter 在每个请求进来时 set UserContext,但在 finally 块里没有 remove。线程池的线程被复用,每个线程的 ThreadLocalMap 就像垃圾场一样不断堆积 Entry。虽然 ThreadLocal 的 Entry 是 WeakReference,但 value(UserContext)是强引用,不会被GC!这就是经典的 ThreadLocal 内存泄漏。
问题根因:线程池中使用 ThreadLocal 但未在 finally 中 remove,线程复用导致 Entry 堆积
修复方案
在 finally 块中调用 UserContextHolder.remove()
更好的方案:使用 InheritableThreadLocal 或阿里的 TransmittableThreadLocal(TTL)
代码审查规范:任何 ThreadLocal.set() 必须配对 remove()
IntelliJ IDEA — AuthFilter.java (修复后)
1 @Component
2 public class AuthFilter implements Filter {
3
4 @Override
5 public void doFilter (ServletRequest req, ServletResponse resp, FilterChain chain) {
6 UserContext ctx = parseUser (req);
7 UserContextHolder.set (ctx);
8 try {
9 chain.doFilter (req, resp);
10 } finally {
11 // ✅ 必须在 finally 中清理 ThreadLocal
12 UserContextHolder.remove ();
13 }
14 }
15 }
IntelliJ IDEA — UserContextHolder.java (修复后)
1 public class UserContextHolder {
2
3 private static final ThreadLocal <UserContext > contextHolder =
4 new ThreadLocal <>();
5
6 public static void set (UserContext ctx) { contextHolder.set (ctx); }
7 public static UserContext get () { return contextHolder.get (); }
8
9 // ✅ 新增 remove 方法
10 public static void remove () { contextHolder.remove (); }
11 }
场景 9
死锁 — 接口全部卡住不动
场景: 线上多个接口同时超时,看日志没有任何报错,线程全卡住了。重启后恢复,过一阵又卡住。
🚨 P1 告警 — 生产环境
多个接口同时超时 RT > 30s,但 CPU 和内存都正常。日志里没有任何异常,只是请求越来越慢直到完全卡死。
—— 2026-05-22 10:15 ——
QA
测试小王
创建订单和退款接口都卡住了,点完一直转圈,30秒超时
运
运维老刘
重启了,好了。但过了2小时又卡了。这不是重启能解决的。
场景描述: 重启就好、过一阵又卡——这是死锁的典型特征。两个线程各持有一把锁,又想拿对方的锁,谁都不让步,就永久等待了。
1
第一步:jstack 一看就知道 — 找到 "Found one Java-level deadlock"
[prod-server ~]# jstack 28456
...
Found one Java-level deadlock:
=============================
"order-thread-1":
waiting to lock monitor 0x00007f8a1400b800 (object 0x000000076b123456, a java.lang.Object),
which is held by "refund-thread-1"
"refund-thread-1":
waiting to lock monitor 0x00007f8a1400b900 (object 0x000000076b123789, a java.lang.Object),
which is held by "order-thread-1"
jstack 直接告诉了我们:order-thread-1 和 refund-thread-1 互相等对方的锁。接下来去看代码——谁在用这两把锁。
IntelliJ IDEA — OrderService.java + RefundService.java
OrderService.java
RefundService.java
1 // ❌ 问题代码:锁的顺序和 RefundService 反了
2 public void createOrder () {
3 synchronized (orderLock ) { // ① 先拿 orderLock
4 synchronized (refundLock ) { // ② 再拿 refundLock
5 // 创建订单 + 扣库存
6 }
7 }
8 }
9
10 // ❌ 问题代码:锁的顺序和 OrderService 反了!
11 public void refund () {
12 synchronized (refundLock ) { // ① 先拿 refundLock
13 synchronized (orderLock ) { // ② 再拿 orderLock
14 // 退款 + 恢复库存
15 }
16 }
17 }
原因找到了!createOrder 先拿 orderLock 再拿 refundLock,refund 先拿 refundLock 再拿 orderLock。两个线程同时跑就卡死了——就像两个人同时过独木桥,谁也不让谁。
IntelliJ IDEA — 修复后
1 // ✅ 修复:两个方法锁的顺序一致 — 都是先 orderLock 再 refundLock
2 public void createOrder () {
3 synchronized (orderLock ) { // ① 先拿 orderLock
4 synchronized (refundLock ) { // ② 再拿 refundLock
5 // 创建订单
6 }
7 }
8 }
9
10 // ✅ 修复:refund 也先拿 orderLock 再拿 refundLock,顺序一致
11 public void refund () {
12 synchronized (orderLock ) { // ① 也先拿 orderLock
13 synchronized (refundLock ) { // ② 再拿 refundLock
14 // 退款
15 }
16 }
17 }
✅ 修复总结:
• 死锁的根本原因:两个线程拿锁的顺序不一致
• 修复方法:统一所有方法拿锁的顺序(都先 orderLock 再 refundLock)
• 更好的做法:尽量不用嵌套 synchronized,改用 ReentrantLock + tryLock(timeout) 超时机制
💭 类比:两个人过独木桥
A 从左往右走,B 从右往左走,走到中间谁也不让谁 → 死锁
修复:规定所有人只能从左往右走 → 不会再相遇(统一锁顺序)
或者:等3秒等不到就退回去 → tryLock(timeout)
场景 10
线程池耗尽 — 所有接口排队等待
场景: 服务还活着,但新请求越来越慢。看日志没有报错,但 CPU 很低,内存正常。接口 RT 从 50ms 涨到 5s。
场景描述: 线程池有 200 个线程,但全被一个慢操作占满了。新来的请求只能排队,越排越长。就像银行5个窗口,全在办一笔1小时的业务,后面排了100个人。
[prod-server ~]# jstack 28456 | grep "http-nio-8080" | wc -l
200
[prod-server ~]# jstack 28456 | grep -A 3 "http-nio-8080" | grep "at "
at java.net.SocketInputStream.socketRead0(Native Method)
at java.net.SocketInputStream.socketRead0(Native Method)
at java.net.SocketInputStream.socketRead0(Native Method)
... (200个线程几乎都在 socketRead0)
200 个线程全部在等网络 IO(socketRead0),说明它们都在调某个外部服务,但那个服务响应很慢。这不是你的代码问题,是下游太慢把你的线程池耗光了。
2
第二步:Arthas trace 追踪到底调了什么
arthas> trace com.order.service.PaymentService pay
+---[4987ms ] com.order.service.PaymentService.callBankApi()
| +---[4985ms ] java.net.HttpURLConnection.getResponseCode()
+---[1ms] com.order.service.PaymentService.savePayment()
3
第三步:修复 — 给外部调用加超时 + 独立线程池
IntelliJ IDEA — 修复后
1 // ✅ 修复1:给外部调用设超时
2 @Bean
3 public RestTemplate restTemplate () {
4 return new RestTemplate (new SimpleClientHttpRequestFactory () {{
5 setConnectTimeout(3000 ); // 连接超时 3秒
6 setReadTimeout(5000 ); // 读取超时 5秒,别一直等!
7 }});
8 }
9
10 // ✅ 修复2:银行调用用独立线程池,不要占 Tomcat 线程
11 @Bean ("bankApiExecutor" )
12 public ThreadPoolTaskExecutor bankApiExecutor () {
13 ThreadPoolTaskExecutor exec = new ThreadPoolTaskExecutor ();
14 exec.setCorePoolSize(10 );
15 exec.setMaxPoolSize(30 );
16 exec.setQueueCapacity(100 );
17 exec.setRejectedExecutionHandler(new CallerRunsPolicy ());
18 return exec;
19 }
✅ 修复总结:
• 给所有外部调用设超时(连接3秒、读取5秒)——不能无限等
• 慢操作用独立线程池,不要占 Tomcat 工作线程——银行慢就银行慢,别拖累其他接口
• 配置拒绝策略——线程池满了怎么办(CallerRunsPolicy = 让调用者线程自己跑)
💭 类比:银行5个窗口
问题:5个窗口都在等一个1小时才能完成的"大客户业务" → 线程池耗尽
修复1:给每个业务设时限,超时就办下一个 → 超时机制
修复2:大客户业务专门开1个窗口处理 → 独立线程池
场景 11
内存缓慢增长 — 没有OOM但越来越慢
场景: 服务刚重启时很快,运行几天后越来越慢。内存没有OOM,但GC暂停时间越来越长。每次重启能恢复几天。
场景描述: 这种"重启治百病"的问题最常见。内存没到OOM线,但 Old 区在慢慢涨,导致 GC 越来越费劲。就像房间不打扫不会立刻住不了人,但垃圾越来越多,打扫时间也越来越长。
1
第一步:jstat 持续观察,看 Old 区是不是在慢慢涨
$ jstat -gcutil 28456 5000
S0 S1 E O M YGC YGCT FGC FGCT GCT
0.00 23.45 67.89 45.23 92.34 234 1.234 2 0.234 1.468
0.00 18.32 71.56 46.12 92.34 236 1.256 2 0.234 1.490
0.00 25.67 54.23 47.89 92.34 239 1.289 2 0.234 1.523
0.00 12.34 82.45 49.56 92.34 241 1.312 2 0.234 1.556
0.00 31.23 45.67 51.23 92.34 244 1.345 2 0.234 1.589
Old 区在持续缓慢增长!每次 Young GC 都有对象"晋升"到老年代(熬过了多次 GC),而且老年代的对象没有被回收。这说明有对象被长期引用着,GC 回收不了。但引用它们的东西不是静态变量(那样会很快 OOM),而是某种缓慢积累的结构。
2
第二步:jmap -histo 看什么对象在堆积
$ jmap -histo 28456 | head -15
num #instances #bytes class name
----------------------------------------------
1: 1256789 201084624 java.util.concurrent.ConcurrentHashMap$Node
2: 987654 94855584 java.lang.String
3: 12345 67237680 com.order.cache.LocalCache$CacheEntry
4: 98765 42155600 byte[]
3
第三步:看 LocalCache 代码 — 没有淘汰策略!
IntelliJ IDEA — LocalCache.java
1 // ❌ 问题代码:本地缓存只管往里放,不管淘汰
2 public class LocalCache {
3 private static final Map <String , CacheEntry > cache =
4 new ConcurrentHashMap <>(); // ← 没有大小限制!
5
6 public static void put (String key, Object value) {
7 cache.put(key, new CacheEntry (value)); // ← 只放不清!
8 }
9 }
找到了!ConcurrentHashMap 没有大小限制,只往里 put 不清除。随着运行时间增长,缓存越来越大,Young GC 回收不了这些对象(因为被 static Map 引用),慢慢全晋升到老年代,GC 就越来越慢。
IntelliJ IDEA — 修复后
1 // ✅ 修复1:用 Caffeine 替代手动 ConcurrentHashMap
2 private static final Cache <String , CacheEntry > cache =
3 Caffeine .newBuilder ()
4 .maximumSize (10000 ) // 最多10000条
5 .expireAfterWrite (30 , TimeUnit .MINUTES) // 30分钟过期
6 .recordStats () // 记录命中率
7 .build ();
8
9 // ✅ 修复2:如果缓存需求简单,用 LinkedHashMap 也可以
10 private static final Map <String , CacheEntry > cache =
11 new LinkedHashMap <>(16 , 0.75f , true ) {
12 @Override
13 protected boolean removeEldestEntry (Map.Entry eldest) {
14 return size() > 10000 ; // 超过10000就淘汰最老的
15 }
16 };
✅ 修复总结:
• 本地缓存必须有淘汰策略:大小上限 + 过期时间
• 推荐用 Caffeine(自动淘汰 + 统计 + 异步刷新),不要手写 ConcurrentHashMap
• 所有 static Map 都要警惕——没有淘汰就是内存泄漏的温床
💭 类比:房间不打扫
不OOM = 房间还没满到住不了人
但越来越慢 = 垃圾越来越多,每次打扫(GC)要绕过垃圾,越来越费时间
重启 = 把房间彻底打扫一遍
修复 = 给房间装个垃圾桶 + 每天定时清理(淘汰策略)
排查工具速查表
11个故事的核心教训
1. OOM-Heap
单例Bean不用实例变量存数据,导出用流式处理
2. OOM-Metaspace
动态代理要缓存,设 -XX:MaxMetaspaceSize
3. GC Overhead
缓存设上限,不用无限增长的 Map
4. StackOverflow
双向引用加 @JsonIgnore,用 DTO 替代实体返回
5. CPU-正则
避免嵌套量词,正则要做回溯测试
6. CPU-死循环
while循环必须有退出条件和等待机制
7. Full GC频繁
减少大对象,调大年轻代,合理设置JVM参数
8. ThreadLocal泄漏
线程池中 ThreadLocal 必须 finally remove()
9. 死锁
统一锁顺序,用 tryLock 超时避免永久等待
10. 线程池耗尽
外部调用设超时,慢操作用独立线程池
11. 内存缓慢增长
本地缓存必须有淘汰策略,用 Caffeine 替代手写 Map
问题类型
排查命令
关键指标
OOM - Heap
jmap -histo → jmap -dump → MAT
对象数量、retained size
OOM - Metaspace
jstat -gc → Arthas classloader
MC/MU 持续增长、代理类数量
GC Overhead
jstat -gcutil → GC日志 → dump
Old区 99%+、Full GC频率
StackOverflow
看异常堆栈 → 代码定位
重复的递归调用栈
CPU 100% - 正则
top -Hp → printf %x → jstack
Pattern.match 在栈顶
CPU 100% - 死循环
top -Hp → jstack
RUNNABLE 线程卡在某方法
Full GC 频繁
jstat -gcutil → jmap -histo
FGC频率、Old区使用率、大对象
ThreadLocal 泄漏
jmap -histo → 查 ThreadLocal$Entry
Entry 实例数远超线程数
死锁
jstack → 找 "Found one Java-level deadlock"
两个线程互相等对方的锁
线程池耗尽
jstack → Arthas trace
所有线程卡在 socketRead0
内存缓慢增长
jstat 看趋势 → jmap -histo
Old区持续缓慢增长
JVM 排查命令速查
$ jmap -histo <pid>
$ jmap -dump:format=b,file=heap.hprof <pid>
$ jstat -gc <pid> 1000 10
$ jstat -gcutil <pid>
$ top -Hp <pid>
$ printf "%x\n" <tid>
$ jstack <pid> | grep -A 20 "<hex-tid>"
$ java -jar arthas-boot.jar
$ classloader -t
$ sc -d *Proxy*
$ watch com.order.Service method params
预防胜于排查:JVM 监控告警配置建议
不要等问题发生了才查。以下指标建议配置告警:
监控指标
告警阈值建议
对应问题
Heap Usage
> 85% 持续 5 分钟
OOM-Heap / GC Overhead
Metaspace Usage
> 80% MaxMetaspaceSize
OOM-Metaspace
Full GC 频率
> 3次/分钟
Full GC 频繁
Full GC 耗时
单次 > 3秒
STW 时间过长
CPU 使用率
> 90% 持续 2 分钟
CPU 100% 各类原因
线程数
> 预期值 2 倍
线程泄漏
Error Rate
> 1% 持续 3 分钟
StackOverflow / OOM
推荐工具:Prometheus + Grafana(指标采集)+ Arthas(在线诊断)+ SkyWalking(链路追踪)
常见问题 FAQ
OOM和内存泄漏有什么区别?怎么快速判断是哪种?
OOM = 内存不够用(堆/元空间真的满了)
特征:报错 java.lang.OutOfMemoryError,进程可能直接崩溃或频繁Full GC后OOM。
内存泄漏 = 内存被占着不放(够用但越来越少)
特征:Old区持续缓慢增长,GC越来越频繁但不报错,最终也会导致OOM。
判断方法: 看jstat -gcutil的Old区使用率趋势。如果每次GC都能回收大部分 → 可能是正常增长;如果GC后Old区只回收一点点、持续上升 → 泄漏。
常见原因: OOM → 堆设太小 / 大对象太多;泄漏 → 静态集合无限增长 / ThreadLocal不remove / 未关闭连接。
CPU飙高到100%,第一步该做什么?
标准排查链:top → top -Hp → printf %x → jstack
第一步:top 找到CPU最高的Java进程PID
第二步:top -Hp PID 找到CPU最高的线程TID
第三步:printf "%x\n" TID 把线程ID转成16进制
第四步:jstack PID | grep "nid=0x十六进制" 定位代码位置
两种典型原因:
• 死循环 → 线程栈停在某个循环里(RUNNABLE状态)
• 正则回溯 → 线程栈停在Pattern.compile或String.match相关调用
Full GC频繁是什么原因?该怎么调优?
Full GC触发的4个条件:
① Old区使用率超过阈值(默认92%)
② Metaspace满了
③ System.gc() 被显式调用
④ 堆分配失败时担保失败
调优方向(按优先级):
1. 先确认是否有大对象直接进老年代 → 减少大对象创建
2. 检查Young区是否太小 → 对象过早晋升老年代 → 加大新生代(-Xmn)
3. 用jstat -gcutil看每次GC的回收率,如果YGC只能回收10%说明 Survivor 区不够
4. 最后才考虑换垃圾收集器(G1/ZGC比CMS更容易调参)
jmap导出dump文件会卡住服务吗?什么时候该用Arthas代替?
是的!jmap -dump 会触发STW(Stop-The-World),堆越大停顿越长。
• 2GB堆 ≈ 停顿几秒到十几秒
• 8GB堆 ≈ 可能停顿30秒+
生产环境慎用!
替代方案:
① jmap -histo PID 只统计对象分布,不停顿(推荐先做这一步)
② jstat -gcutil PID 看各区域使用率趋势
③ Arthas heapstats 在线查看堆信息不停顿
④ 如果必须dump,用 jcmd PID GC.heap_dump file.hprof(比jmap更可靠)
⑤ 低峰期操作,做好告警通知
线程池耗尽和死锁怎么区分?都是"接口卡住"啊
关键区别在于线程的状态不同:
线程池耗尽:
• jstack看到大量线程在 WAITING(parking) 状态
• 线程卡在 ThreadPoolExecutor 的队列等待或任务提交处
• 根因:核心线程太少、任务执行太慢、拒绝策略配置不当
解决:加大corePoolSize / 缩短任务耗时 / 配置合理的拒绝策略
死锁:
• jstack看到多个线程在 BLOCKED 状态
• jstack底部会有 "Found 1 Java-level deadlock" 明确提示
• 根因:两个线程互相持有对方需要的锁
解决:按顺序加锁 / 使用Lock.tryLock(timeout) / 减小锁范围
生产环境排查有没有什么通用原则或经验总结?
排查四原则:
① 先看症状,再定方向 — OOM/CPU/卡住/慢,每种有对应的排查链路,别瞎试
② 从轻量到重 — 先jstat/top(几乎无影响),再jstack/jmap-histo(影响小),最后dump+MAT(STW)
③ 保留现场 — 发现问题第一时间保存日志和线程栈,重启后就没了
④ 一次只改一个参数 — JVM调参不要同时改多个,否则不知道哪个生效了
最常犯的错:
• 看到OOM就加内存 → 可能只是掩盖了泄漏问题
• 看到 CPU 高就扩容 → 可能是死循环或正则问题,加机器没用
• 不看日志直接重启 → 现场丢失,下次复现困难