跳转至

Java 接口卡死(线程死锁)

01 前置理论与核心机制

1. 为什么“CPU 不高”反而接口全线卡死?

  • 阻塞不耗 CPU:CPU 飙升通常是因为死循环或繁重计算,此时线程处于 RUNNABLE 状态;而当接口卡死、CPU 水位极低(甚至接近 0%)时,说明处理请求的线程没有在干活,而是全部陷入了 BLOCKED(等待监视器锁)或 WAITING(等待资源释放)状态。线程被挂起后交出了 CPU 调度权,导致系统空转但业务瘫痪。
  • 死锁的四个必要条件:互斥条件、请求与保持、不剥夺条件、循环等待。在生产中,90% 以上的死锁都是因为业务代码中多个线程获取多把锁的顺序不一致引起的。

2. JVM 原生死锁检测机制

  • HotSpot 内部机制:JVM 自带死锁探测算法。当通过 jstack 发起 Thread Dump 时,JVM 会遍历当前所有的线程与锁分配图(Resource Allocation Graph),如果图中存在闭环,JVM 会在导出的快照尾部直接打印 Found one Java-level deadlock。
  • 排查最短路径:无需像高 CPU 问题那样逐个排查线程或做十六进制转换,只需一把拉出 jstack 截取末尾报告,即可瞬间锁定死锁的相互依赖环及代码行号。

02 故障排查与恢复闭环 SOP

遇到接口大面积无响应、连接超时但 CPU 正常的故障,严格按照以下步骤操作:

步骤一:确认现象与获取进程号

先通过监控确认 CPU 低且系统无 IO 瓶颈,紧接着在容器或宿主机中获取 Java 目标进程号:

# 列出当前运行的 Java 进程与主类名
jps -l
  • 观测点:找到对应的业务服务名称,记下最左侧的进程号 (例如示例中的 16312)。

步骤二:导出线程快照现场

在做任何重启动作之前,第一时间将当前 JVM 的线程栈全量导出固化为文件:

# 导出当前 JVM 线程堆栈至临时文件
jstack  > /tmp/jstack.log
  • 注意:如果是在 K8S Pod 内部执行,可通过 kubectl exec -n -- jstack > jstack.log 导出至本地。

步骤三:直接扫描文件末尾死锁报告

不需要从第一行开始人工翻看成百上千个线程,直接提取文件末尾的死锁分析块:

# 截取快照末尾最后 30-50 行
tail -n 50 /tmp/jstack.log

  • 核心证据链闭环:
  • 确认特征字符串:Found one Java-level deadlock:。
  • 观察互相死锁的调用链:
  • Thread-1:持有锁 <0x000000076b123456>,正在等待锁 <0x000000076b654321>,代码停留在 OrderService.java:17;
  • Thread-2:持有锁 <0x000000076b654321>,正在等待锁 <0x000000076b123456>,代码停留在 PayService.java:24。
  • 此时直接确认:两个线程以相反顺序尝试获取这两把锁,形成了锁等待闭环。

步骤四:紧急止血与业务恢复规范

  • 临时恢复:纯死锁在 JVM 内部不可自愈。只要陷入死锁,该死锁线程将永久占用资源,后续进来的请求如果是同步获取同一把锁,也会依次阻塞,导致连接池打满。现场取证完成后,必须重启 Pod 临时恢复业务可用性:
kubectl delete pod  -n
  • 彻底根治:将提取到的 jstack.log 以及指向的真实源码行号交由研发,统一锁获取顺序,或引入带超时的锁机制(如 ReentrantLock.tryLock())。

03 真实生产故障现象与案例库

案例 1:转账/订单交叉锁顺序不一致

  • 监控现象:核心交易服务突然报警,部分结算接口全部超时,Tomcat/Undertow 线程池瞬间被占满,但宿主机 CPU 使用率不足 10%。
  • 排查结果:jstack 末尾明确报告 Java-level deadlock。线程 A 在执行 accountA.transfer(accountB) 先锁 A 再锁 B;同时线程 B 在执行 accountB.transfer(accountA) 先锁 B 再锁 A,造成经典交叉死锁。
  • 根因与处置:代码中未统一资源加锁顺序。处置方案为研发在加锁前根据对象唯一标识(如 AccountId)按固定字典序排序加锁,从逻辑上消除循环等待。

案例 2:数据库连接池泄漏引发的“伪死锁”

  • 监控现象:服务所有需要查库的接口卡死,CPU 使用率为 0%,jstack 扫描未见 Java-level deadlock。
  • 排查结果:在 jstack.log 中搜索 WAITING 状态,发现几乎所有工作线程都在 HikariPool.getConnection() 上等待。
  • 根因与处置:某些长事务未提交,或者大批量慢 SQL 耗尽了连接池可用连接,导致后续所有业务线程被挂起阻塞。处置方案为排查慢 SQL 并强制增加数据库连接池获取超时与最大生命周期限制。

案例 3:嵌套 synchronized 隐式类加载锁

  • 监控现象:应用启动或热加载某些插件时,服务瞬间僵死,既不报错也不就绪。
  • 排查结果:jstack 报告两个业务自定义的 ClassLoader 相互持有监视器锁并等待对方。
  • 根因与处置:双亲委派机制被破坏,或者在自定义类加载器中使用了跨对象的同步块。处置方案为改为单向加载机制并注册为并行类加载器。

04 生产排障速查表(接口卡死与死锁)

遇到接口卡死但 CPU 极低时,请按序单向排查:顺序排查层级执行命令 / 观测点异常基线与定性处置动作01全局观察top / 监控仪表盘CPU 使用率极低(< 10%),但请求超时排除高负载,定性为线程挂起/阻塞02锁定进程jps -l确认服务进程存活未崩溃记下业务进程的 <pid>03导出快照jstack <pid> > /tmp/jstack.log完整导出时刻现场保护现场,避免盲目重启04死锁直检tail -n 50 /tmp/jstack.log命中 Java-level deadlock提取互相持有的锁与代码行号05状态检索grep "java.lang.Thread.State" dump发现大量线程处于 BLOCKED寻找排在最前端持有锁的源头线程06应急恢复保留快照后重启 Pod依赖死锁不可自愈重启恢复业务,通知研发按行排查锁顺序

05 面试应答参考话术

面试官问:“如果线上 Java 接口突然卡死、调用全部超时,但登录机器一看 CPU 却一点都不高,你平时怎么排查?”

可以这样回答:遇到这种接口卡死但 CPU 很低的现象,不能急着重启,重启了现场就没了,也不要去查什么死循环。CPU 不高却卡死,本质上就是线程都停下手里的活在等资源,重点要怀疑线程阻塞或者死锁。第一步:保住现场并拿到进程

我会先在容器或者机器上敲 jps -l 拿到这个 Java 服务的 PID。接着就是用 jstack <pid> > jstack.log 把当前的线程堆栈快照导出来,先把现场证据留下来。第二步:直接看末尾抓死锁

拿到这个日志文件,不需要从头开始翻几百个线程。JVM 自身带了死锁探测算法,只要有死锁,快照的最末尾一定会明确打印 Found one Java-level deadlock。 直接执行 tail -n 30 看最后几行。如果有死锁,日志里会清清楚楚写着:线程 1 拿着锁 A 在等锁 B,代码在第几行;线程 2 拿着锁 B 在等锁 A,代码又在第几行。到这里死锁的两个线程、两把锁和具体的代码行号就全都齐了。第三步:应急与根因处置

因为 Java 原生死锁是不可自愈的,确认拿到了死锁证据后,为了快速恢复业务,我会第一时间重启或者重建这个 Pod 临时止血。事后把导出的快照和具体的代码行给研发。这类问题代码层面的根因通常是多个线程获取锁的顺序不一致,让研发把加锁顺序排个序,或者改用带超时的 tryLock,彻底从源头规避。

06 面试高频追问与应答预案

追问 1:如果 jstack 末尾没有发现死锁,接口还是卡死,你接下来怎么查?

  • 回答要点:
  • 死锁只是其中一种可能。若未报告死锁,需统计分析所有线程的状态:grep "java.lang.Thread.State" jstack.log | sort | uniq -c。
  • 关注 WAITING 状态:如果几百个线程全在 WAITING (parking) 并且调用栈指向数据库连接池(如 Druid / Hikari),说明是连接池被打满或连接泄漏;
  • 关注 TIMED_WAITING 状态:如果全停留在 HTTP/RPC 客户端调用上(如 Feign、HttpClient),说明下游依赖方响应极慢或熔断未开启,导致上游请求全部积压。

追问 2:为什么死锁发生时,CPU 占用率通常非常低?

  • 回答要点:
  • 因为当线程尝试获取一个已被占用的监视器锁(synchronized)时,JVM 会调用底层的操作系统原语将该线程挂起,进入睡眠(Wait-Queue)状态,线程让出 CPU 执行时间片。
  • 陷入死锁的线程不再参与系统的 CPU 调度循环,因此哪怕几百个工作线程全都死锁卡住,整个进程的 CPU 使用率也会呈现极低的状态。

07 一键死锁与线程阻塞巡检脚本(check_java_deadlock.sh)

该脚本用于只读快速扫描指定 Java 进程是否存在死锁,并一键统计当前线程池的状态分布。

#!/usr/bin/env bash
# ==============================================================================
# 脚本名称: check_java_deadlock.sh
# 功能描述: 自动化 Java 死锁探测与线程池状态全景分析 (纯只读)
# 使用方式: 在运行 Java 的宿主机/容器内以 root 权限执行
# ==============================================================================

set -o pipefail

RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
BLUE='\033[0;34m'
PLAIN='\033[0m'

echo -e "${BLUE}================================================================${PLAIN}"
echo -e "${BLUE}              Java 线程阻塞与死锁自动化探测                     ${PLAIN}"
echo -e "${BLUE}================================================================${PLAIN}"

# 1. 获取 Java 进程号
if command -v jps &>/dev/null; then
    JAVA_PID=$(jps -l | grep -v "sun.tools.jps" | head -n 1 | awk '{print $1}')
else
    JAVA_PID=$(ps -ef | grep -i java | grep -v grep | head -n 1 | awk '{print $2}')
fi

if [ -z "$JAVA_PID" ]; then
    echo -e "${RED}[ERROR] 未探测到正在运行的 Java 进程。${PLAIN}"
    exit 1
fi

echo -e "${GREEN}[INFO] 锁定目标 Java 进程 PID: ${JAVA_PID}${PLAIN}"

# 2. 检查 jstack 工具
if ! command -v jstack &>/dev/null; then
    echo -e "${RED}[ERROR] 当前环境缺少 jstack 工具,无法执行堆栈提取!${PLAIN}"
    exit 1
fi

# 3. 抓取堆栈快照
DUMP_FILE="/tmp/jstack_deadlock_check_${JAVA_PID}.log"
echo -e "\n${YELLOW}[1/3] 正在导出线程快照至 ${DUMP_FILE}...${PLAIN}"
jstack "$JAVA_PID" > "$DUMP_FILE"

# 4. 自动匹配是否存在死锁
echo -e "\n${YELLOW}[2/3] 正在分析 JVM 死锁报告...${PLAIN}"
DEADLOCK_LINE=$(grep -n "Found one Java-level deadlock" "$DUMP_FILE" | cut -d: -f1)

if [ -n "$DEADLOCK_LINE" ]; then
    echo -e "${RED}!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!${PLAIN}"
    echo -e "${RED}[ALERT] 实锤命中 Java 死锁!详细报告如下:${PLAIN}"
    echo -e "${RED}!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!${PLAIN}"
    tail -n +"$DEADLOCK_LINE" "$DUMP_FILE"
else
    echo -e "${GREEN}[OK] 未发现 JVM 原生 Java-level deadlock 死锁。${PLAIN}"
fi

# 5. 统计线程状态分布
echo -e "\n${YELLOW}[3/3] 统计当前线程池状态分布...${PLAIN}"
echo -e "${BLUE}  数量    状态名${PLAIN}"
grep "java.lang.Thread.State:" "$DUMP_FILE" | awk '{print $2}' | sort | uniq -c | sort -nr

echo -e "\n${BLUE}================================================================${PLAIN}"
echo -e "${BLUE}巡检完毕。若无死锁但 WAITING 偏高,请重点排查数据库连接池与下游依赖。${PLAIN}"
echo -e "${BLUE}================================================================${PLAIN}"