死锁定位与避免
学习目标
- 区分竞态、死锁、活锁、饥饿和阻塞堆积。
- 用固定锁顺序、超时和可中断获取降低死锁风险。
- 使用 jstack、jcmd Thread.print 和 ThreadMXBean 取得线程转储证据。
核心知识点
专业术语
- 竞态条件:结果依赖并发时序,可能错误但不一定永久阻塞。
- 死锁(deadlock):线程互相等待资源,形成循环等待,任何线程都无法继续。
- 活锁(livelock):线程持续改变状态但没有有效进展。
- 饥饿(starvation):某线程长期得不到 CPU、锁或许可证。
- 线程转储(thread dump):某个时刻所有线程栈、状态和锁等待关系的快照。
白话解释与边界
经典死锁同时满足互斥、持有并等待、不可剥夺和循环等待四个条件。最有效的预防是让所有路径按同一全局顺序获得多把锁;拿不到时使用 tryLock 超时退出或回滚。线程转储用于证明现场,不应靠“某次程序卡住”猜测。
线程转储显示的是诊断时刻,不能替代业务日志、请求 ID 和锁等待指标。死锁避免还要考虑数据库锁、远端服务和文件锁等 JVM 外部资源。
常用用法
固定锁顺序:按稳定标识获取多把锁
无论转账方向如何都先锁 id 小的账户,破坏循环等待条件。
// 说明:固定锁顺序:按稳定标识获取多把锁。
public class LockOrderDemo {
static void transfer(Account from, Account to, int amount) {
Account first = from.id() < to.id() ? from : to;
// 初始状态:first = from.id() < to.id() ? from : to。
// 作用:Account first = from.id() < to.id() ? from : to;;按稳定标识获取多把锁。
Account second = from.id() < to.id() ? to : from;
// 初始状态:second = from.id() < to.id() ? to : from。
// 作用:Account second = from.id() < to.id() ? to : from;;按稳定标识获取多把锁。
synchronized (first) {
synchronized (second) {
from.balance -= amount;
to.balance += amount;
}
}
}
static final class Account {
final int id;
int balance;
Account(int id, int balance) {
this.id = id;
// 初始状态:id = id。
this.balance = balance;
// 初始状态:balance = balance。
}
int id() {
return id;
}
}
public static void main(String[] args) {
var a = new Account(1, 100);
// 初始状态:a = new Account(1, 100)。
// 作用:var a = new Account(1, 100);;按稳定标识获取多把锁。
var b = new Account(2, 50);
// 初始状态:b = new Account(2, 50)。
// 作用:var b = new Account(2, 50);;按稳定标识获取多把锁。
transfer(a, b, 10);
System.out.println(a.balance + "," + b.balance);
// 输出:90,60
}
}id 必须稳定且唯一;若无法定义全局顺序,应改用单一协调锁或 tryLock 回退。
tryLock:拿不到锁就释放并重试
tryLock 让线程不会无限占有第一把锁等待第二把;失败时要释放已持有的锁并制定重试、降级或回滚策略。
// 说明:tryLock:拿不到锁就释放并重试。
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
public class TryLockDeadlockAvoidanceDemo {
public static void main(String[] args) throws InterruptedException {
ReentrantLock first = new ReentrantLock();
// 初始状态:first = new ReentrantLock()。
// 作用:ReentrantLock first = new ReentrantLock();;拿不到锁就释放并重试。
ReentrantLock second = new ReentrantLock();
// 作用:ReentrantLock second = new ReentrantLock();;拿不到锁就释放并重试。
// 初始状态:second = new ReentrantLock()。
first.lock();
// 作用:first.lock();;拿不到锁就释放并重试。
try {
if (second.tryLock(1, TimeUnit.MILLISECONDS)) {
// 作用:if (second.tryLock(1, TimeUnit.MILLISECONDS)) {;拿不到锁就释放并重试。
try {
System.out.println("both acquired");
// 输出:both acquired
} finally {
second.unlock();
// 作用:second.unlock();;拿不到锁就释放并重试。
}
} else {
System.out.println("retry later");
// 输出:retry later(可能)
}
} finally {
first.unlock();
// 作用:first.unlock();;拿不到锁就释放并重试。
}
}
}超时不是业务成功,不能直接忽略。
lockInterruptibly:允许诊断或关闭打断等待
在请求取消、服务关闭或锁等待可放弃的场景,使用可中断等待比永久 lock 更容易恢复。
// 说明:lockInterruptibly:允许诊断或关闭打断等待。
import java.util.concurrent.locks.ReentrantLock;
public class InterruptibleWaitDemo {
public static void main(String[] args) throws InterruptedException {
ReentrantLock lock = new ReentrantLock();
// 作用:ReentrantLock lock = new ReentrantLock();;允许诊断或关闭打断等待。
// 初始状态:lock = new ReentrantLock()。
lock.lockInterruptibly();
// 初始状态:lock.lockInterruptibly()。
// 作用:lock.lockInterruptibly();;允许诊断或关闭打断等待。
try {
System.out.println("lock acquired");
// 输出:lock acquired
} finally {
lock.unlock();
// 作用:lock.unlock();;允许诊断或关闭打断等待。
}
}
}捕获中断后应结束当前工作或恢复中断标记。
取得线程转储:选择独立工具或统一诊断入口
取得 PID 后可用 jcmd <pid> Thread.print -l 或 jstack -l <pid> 捕获线程转储。
// 说明:取得线程转储:选择独立工具或统一诊断入口。
public class ThreadDumpPidDemo {
public static void main(String[] args) {
System.out.println("pid=" + ProcessHandle.current().pid());
// 输出:pid=12345(进程号可能不同)
}
}重点查看 BLOCKED、WAITING、锁拥有者、等待对象和是否存在 Found one Java-level deadlock;PID 和线程顺序是运行时可能变化的。
ThreadMXBean:程序内检测死锁
findDeadlockedThreads 可以检测对象监视器和 ownable synchronizer;它是诊断探针,不应每个请求都高频调用。
// 说明:ThreadMXBean:程序内检测死锁。
import java.lang.management.ManagementFactory;
import java.lang.management.ThreadMXBean;
public class DeadlockDetectorDemo {
public static void main(String[] args) {
ThreadMXBean bean = ManagementFactory.getThreadMXBean();
// 初始状态:bean = ManagementFactory.getThreadMXBean()。
// 作用:ThreadMXBean bean = ManagementFactory.getThreadMXBean();;程序内检测死锁。
long[] ids = bean.findDeadlockedThreads();
// 初始状态:ids = bean.findDeadlockedThreads()。
// 作用:long[] ids = bean.findDeadlockedThreads();;程序内检测死锁。
System.out.println(ids == null ? "no deadlock" : "deadlock detected");
// 输出:no deadlock
}
}发现死锁后要记录线程栈并触发恢复或实例摘除策略。
jcmd Thread.print:低开销现场快照
相比只看业务日志,线程转储能看到 JVM 线程状态、锁等待关系和调用栈;采集前应记录时间、实例、负载和连续多份快照,避免把一次短暂等待误判为死锁。
// 说明:jcmd Thread.print:低开销现场快照。
public class DiagnosticCommandHintDemo {
public static void main(String[] args) {
System.out.println("run: jcmd <pid> Thread.print -l");
// 输出:run: jcmd <pid> Thread.print -l
}
}不常用但需要知道
锁顺序图:记录等待关系
这只是把现场关系写成可读图示;真正的锁拥有者和调用栈应以 jstack/jcmd 输出为准。发现相反顺序就统一为同一排序规则或撤销其中一层锁。
public class WaitForGraphDemo {
public static void main(String[] args) {
System.out.println("A -> lock-1 -> lock-2");
// 输出:A -> lock-1 -> lock-2
System.out.println("B -> lock-2 -> lock-1");
// 输出:B -> lock-2 -> lock-1
}
}解释线程状态:区分锁竞争与条件等待
BLOCKED 通常表示等待进入 synchronized 监视器,WAITING 表示等待 notify、join 或其他无限等待;状态本身不能证明死锁,还要结合锁拥有者和循环关系。
public class ThreadStateDiagnosisDemo {
public static void main(String[] args) {
System.out.println(Thread.State.BLOCKED);
// 输出:BLOCKED
System.out.println(Thread.State.WAITING);
// 输出:WAITING
}
}数据库与 JVM 锁的交叉死锁
业务同时持有数据库行锁、JVM 锁、远端租约时也必须定义全局顺序;jstack 只能看到 JVM 一侧,数据库锁等待需要数据库自己的诊断视图。
public class CrossResourceRuleDemo {
public static void main(String[] args) {
System.out.println("acquire database lock before JVM lock, consistently");
// 输出:acquire database lock before JVM lock, consistently
}
}jstack -l:锁信息选项
-l 请求更详细的锁信息,采集命令因平台和权限可能不同;生产环境先在低风险时间采集,必要时做连续快照对比。
// 说明:实际命令要把 <pid> 替换为目标 JVM 进程号;-l 让 jstack 在线程栈外附带 java.util.concurrent 锁信息。
public class JstackOptionDemo {
public static void main(String[] args) {
System.out.println("jstack -l <pid>");
// 输出:jstack -l <pid>
}
}经典死锁反例:仅用于受控诊断
这个反例只用于本地学习 jstack/jcmd Thread.print 的死锁现场,不能直接放进生产代码或无超时的测试套件;生产路径应使用统一锁顺序、tryLock 超时或可中断获取。
import java.util.concurrent.CountDownLatch;
public class DeadlockReproductionDemo {
public static void main(String[] args) throws InterruptedException {
// 警告:以下 main 会故意永久等待,只能在隔离进程和超时 harness 中运行。
Object firstLock = new Object();
// 初始状态:firstLock = new Object()。
// 作用:Object firstLock = new Object();;仅用于受控诊断。
Object secondLock = new Object();
// 初始状态:secondLock = new Object()。
// 作用:Object secondLock = new Object();;仅用于受控诊断。
CountDownLatch firstHeld = new CountDownLatch(1);
// 初始状态:firstHeld = new CountDownLatch(1)。
// 作用:CountDownLatch firstHeld = new CountDownLatch(1);;仅用于受控诊断。
CountDownLatch secondHeld = new CountDownLatch(1);
// 初始状态:secondHeld = new CountDownLatch(1)。
// 作用:CountDownLatch secondHeld = new CountDownLatch(1);;仅用于受控诊断。
Thread first = new Thread(() -> {
// 初始状态:first = new Thread(() ->。
// 作用:Thread first = new Thread(() -> {;仅用于受控诊断。
synchronized (firstLock) {
firstHeld.countDown();
// 初始状态:firstHeld.countDown()。
// 作用:firstHeld.countDown();;仅用于受控诊断。
await(secondHeld);
synchronized (secondLock) {
// 不会执行到这里
}
}
});
Thread second = new Thread(() -> {
// 初始状态:second = new Thread(() ->。
// 作用:Thread second = new Thread(() -> {;仅用于受控诊断。
synchronized (secondLock) {
secondHeld.countDown();
// 初始状态:secondHeld.countDown()。
// 作用:secondHeld.countDown();;仅用于受控诊断。
await(firstHeld);
synchronized (firstLock) {
// 不会执行到这里
}
}
});
first.start();
// 初始状态:first.start()。
// 作用:first.start();;仅用于受控诊断。
second.start();
// 初始状态:second.start()。
// 作用:second.start();;仅用于受控诊断。
first.join();
// 初始状态:first.join()。
// 作用:first.join();;仅用于受控诊断。
second.join();
// 初始状态:second.join()。
// 作用:second.join();;仅用于受控诊断。
}
static void await(CountDownLatch latch) {
try {
latch.await();
// 初始状态:latch.await()。
// 作用:latch.await();;仅用于受控诊断。
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
// 初始状态:Thread.currentThread().interrupt()。
// 作用:Thread.currentThread().interrupt();;仅用于受控诊断。
}
}
}
// 结果:first 与 second 分别持有一把锁并等待另一把锁,两个线程都进入 BLOCKED,main 停在 join()简单案例
import java.util.concurrent.locks.ReentrantLock;
public class SafePairLockDemo {
public static void main(String[] args) {
ReentrantLock left = new ReentrantLock();
// 初始状态:left = new ReentrantLock()。
// 作用:ReentrantLock left = new ReentrantLock();;仅用于受控诊断。
ReentrantLock right = new ReentrantLock();
// 初始状态:right = new ReentrantLock()。
// 作用:ReentrantLock right = new ReentrantLock();;仅用于受控诊断。
left.lock();
// 初始状态:left.lock()。
// 作用:left.lock();;仅用于受控诊断。
try {
if (right.tryLock()) {
try {
System.out.println("critical pair");
// 输出:critical pair
} finally {
right.unlock();
// 初始状态:right.unlock()。
// 作用:right.unlock();;仅用于受控诊断。
}
}
} finally {
left.unlock();
// 初始状态:left.unlock()。
// 作用:left.unlock();;仅用于受控诊断。
}
}
}两把锁的获取要么固定顺序,要么在第二把获取失败时尽快释放第一把;这比让线程无限等待更容易取消和恢复。
易混点
- 有 BLOCKED 线程不等于死锁,必须找到循环等待;短暂锁竞争是正常现象。
- 线程 dump 是快照,单份输出只能说明当时状态,连续快照更有证据力。
- tryLock 失败后若不释放已有锁,仍可能形成死锁。
- lockInterruptibly 只能让等待可被打断,不能自动解决已经持有的外部资源。
- JVM 诊断看不到数据库内部锁;跨资源死锁要联合数据库、连接池和应用日志。
课后小问
为什么统一锁顺序能避免经典死锁? 答案:所有线程都按同一顺序获取锁,无法形成 A 等 B、B 等 A 的循环等待。 解析:锁的排序键必须稳定,新增代码路径也必须遵守,否则预防规则会被绕过。
如何证明“线程卡住”是死锁而不是慢 I/O? 答案:采集连续线程转储,检查锁等待图是否存在循环,并结合 I/O、数据库和业务耗时证据。 解析:BLOCKED/WAITING 只是现象,循环拥有者和等待者关系才是 JVM 死锁的直接证据。
为什么 jstack 不能独立诊断数据库死锁? 答案:数据库行锁不完全呈现在 JVM 线程的 Java monitor 或 ownable synchronizer 中。 解析:需要同时查看数据库锁等待视图、连接池状态和应用线程栈,按请求 ID 对齐证据。
本节小结
- 死锁是循环等待,竞态、活锁和饥饿是不同故障模式。
- 固定锁顺序、tryLock 超时、可中断等待和缩短临界区是常见预防手段。
- jstack、jcmd Thread.print 和 ThreadMXBean 能提供 JVM 侧的线程转储与检测证据。
- 外部数据库/远端锁要纳入统一资源顺序,不能只看 JVM 堆栈。
快速回顾
- 能区分 BLOCKED、WAITING、死锁和普通慢任务。
- 会用稳定排序避免多锁循环等待。
- 会写 tryLock 失败后的释放与重试边界。
- 知道如何用 jstack、jcmd 和 ThreadMXBean 取得证据。
阿源的知识库