Java 并发 BUG 排查实战——死锁、活锁、饥饿问题的诊断与解决
Java 并发 BUG 排查实战——死锁、活锁、饥饿问题的诊断与解决
适读人群:Java后端开发,在生产环境遇到过或担心遇到并发BUG的工程师 | 阅读时长:约18分钟 | 核心价值:掌握死锁/活锁/饥饿的诊断工具链,能在线上快速定位并给出解决方案
凌晨2点的生产事故
那是2022年8月的一个周四凌晨2点多,我的手机被报警电话打醒。值班的运维同学说:用户中心服务完全失去响应,所有请求超时,已经持续了15分钟。
我睡眼朦胧地打开电脑,首先看监控:CPU 5%,内存正常,GC正常,就是没有任何请求在处理。JVM还活着,只是像被什么东西卡住了一样。
我的第一直觉是死锁。
用jstack导出线程栈,果然找到了:
Found one Java-level deadlock:
=============================
"thread-pool-1":
waiting to lock monitor 0x00007f8b8c014a28 (object 0x00000000e1234567, a com.example.UserService),
which is held by "thread-pool-2"
"thread-pool-2":
waiting to lock monitor 0x00007f8b8c014b40 (object 0x00000000e1234568, a com.example.RoleService),
which is held by "thread-pool-1"两个线程互相持有对方需要的锁,形成经典的死锁。那天凌晨3点多,我们完成了紧急修复。
今天把这三类并发活性问题(死锁、活锁、饥饿)的诊断和解决方法,完整地讲一遍。
死锁(Deadlock)
死锁的四个必要条件
死锁的形成需要同时满足:
- 互斥:资源一次只能被一个线程使用
- 请求与保持:线程持有至少一个资源,同时等待获取其他线程持有的资源
- 不可剥夺:线程已获得的资源,在使用完之前不能被强制剥夺
- 循环等待:线程之间形成环形等待链
只要破坏其中一个条件,死锁就不会发生。
死锁诊断工具链
# 方法1:jstack(最常用)
jstack <pid>
# 输出中搜索 "Found one Java-level deadlock"
# 方法2:jconsole(图形化)
jconsole
# 连接后点击"线程"→"检测死锁"
# 方法3:Arthas(线上诊断利器)
thread -b # 检测死锁,显示阻塞链
thread --state BLOCKED # 查看所有BLOCKED状态线程
# 方法4:VisualVM(功能更全面)死锁复现与解决代码
/**
* 经典死锁场景:两个线程以相反的顺序获取两把锁
*/
public class DeadlockDemo {
private final Object lockA = new Object();
private final Object lockB = new Object();
// 死锁:线程1先拿A再拿B
public void method1() {
synchronized (lockA) {
System.out.println(Thread.currentThread().getName() + " 持有 lockA,等待 lockB");
try { Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
synchronized (lockB) {
System.out.println(Thread.currentThread().getName() + " 同时持有 lockA 和 lockB");
}
}
}
// 死锁:线程2先拿B再拿A
public void method2() {
synchronized (lockB) { // 获取顺序和method1相反!
System.out.println(Thread.currentThread().getName() + " 持有 lockB,等待 lockA");
try { Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
synchronized (lockA) {
System.out.println(Thread.currentThread().getName() + " 同时持有 lockA 和 lockB");
}
}
}
// ========== 解法1:固定锁的获取顺序 ==========
public void safeMethod1() {
// 永远先拿 lockA,再拿 lockB
synchronized (lockA) {
synchronized (lockB) {
doWork();
}
}
}
public void safeMethod2() {
// 和 safeMethod1 相同的锁顺序
synchronized (lockA) {
synchronized (lockB) {
doWork();
}
}
}
// ========== 解法2:用 tryLock 超时,破坏"请求与保持" ==========
private final ReentrantLock rlockA = new ReentrantLock();
private final ReentrantLock rlockB = new ReentrantLock();
public boolean safeMethod3() throws InterruptedException {
boolean acquiredA = false, acquiredB = false;
try {
acquiredA = rlockA.tryLock(100, TimeUnit.MILLISECONDS);
if (!acquiredA) return false; // 拿不到直接放弃
acquiredB = rlockB.tryLock(100, TimeUnit.MILLISECONDS);
if (!acquiredB) return false; // 拿不到直接放弃
doWork();
return true;
} finally {
if (acquiredB) rlockB.unlock();
if (acquiredA) rlockA.unlock();
}
}
// ========== 解法3:给锁排序,动态确定获取顺序 ==========
/**
* 当需要同时获取多个对象的锁时,按对象的hashCode排序获取
* 保证所有线程获取锁的顺序一致,消除循环等待
*/
public void transferMoney(Object accountA, Object accountB, double amount) {
Object firstLock, secondLock;
int hashA = System.identityHashCode(accountA);
int hashB = System.identityHashCode(accountB);
if (hashA < hashB) {
firstLock = accountA;
secondLock = accountB;
} else if (hashA > hashB) {
firstLock = accountB;
secondLock = accountA;
} else {
// 极少情况hashCode相同,需要第三把锁作为平局锁
synchronized (TieLock.INSTANCE) {
synchronized (accountA) {
synchronized (accountB) {
doTransfer(accountA, accountB, amount);
}
}
}
return;
}
synchronized (firstLock) {
synchronized (secondLock) {
doTransfer(accountA, accountB, amount);
}
}
}
enum TieLock { INSTANCE }
private void doWork() {}
private void doTransfer(Object a, Object b, double amount) {}
}活锁(Livelock)
活锁比死锁更难诊断:线程没有阻塞,一直在运行,但什么事也做不成。
活锁的经典场景
想象两个人在走廊相遇,双方都礼貌地往同一个方向让路,然后又同时往另一个方向让路,一直重复,谁也走不过去——这就是活锁。
/**
* 活锁演示:两个线程互相谦让,永远无法获取对方的资源
*/
public class LivelockDemo {
static class Worker {
private final String name;
private boolean active = true;
Worker(String name) { this.name = name; }
public void work(Worker other, Object resource) {
while (active) {
// 如果其他工作者也是active,我就让步
if (other.active) {
System.out.println(name + " 礼让,等待...");
try { Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
continue; // 继续循环,但两个工作者都在等对方先行
}
// 到这里说明 other 不active了,我可以工作
System.out.println(name + " 开始工作");
active = false;
}
}
}
// ========== 解法:引入随机退让,破坏对称性 ==========
public boolean tryAcquire(ReentrantLock lock1, ReentrantLock lock2)
throws InterruptedException {
while (true) {
boolean got1 = lock1.tryLock();
boolean got2 = lock2.tryLock();
if (got1 && got2) return true; // 成功获取两个锁
// 获取失败,释放已持有的锁
if (got1) lock1.unlock();
if (got2) lock2.unlock();
// 随机等待一段时间再重试(破坏对称性,避免永远同步)
long backoff = (long)(Math.random() * 100);
Thread.sleep(backoff);
}
}
}如何诊断活锁
活锁的特征:CPU使用率高(线程在忙等),但业务没有进展。
# 用 jstack 看线程状态
# 活锁的线程状态是 RUNNABLE,不是 BLOCKED
# 需要多次采样,看线程是否一直在同一段代码上反复执行
jstack <pid> | grep -A 3 "thread-name"
# 如果多次采样都看到同一个线程在同一行代码,可能是活锁线程饥饿(Starvation)
饥饿是指某个线程长期得不到CPU时间片,无法执行。
饥饿的常见原因
- 优先级设置不当:低优先级线程被高优先级线程长期排挤
- 非公平锁下的倒霉线程:某个线程总是在CAS竞争中失败
- 同步方法过长:持有锁的线程执行时间太长,其他等待者被"饿着"
/**
* 饥饿演示:大量高优先级线程让低优先级线程长期得不到执行
*/
public class StarvationDemo {
private static final Object lock = new Object();
// 演示优先级饥饿
public static void demonstratePriorityStarvation() throws InterruptedException {
// 10个高优先级线程
for (int i = 0; i < 10; i++) {
Thread highPriority = new Thread(() -> {
while (true) {
synchronized (lock) {
// 持有锁做一些工作
try { Thread.sleep(10); } catch (InterruptedException e) { return; }
}
}
});
highPriority.setPriority(Thread.MAX_PRIORITY);
highPriority.setDaemon(true);
highPriority.start();
}
// 1个低优先级线程,可能长期得不到执行
Thread lowPriority = new Thread(() -> {
long startTime = System.currentTimeMillis();
synchronized (lock) {
System.out.println("低优先级线程终于执行了,等待了 " +
(System.currentTimeMillis() - startTime) + "ms");
}
});
lowPriority.setPriority(Thread.MIN_PRIORITY);
lowPriority.start();
lowPriority.join(10000);
}
// ========== 解法:使用公平锁 ==========
private static final ReentrantLock fairLock = new ReentrantLock(true); // 公平锁
public static void fairExecution() {
// 公平锁保证FIFO顺序,先请求先获得
fairLock.lock();
try {
doWork();
} finally {
fairLock.unlock();
}
}
private static void doWork() {}
}完整排查工具箱
/**
* 线上并发问题诊断工具集成
* 提供:死锁检测、线程Dump分析、锁竞争统计
*/
public class ConcurrencyDiagnostics {
/**
* 用ThreadMXBean检测死锁(可嵌入到监控系统)
*/
public static List<String> detectDeadlocks() {
ThreadMXBean tmx = ManagementFactory.getThreadMXBean();
long[] deadlockedIds = tmx.findDeadlockedThreads();
if (deadlockedIds == null || deadlockedIds.length == 0) {
return Collections.emptyList();
}
List<String> deadlockInfo = new ArrayList<>();
ThreadInfo[] deadlockedThreads = tmx.getThreadInfo(deadlockedIds, true, true);
for (ThreadInfo ti : deadlockedThreads) {
StringBuilder sb = new StringBuilder();
sb.append("死锁线程: ").append(ti.getThreadName())
.append(" (状态: ").append(ti.getThreadState()).append(")\n");
sb.append("等待获取: ").append(ti.getLockName()).append("\n");
sb.append("被以下线程持有: ").append(ti.getLockOwnerName()).append("\n");
for (StackTraceElement ste : ti.getStackTrace()) {
sb.append("\t").append(ste).append("\n");
}
deadlockInfo.add(sb.toString());
}
return deadlockInfo;
}
/**
* 定时检测死锁,发现后发送告警
*/
public static void startDeadlockMonitor() {
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "deadlock-monitor");
t.setDaemon(true);
return t;
});
scheduler.scheduleAtFixedRate(() -> {
List<String> deadlocks = detectDeadlocks();
if (!deadlocks.isEmpty()) {
System.err.println("==================== 检测到死锁!====================");
deadlocks.forEach(System.err::println);
System.err.println("====================================================");
// 这里接入告警系统(钉钉/飞书/PagerDuty等)
sendAlert("检测到死锁!", String.join("\n", deadlocks));
}
}, 5, 30, TimeUnit.SECONDS); // 每30秒检测一次
}
/**
* 打印所有线程状态统计
*/
public static void printThreadStats() {
ThreadMXBean tmx = ManagementFactory.getThreadMXBean();
ThreadInfo[] allThreads = tmx.getThreadInfo(tmx.getAllThreadIds());
Map<Thread.State, Long> stateCounts = Arrays.stream(allThreads)
.collect(Collectors.groupingBy(ThreadInfo::getThreadState, Collectors.counting()));
System.out.println("线程状态统计:");
stateCounts.forEach((state, count) ->
System.out.printf(" %-15s: %d%n", state, count));
long blockedCount = stateCounts.getOrDefault(Thread.State.BLOCKED, 0L);
if (blockedCount > 10) {
System.err.println("警告:" + blockedCount + " 个线程处于 BLOCKED 状态,可能存在锁竞争!");
}
}
private static void sendAlert(String title, String detail) {
// 告警集成逻辑
}
}三个踩坑实录
坑一:锁获取顺序随业务条件变化,导致偶发死锁
现象: 转账接口在高并发下偶发死锁,复现概率约千分之一。
原因: 转账需要同时锁住转出账户和转入账户。当用户A转给用户B的同时,用户B也在转给用户A,两个线程的锁获取顺序相反:
- 线程1:锁账户A → 锁账户B
- 线程2:锁账户B → 锁账户A
解法: 按账户ID的自然顺序排序后再加锁,保证所有线程的锁顺序一致。见上方transferMoney方法。
坑二:ReentrantLock 的死锁没有 jstack 自动识别
现象: jstack输出中没有"Found one Java-level deadlock",但服务确实死锁了。
原因: jstack的死锁检测只对synchronized内置锁有效,对ReentrantLock(AQS实现)不会自动识别死锁。
# 对于ReentrantLock死锁,需要手动分析
# 看 WAITING 状态的线程,关注 parking 在 LockSupport.park 上的
jstack <pid> | grep -B 5 "parking to wait"
# 然后追查各线程的锁持有情况解法: 用ThreadMXBean.findDeadlockedThreads()(上方代码),它能检测ReentrantLock的死锁;或者用tryLock超时机制从根本上避免死锁。
坑三:活锁导致的CPU飙升被误诊为内存泄漏
现象: 服务CPU突然从30%飙升到95%,同时QPS反而下降了,研发以为是内存泄漏引发了频繁GC。
原因: 实际上是两个线程陷入了活锁——都在循环重试某个CAS操作,不断消耗CPU,但业务一直没有进展。GC是正常的,CPU飙升是活锁里的忙等。
解法:
- 先用
top -H -p <pid>看哪个线程吃CPU - 把线程ID转成16进制,用jstack找到对应线程的栈
- 如果发现线程RUNNABLE且多次采样都在同一段代码,高度怀疑活锁
- 引入随机退让(退避算法)打破对称性
并发问题排查清单
遇到并发问题,我的排查顺序:
jstack <pid>→ 看是否有"Found one Java-level deadlock"- 统计各状态线程数 → BLOCKED多:锁竞争;WAITING多:等待某事件
top -H -p <pid>→ 找高CPU线程 → 16进制转换后查jstack → 活锁或CPU密集- 检查是否有长时间BLOCKED的线程 → 可能是某个锁持有者执行了耗时操作
- 检查锁获取顺序 → 是否有交叉持有的可能
- 嵌入
ConcurrencyDiagnostics.startDeadlockMonitor()→ 持续监控
小结
并发活性问题三兄弟:
- 死锁:互相等待,谁都动不了。诊断:jstack找BLOCKED链。解法:固定锁顺序或tryLock超时。
- 活锁:都在动,但没有进展。诊断:CPU高但无业务输出,RUNNABLE线程反复执行同一段代码。解法:随机退让打破对称。
- 饥饿:某线程长期得不到资源。诊断:响应时间长尾异常大。解法:公平锁或调整优先级。
生产环境的并发问题,90%的时间都花在诊断上,建议把死锁监控作为标准基础设施,而不是等出事了再查。
