说实话,搞Java后台开发这几年,线上故障排查算是每个工程师都躲不开的“成人礼”。平时写代码再风光,一遇到CPU飙高、内存溢出、接口超时,整个人的肾上腺素就直接拉满。这个Java线上故障学习笔记,我把这几年踩过的坑、用过的命令、总结出的排查路径整理成一套可以直接照着做的清单。不管是刚转正的初级开发,还是被线上问题折磨过几次的资深工程师,只要你写过Java服务,这套思路就能帮你从“对着监控一脸懵”变成“按步骤一步步缩小范围”,最后直接把根因揪出来。
1. 线上故障排查的总体思路与准备
1.1 先救人,再破案:故障响应的优先级
我很早就被老组长灌输过一个理念:线上出故障,第一步不是打开各种工具拼命分析,而是先判断要不要立即恢复。这里有个经典的三步优先级:
- 恢复优先:如果故障影响面大(比如核心接口大面积超时、支付链路异常),先考虑重启服务、切流到备用节点、降级非核心功能、限流保护下游。哪怕事后查不到原因,也要先让业务恢复。
- 留证其次:在重启或kill进程之前,如果条件允许,先截图、保存日志、把堆栈和堆dump导出来。很多问题一旦重启就再也复现不了,现场没了,后面就只能靠猜。
- 定位最后:等业务平稳了,再来复盘日志和堆栈,找到根因,出修复方案。
这个顺序反了就会出大问题。我见过有人上来就jstack、jmap一通操作,结果命令还没跑完,应用已经被系统OOM Kill了,最后什么都没留下。我的习惯是:一旦发现服务不可用,先看是不是有“一键恢复”的开关(如动态配置降级、临时扩容),能恢复就立刻恢复,同时后台尽量异步保留现场。
1.2 工具准备:JDK自带命令加上真香神器Arthas
排查Java线上问题,工具链其实很固定。先说最基础的JDK自带命令:
| 命令 | 作用 | 常用场景 |
|---|---|---|
| jps | 查看Java进程PID | 找目标进程 |
| jstack | 打印线程快照 | 看线程状态、死锁、卡点 |
| jmap | 堆信息与dump | 看堆内存、导出堆文件 |
| jstat | JVM统计信息 | 看GC频率、GC耗时、类加载 |
| jcmd | 综合诊断命令 | 替代部分jmap/jstack功能 |
我个人强烈建议在所有Java服务上提前部署Arthas(阿里开源的Java诊断工具)。它最爽的一点是能做到运行时观测而不改代码:线上代码出了诡异问题,你不需要重新发版加日志,直接通过Arthas的watch、trace、stack命令去看方法入参、返回值、调用耗时。后面正文里的案例,很多我用Arthas代替了传统命令,效率高出一大截。
1.3 关键参数提前配好:线上一定要开这些JVM参数
这个是真的“血的教训换来的”。新接手的服务如果没开下面这些参数,出问题的时候大概率抓瞎:
-Xms2g -Xmx2g # 堆大小,建议初始和最大一致 -XX:+HeapDumpOnOutOfMemoryError # OOM时自动导出堆dump -XX:HeapDumpPath=/data/logs/heap.hprof # dump文件路径 -XX:+PrintGCDetails # 打印GC详细日志 -XX:+PrintGCDateStamps # GC日志加时间戳 -Xloggc:/data/logs/gc.log # GC日志独立文件其中HeapDumpOnOutOfMemoryError尤其重要。没有这个参数,OOM发生后进程还在(或直接挂掉),但你拿不到那一瞬间的内存快照,只能靠猜。而有了dump文件,配合MAT(Memory Analyzer)就能直接看到底是什么对象把堆撑爆的。GC日志同理,没有它,排查Full GC问题就少了一只眼睛。
2. CPU飙高的定位与处理
2.1 完整排查链路:从top到jstack的六步走
线上最常见的告警就是CPU使用率超过阈值。说实话,CPU高本身不可怕,可怕的是CPU高导致接口RT飙升、线程池任务积压、服务雪崩。我总结了一套固定的六步排查链路,照着做就能定位到具体代码行:
top找到CPU占用最高的Java进程PID(一般就是那个100%甚至几百%的)。top -Hp <PID>查看该进程内哪个线程占CPU最高,记录下线程号(比如4567)。- 把线程号转成16进制:
printf "%x\n" 4567,得到11d7。 jstack <PID> > stack.txt导出线程快照。- 在
stack.txt里搜索0x11d7(注意nid字段),找到对应线程。 - 往下看该线程的栈,卡在哪个类、哪个方法、让哪一行代码“背锅”。
很多教程止步于此,但我想补充一个经验:光看一个线程栈不够,jstack要连续采样3~5次(每次间隔几秒),如果每次都看到同一个线程卡在同一个方法上,那大概率就是这里的问题;如果每次线程都不一样,很可能是GC线程导致CPU高,要结合jstat看GC情况,或者是有大量线程在频繁争抢锁。
2.2 案例复盘:Java 7的HashMap死循环
说一个经典中的经典,我在老项目里真实遇到过一次。某个接口在做并发写入时,CPU直接飙到接近300%,服务卡死。按照上面的步骤定位,发现线程栈卡在HashMap.put()的内部方法上。当时项目用的还是Java 7,HashMap在多线程扩容时会形成环形链表,get操作陷入死循环,CPU直接被打满。
这个案例说明两件事:第一,Java 7的HashMap并发场景就是不能碰,现在换Java 8+之后,在并发下虽然不会死循环,但还会数据丢失,所以并发容器老老实实用ConcurrentHashMap;第二,定位CPU问题的核心手段始终是“top找进程,-Hp找线程,jstack找代码”,这套链路不变。
2.3 正则灾难性回溯:不容易想到的CPU杀手
还有一个我特别喜欢拿出来说的案例:某个字符串校验的功能在特定数据下CPU飙升,定位到java.util.regex.Pattern的匹配逻辑上。这类问题的本质是正则表达式里的嵌套量词导致回溯指数级增长,比如(a+)+这种写法遇到长串不匹配的a...b时,匹配引擎会疯狂尝试所有可能的划分,CPU直接被吃干。
解决思路也很明确:
- 正则里避免嵌套量词(
(a+)+、(a|a)*这类),用原子组(?>...)或占有量词a*+消除回溯。 - 线上业务的正则尽量前置编译为
Pattern,不要每次匹配都编译一次。 - 实在不行就直接换简单字符串匹配/前缀匹配,性能远高于正则。
这类排查给我的最大启示是:CPU飙高不等于代码里有死循环,也可能是一个看起来不起眼的库函数调用。所以看到线程栈卡在某个JDK类库方法时,别急着认定是“JDK的bug”,先想想自己的调用方式是不是有坑。
3. 内存溢出与GC问题排查
3.1 OOM的几种类型与对应信号
内存问题是线上故障里最考验功力的类型。首先得能分清几种OOM错误的含义:
| 错误类型 | 含义 | 最可能的原因 |
|---|---|---|
| Java heap space | 堆内存满 | 对象太多、内存泄漏、堆太小 |
| Metaspace | 元空间满 | 动态生成类太多、CGlib代理过载 |
| StackOverflowError | 栈溢出 | 递归过深、无限递归 |
| GC overhead limit exceeded | GC接近失效 | 堆太小或对象疯狂增长 |
| Out of memory: Kill process | 被OS杀死 | 容器内存超限、swap不足 |
每种错误的排查路径不太一样,但通用的第一步都是拿到OOM时刻的内存现场,也就是依赖前面提到的-XX:+HeapDumpOnOutOfMemoryError自动导出的.hprof文件。
3.2 堆dump分析实战:MAT怎么看泄漏
拿到dump文件后,我用的是Eclipse MAT(Memory Analyzer)。打开之后不要急着乱点,按这两个地方看基本就够了:
- Leak Suspects(泄漏嫌疑):MAT自动分析报告,直接告诉你哪个对象持有大量内存。
- Dominator Tree(支配树):按对象保留大小排序,看谁“占着内存不放手”。
- Histogram(直方图):按类统计实例数量与占用大小,排查是不是某个VO/DTO对象被异常创建了大量实例。
我遇到的一个典型例子:某导出Excel的接口在深夜定时任务触发时OOM。从dump里看到大量的Workbook对象和中间结果的String对象堆积。顺着引用链查下去,发现是每个Sheet都在内存里构建全量数据模型,数据量大后就炸了。修复方案也不复杂:改为流式导出(SXSSFWorkbook),控制单Sheet行数,分批写临时文件。
这里分享一个排查技巧:在MAT中对比两个dump文件。如果服务还能撑住,先导一个正常时段的dump;等内存又涨上去后再导一个。对比两个dump中对象的数量差异,就能很清楚地看出哪些对象在持续增长,这些对象就是泄漏点。
3.3 Full GC频繁的定位思路
有的故障不是干脆利落OOM,而是CPU不高不低、接口越来越慢、Full GC频次越来越高,最后才一次性OOM。这种渐进式问题比突发OOM更难搞,我的排查顺序是:
jstat -gcutil <PID> 1000 10,每隔1秒打一次GC信息,看FGC和FGCT的增长速度。- 如果
FGC涨得快,记住老年代O这一列的占比。占比持续涨到90%以上,说明对象进入了老年代但回收不掉。 - 用
jmap -dump:live,format=b,file=heap.bin <PID>导出当前存活对象堆,注意这个命令会触发一次Full GC,高峰期慎用。 - MAT分析,核心是看“老年代里到底是谁占着”。
有一类经常遇到的隐藏问题:大对象直接进老年代。有些接口一次性查询上万条数据,每条都封装成大List或大数组,这些对象超过-XX:PretenureSizeThreshold(默认是0,表示不启用)会直接在老年代分配,老年代很快被打满。这类问题光靠jstack是看不出来的,必须看堆里的大对象分布。
4. 线程问题排查:阻塞、死锁与资源耗尽
4.1 线程池耗尽:接口“假死”的典型场景
线上一个很常见的现象是:某个接口偶尔超时,一开始频率很低,后来频率越来越高,最后整个应用“卡死”,新请求连队列都进不去。这时候jstack看线程池里的线程状态,能看到大量线程处于WAITING或TIMED_WAITING状态,线程池的队列积压了几万条任务。
这种故障的根因通常不是线程池本身,而是线程里的任务在等某个外部资源。最常见的三大外部资源坑位:数据库连接池被耗尽、HTTP连接池被耗尽、分布式锁等待超时。
我处理过一个真实案例:一个批量导入接口在外部系统响应变慢后,所有工作线程都卡在RestTemplate.exchange()上等待响应,HTTP连接池被占满。后续请求全部排队,连接池里的连接一直没有释放,最终整个服务的Tomcat线程池也占满了。排查时jstack里一眼就看到所有线程都停在HttpClient的getConnection()方法上,而那个方法在等待空闲连接。
修复方案分了两步:第一步紧急加大连接池并给HTTP调用加超时;第二步(根本解法)把外部接口调用改为异步化,加熔断和降级。这里我非常想强调:所有外部依赖调用必须设置超时时间。没有超时的调用,在依赖故障时就是一颗定时炸弹。
4.2 死锁:jstack一眼识破
死锁算是线程排查里最简单直接的一类了,因为jstack自带死锁检测功能,输出末尾如果有Found one Java-level deadlock这段信息,那就是死锁了。死锁出现时,相关线程状态为BLOCKED,等待的锁互相持有。
代码层面死锁我们平时写业务时会很注意,但容易忽略的是锁的嵌套顺序问题。比如线程A持有了锁1去拿锁2,线程B持有了锁2去拿锁1,只要交叉发生就会死锁。我的经验是:
- 尽量用
tryLock加超时,拿不到锁就退出重试,不要死等。 - 加锁顺序全局统一,比如先锁ID小的再锁ID大的。
- 减少锁粒度,能用无锁数据结构(如
LongAdder、ConcurrentHashMap)就别用全局锁。
4.3 线程状态速查:看一眼就知道问题倾向
jstack输出里线程状态是定位问题的第一手线索,我把常见的状态和对应的问题倾向整理成了速查表:
| 线程状态 | 可能的问题 | 下一步动作 |
|---|---|---|
| RUNNABLE | 正常运行,也可能CPU占用高 | 看栈顶方法,判断是业务逻辑还是GC |
| BLOCKED | 在等锁,可能锁竞争严重或死锁 | 看等待的锁,找锁持有者 |
| WAITING | 无限期等待,可能在等队列/条件 | 看park或wait的位置 |
| TIMED_WAITING | 带超时等待,常见于sleep、await | 看等什么资源,判断是否合理 |
| TERMINATED | 线程已结束 | 排查是否有任务异常中断 |
结合前面说的,线程问题排查最核心的一句话:线程栈反映了代码当前卡在哪,但要结合整体资源(连接池、队列长度、CPU、GC)才能推断出为什么卡在那。
5. 线上故障排查高频场景与避坑清单
5.1 常用排查命令速查表
这里把全文涉及的命令整理成一份可直接保存的速查表,线上紧急时照着执行就好:
| 目的 | 命令 |
|---|---|
| 找Java进程 | jps -l |
| 看线程CPU占用 | top -Hp <PID> |
| 打印线程栈 | jstack <PID> > jstack.txt |
| 看GC情况 | jstat -gcutil <PID> 1000 10 |
| 看堆内存概况 | jmap -heap <PID> |
| 导出堆dump | jmap -dump:format=b,file=heap.bin <PID> |
| 生产临时观测 | arthas attach <PID> |
| 查看JVM参数 | jinfo -flags <PID> |
| 查看对象统计 | `jmap -histo:live |
5.2 我的独家避坑经验
做线上排查多了,积累了几条说来容易、做起来全是泪的经验,专门列出来:
- jmap -dump和jmap -histo:live会触发Full GC,意味着服务会暂停相当一段时间。线上高峰期慎用,能错峰就错峰,实在要用先评估影响面。
- 堆dump文件可能非常大(几个GB),导出前确认磁盘空间足够,不然dump到一半磁盘满了,故障变双杀。
- jstack输出要保存到文件再分析,直接打印到终端输出会被截断,且生产环境终端缓冲区可能不够。
- 多采集几次再下结论,单次jstack只能看到某一瞬间的状态,偶发性问题需要连续采样。
- 配置好告警和监控是排查的地基。没有历史监控曲线,很多问题只能靠猜。至少要把CPU、堆内存、GC次数、GC耗时、线程池活跃数、Tomcat线程数这些核心指标配上告警。
5.3 一个高性价比的“钝感力”手段:Arthas线上观测
最后单独说下Arthas,因为它改变了我的排查方式。以前看线上某个方法执行慢,只能猜或改代码加日志,然后重新发版。现在用Arthas可以直接观察:
watch com.example.service.UserService getUser '{params, returnObj, throwExp}':查看方法入参和返回。trace com.example.service.UserService getUser:查看方法内部每步的耗时。dashboard:实时查看线程、内存、GC状态。thread -n 3:直接列出CPU占用最高的3个线程,省去top和jstack的转换过程。
我遇到最惊艳的一次是用trace直接看到了一个接口80%的时间花在一个UUID.randomUUID()上(因为项目里用了带纳秒锁的ID生成器,并发高时性能很差)。这种问题不跑线上观测,完全没法从代码审查里看出来。
6. 最后分享一点个人体会
排查线上故障这事,说实话没有太多捷径可走,但有一个心法我觉得特别受用:每次故障都是一次学习机会,别浪费了。我会把每一次线上问题的现象、排查日志、根因、修复方案整理成一篇笔记存档,到了年底回头一看,最初踩过的那些坑基本都不会再踩第二次了。
这也是我为什么把这篇笔记写得这么细。它不只是一份命令清单,更是一个完整的排查心智模型:遇到问题先恢复服务、保住现场,然后从CPU、内存、线程三个维度切入,一层层缩小范围,直到定位到具体的类和代码行。这个过程经历过几次,后面再遇到类似问题,自己心里就有底了。
如果你刚接触Java线上服务,可以先把我前面整理的避坑清单和命令速查表保存在手边,遇到问题时照着做一遍。等跑通一两个真实案例后,再回头看这些内容,你会发现每个步骤背后都有它存在的道理。祝顺利。