本文 的 原文 地址
原始的内容,请参考 本文 的 原文 地址
尼恩说在前面
在40岁老架构师 尼恩的读者交流群(50+)中,最近有小伙伴拿到了一线互联网企业如得物、阿里、滴滴、极兔、有赞、希音、百度、网易、美团、蚂蚁、得物的面试资格,遇到很多很重要的相关面试题:
如何定位&避免死锁?
请问:死锁是如何产生的,如何预防?
请问:死锁产生条件?
请问:解决死锁的基本方法 ?
请问:死锁产生的原因 ?
最近有小伙伴在面 阿里/滴滴,问到了相关的面试题,可以说是逢面必问。
小伙伴没有系统的去梳理和总结,所以支支吾吾的说了几句,面试官不满意,面试挂了。
所以,尼恩给大家做一下系统化、体系化的梳理,使得大家内力猛增,可以充分展示一下大家雄厚的 “技术肌肉”,让面试官爱到 “不能自已、口水直流”,然后实现”offer直提”。
当然,这道面试题,以及参考答案,也会收入咱们的 《尼恩Java面试宝典PDF》V175版本,供后面的小伙伴参考,提升大家的 3高 架构、设计、开发水平。
《尼恩 架构笔记》《尼恩高并发三部曲》《尼恩Java面试宝典》的PDF,请到文末公号【技术自由圈】获取
本文作者:
- 第一作者 老架构师 肖恩(肖恩 是尼恩团队 高级架构师,负责写此文的第一稿,初稿 )
- 第二作者 老架构师 尼恩 (45岁老架构师, 负责 提升此文的 技术高度,让大家有一种 俯视 技术、俯瞰技术、 技术自由 的感觉)
什么是死锁
死锁是指两个或多个任务(比如线程、进程)彼此等待对方释放资源,结果谁也不愿意先让步,导致程序卡住不动。
如下图场景汇总:
- 任务A 拿着资源1,还要资源2才能继续。
- 任务B 拿着资源2,还要资源1才能继续。
- 谁都不肯放手自己手里的资源,于是都卡在那等着。
- 这种互相等待的情况就叫 死锁。
死锁通常有两种情况:
-
一种是使用
synchronized
内置锁 造成的, -
另一种是使用
Lock
显式锁引起的。
下面我们分别来看这两种情况。
内置锁 死锁 synchronized 版
这是一个演示死锁的小程序。我们用两个线程和两把锁来展示什么是死锁。
基本流程:
1、 创建两个锁对象:lockA
和 lockB
。
2、 启动两个线程:
-
线程 1 先拿
lockA
,然后尝试拿lockB
。 -
线程 2 先拿
lockB
,然后尝试拿lockA
。
3、 每个线程拿到第一个锁之后都会停顿 1 秒钟,再去拿第二个锁。
4、 最终两个线程都在等对方释放锁,导致死锁。
源码如下:
public class DeadLockExample {
public static void main(String[] args) {
Object lockA = new Object(); // 创建锁 A
Object lockB = new Object(); // 创建锁 B
// 创建线程 1
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
// 先获取锁 A
synchronized (lockA) {
System.out.println("线程 1:获取到锁 A!");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 尝试获取锁 B
System.out.println("线程 1:等待获取 B...");
synchronized (lockB) {
System.out.println("线程 1:获取到锁 B!");
}
}
}
});
t1.start(); // 运行线程
// 创建线程 2
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
// 先获取锁 B
synchronized (lockB) {
System.out.println("线程 2:获取到锁 B!");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 尝试获取锁 A
System.out.println("线程 2:等待获取 A...");
synchronized (lockA) {
System.out.println("线程 2:获取到锁 A!");
}
}
}
});
t2.start(); // 运行线程
}
}
以上程序的执行结果如下:
从输出可以看到:
- 线程 1 拿到了 A,等待 B;
- 线程 2 拿到了 B,等待 A;
双方都无法继续执行,程序卡住。 这就是典型的死锁场景。
显式锁 死锁 Lock 版
这个案例演示演示使用Lock场景下的死锁。
1、 创建两个锁对象:lockA
和 lockB
2、 创建两个线程 t1 和 t2
3、 t1 先获取 lockA,然后尝试获取 lockB
4、 t2 先获取 lockB,然后尝试获取 lockA
源码如下:
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class DeadLockByReentrantLockExample {
public static void main(String[] args) {
Lock lockA = new ReentrantLock(); // 创建锁 A
Lock lockB = new ReentrantLock(); // 创建锁 B
// 创建线程 1
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
lockA.lock(); // 加锁
System.out.println("线程 1:获取到锁 A!");
try {
Thread.sleep(1000);
System.out.println("线程 1:等待获取 B...");
lockB.lock(); // 加锁
try {
System.out.println("线程 1:获取到锁 B!");
} finally {
lockA.unlock(); // 释放锁
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lockA.unlock(); // 释放锁
}
}
});
t1.start(); // 运行线程
// 创建线程 2
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
lockB.lock(); // 加锁
System.out.println("线程 2:获取到锁 B!");
try {
Thread.sleep(1000);
System.out.println("线程 2:等待获取 A...");
lockA.lock(); // 加锁
try {
System.out.println("线程 2:获取到锁 A!");
} finally {
lockA.unlock(); // 释放锁
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lockB.unlock(); // 释放锁
}
}
});
t2.start(); // 运行线程
}
}
以上程序的执行结果如下:
死锁产生的条件
死锁的产生必须满足以下四个条件。
当这四个条件同时满足时,就可能发生死锁。
死锁发生的四个必要条件
当下面这四个条件同时满足时,系统就有可能发生死锁(即多个进程或线程互相等待对方释放资源,导致谁都无法继续执行)。
1、 互斥:资源不能共享,一次只能被一个进程使用。 比如打印机、某个文件的写权限等。
2、 持有并等待:一个进程在等待其他资源时,并不释放自己已经占有的资源。 就像你一只手拿着笔,另一只手等着拿书,谁也不放手。
3、 不可抢占:资源只能由持有它的进程主动释放,不能被强制抢走。 有点像借了别人的东西,必须他自己愿意才能还。
4、 循环等待:存在一个进程链,每个进程都在等待下一个进程所持有的资源。 A 等 B,B 等 C,C 又在等 A,形成一个“死循环”。
这个流程帮助我们判断系统中是否可能出现死锁。只要打破其中一个条件,就可以防止死锁的发生。
必要条件1:互斥
一个资源不能同时被多个线程使用。
如果一个线程已经用了这个资源,其他线程就得等它用完才能继续用。
这就是常说的“互斥锁”。
比如上图中,线程T1已经拿到了资源,那T2就不能拿。T2只能等着,直到T1用完了释放资源。
必要条件2:占有并且等待
当一个线程已经拿到了某个资源,还想再拿另一个被别的线程占用的资源时,它就得等着。
在等的过程中,它不会把自己已经拿到的资源释放掉。
比如上图中:
- 线程 T1 拿到了资源1,又去申请资源2;
- 但资源2已经被线程 T3 占用;所以 T1 就卡住等待;
- 但 T1 在等待的时候,还是抓着资源1不放。
必要条件3:不可抢占
一个资源一旦被某个线程占用了,只有这个线程用完后才能释放。
其他线程不能抢这个资源。
比如下图中
-
线程 T1 已经拿到了资源,在它没用完之前,T2 线程是拿不到这个资源的。
-
T2 只能等 T1 用完了、释放了,才能去使用。
必要条件4:循环等待
当发生死锁时,一定会出现一个“线程和资源”的 等待 环。
在这个等待 环 里,每个线程都在等下一个线程占用的资源,结果谁也继续不下去。
比如下图中,线程 T1 在等 T2 占着的资源,而 T2 又在等 T1 占着的资源。
两者互相等待,就卡住了,这就是典型的死锁情况。
死锁排查工具
工具1: arthas
Arthas 是一个用于线上 Java 应用监控和问题排查的工具。
它能让你实时看到应用的负载、内存、垃圾回收、线程等运行状态,还能在不修改代码的前提下,查看方法调用的参数、返回值、执行时间等问题信息,帮助你快速定位线上问题。
当你使用 watch
命令监控某个方法时,Arthas 的工作流程如下:
1、 Arthas找到这个方法在内存中的位置
2、 在这个方法的开头和结尾偷偷加上记录参数和返回值的代码
3、 方法被调用时,这些额外代码就会把信息传回给你看
4、 等你不需要监控了,Arthas就把这些额外代码去掉
arthas排查死锁
(1) 启动Arthas
首先,你需要启动Arthas并附加到目标Java进程:
java -jar arthas-boot.jar
然后选择你要诊断的Java进程。
(2)使用thread命令查看线程状态
thread #查看所有线程状态
thread --state BLOCKED #查看特定状态的线程
(3) 检测死锁
Arthas提供了专门的命令来检测死锁:
thread -b
或者
thread --block
这个命令会直接显示当前JVM中存在的死锁线程信息。
(4) 分析死锁线程堆栈
如果发现死锁,可以使用以下命令查看线程的详细堆栈:
thread <thread-id>
例如:
thread 12
工具2: jstack
在使用 jstack
分析问题前,我们先要用 jps
找到正在运行的 Java 程序对应的进程编号(PID)。
操作方法如下:
- 命令:
jps -l
- 功能:列出本机所有 Java 程序的 PID 和启动类名。
jps
是 Java 自带的一个小工具,可以快速查看当前有哪些 Java 程序在运行。
拿到 PID 后,就可以用 jstack -l PID
来分析线程状态,比如查找有没有线程死锁。
效果如下图:
jstack
的作用:抓取当前 Java 程序中所有线程的状态快照。-l
参数:显示更详细的锁信息,有助于排查死锁。
小提示:输入
jstack -help
可以查看更多参数说明。
工具3:jconsole
1、 打开 JDK 安装目录下的 bin
文件夹,找到 jconsole.exe
,双击运行。
2、 在弹出的界面中,选择你要监控的 Java 程序,点击“连接”。
3、 进入主界面后,切换到“线程”标签页,然后点击“检测死锁”按钮。
4、 稍等几秒,工具会自动找出死锁的线程,并显示相关信息。
工具4:jvisualvm
jvisualvm 是 JDK 自带的一个性能分析工具,放在 JDK 的 bin 目录下,直接双击就能打开:
打开后等几秒钟,本地运行的所有 Java 程序就会显示出来。双击你要查看的程序,然后切换到“线程”标签页,如下图:
进入线程页面后,如果程序中有死锁,会直接提示出来。点击“线程 Dump”,可以查看死锁的详细信息,如下图所示:
工具5:jmc
JMC 是 Java Mission Control 的缩写,是 JDK 自带的一个工具,用来监控和分析 Java 程序的运行情况。
它放在 JDK 安装目录下的 bin
文件夹里,直接双击就能启动。
启动界面如下图:
打开后会看到 JMC 的主页界面:
然后在列表中找到你要排查的 Java 程序,右键选择“启动 JMX 控制台”,就可以查看这个程序的详细运行状态了,如下图所示:
接着点击顶部的“线程”标签,在页面中勾选“死锁检测”,系统就会自动检查是否有线程死锁,并显示死锁的具体信息:
如何避免死锁 ?
死锁的发生必须同时满足四个条件:互斥、占有并等待、不可剥夺、循环等待。
-
只要打破其中一个,就能防止死锁。
-
只要打破其中一个,就能防止死锁。
-
只要打破其中一个,就能防止死锁。
-
只要打破其中一个,就能防止死锁。
重要的,说4遍
我们可以从这四个方面入手,系统性地避免死锁问题。
条件 | 破坏手段 | 关键技术 | 典型场景 |
---|---|---|---|
互斥 | 无锁编程 | AtomicXXX 、ThreadLocal | 计数器、状态标志 |
占有并等待 | 原子分配 | 全局资源管理器、两阶段锁 | 银行转账 |
不可剥夺 | 超时/中断 | tryLock() 、事务回滚 | 实时交易 |
循环等待 | 锁排序 | 资源ID排序、分层锁 | 多资源批量操作 |
破坏必要条件1:打破“互斥”这个条件
避免死锁,核心是打破“互斥”这个条件,常见的做法有以下几种:
(1)用无锁结构代替锁
比如用 ConcurrentHashMap
替代加了锁的 Map
,这样多个线程可以更高效地操作数据,不需要排队等锁。
(2)使用原子类
像 AtomicInteger
这样的类,可以在不加锁的情况下保证操作的原子性,适合简单的并发场景。
(3)线程私有变量
用 ThreadLocal
给每个线程分配自己的变量,彼此之间不共享,自然就不会抢资源。
(4)读写分离
通过读写锁(如 ReentrantReadWriteLock
),允许多个读操作同时进行,但写操作独占。这样在读多写少的场景下效率更高。
示例代码如下:
ReadWriteLock rwLock = new ReentrantReadWriteLock();
rwLock.readLock().lock(); // 多个读线程可以同时进来
rwLock.writeLock().lock(); // 写线程必须单独占用
破坏必要条件2:打破“破坏占有并等待”这个条件
破坏占有并等待条件,避免死锁,可以有两种方案:
方案1:一次性申请所有资源
设计思路:
- 一次把需要的所有资源都申请到,否则一个也不拿。
- 这样就不会出现“拿到了A还在等B”的情况,自然也就不会死锁。
实现方式:
- 用一个叫
AtomicResourceAllocator
的工具类来统一管理资源申请和释放。
代码如下:
import java.util.*;
class AtomicResourceAllocator {
// 使用ConcurrentHashMap保证线程安全
private final Map<Object, Boolean> resourceStatus = new ConcurrentHashMap<>();
/**
* 原子性地申请多个资源
* @param resources 需要申请的资源数组
* @return 全部申请成功返回true,否则false
*/
public boolean acquireAll(Object... resources) {
// 先检查所有资源是否可用
for (Object res : resources) {
if (resourceStatus.putIfAbsent(res, true) != null) {
// 有资源已被占用,释放已暂存的资源
for (Object acquiredRes : resources) {
if (acquiredRes == res) break;
resourceStatus.remove(acquiredRes);
}
return false;
}
}
return true;
}
/**
* 释放多个资源
*/
public void releaseAll(Object... resources) {
for (Object res : resources) {
resourceStatus.remove(res);
}
}
}
转账服务:使用原子分配,转账前先一次性申请两个账户的使用权,成功后再操作余额。
class TransferService {
private final AtomicResourceAllocator allocator = new AtomicResourceAllocator();
public boolean transfer(BankAccount from, BankAccount to, int amount) {
// 1. 原子性申请两个账户资源
if (!allocator.acquireAll(from, to)) {
System.out.println(Thread.currentThread().getName() +
": 资源申请失败,有其他转账正在进行");
return false;
}
try {
// 2. 检查余额是否充足
if (from.getBalance() < amount) {
System.out.println("余额不足");
return false;
}
// 3、 执行转账
from.debit(amount);
to.credit(amount);
System.out.println(Thread.currentThread().getName() +
": 成功转账 " + amount + " 从 " +
from.getAccountId() + " 到 " + to.getAccountId());
return true;
} finally {
// 4. 释放资源
allocator.releaseAll(from, to);
}
}
}
方案2:两阶段加锁协议(2PL)
两阶段锁协议(Two-Phase Locking, 2PL)是数据库系统和并发控制中广泛使用的技术,通过将锁的获取和释放分为两个明确的阶段,彻底破坏"占有并等待"条件,从而避免死锁。
其核心规则是:
阶段 | 操作规则 | 目的 |
---|---|---|
扩张阶段 | 线程只能获取锁,不能释放任何已持有的锁 | 确保资源集中申请,避免零散占用 |
收缩阶段 | 线程只能释放锁,不能获取任何新锁 | 确保资源有序释放,避免循环等待 |
工作原理(以银行转账为例)
// 账户资源
Object lockA = new Object();
Object lockB = new Object();
void transfer(Account from, Account to, int amount) {
// ===== 扩张阶段:只加锁 =====
synchronized (lockA) { // 获取第一个锁
synchronized (lockB) { // 获取第二个锁
// ===== 收缩阶段:只释放 =====
if (from.balance >= amount) {
from.balance -= amount;
to.balance += amount;
}
} // 自动释放lockB(收缩阶段)
} // 自动释放lockA(收缩阶段)
}
关键点:
- 一旦开始释放锁(进入收缩阶段),就不能再申请新锁,从而切断"持有A等B"的死锁链条。
- 锁的释放顺序通常与获取顺序相反(后进先出)。
破坏必要条件3:打破“打破不可抢占条件”这个条件
方案1:超时机制
有时候我们不想一直等下去,而是希望只尝试一段时间去获取锁。这时候可以用 tryLock
方法。示例代码如下:
Lock lock = new ReentrantLock();
if(lock.tryLock(1, TimeUnit.SECONDS)) {
try {
// 临界区
} finally {
lock.unlock();
}
} else {
// 超时后的处理
}
也就是:我最多等1秒钟,能拿到锁就干,拿不到就算了。
方案2:可中断锁
还有一种情况,如果一个线程正在等待锁,但你中途不想让它等了,想“打断”它,就可以使用 lockInterruptibly()
方法。
lock.lockInterruptibly(); // 可响应中断的加锁
意思是: 这个线程在等锁的时候,如果你通知它“别等了”,它就会停下来,而不是傻傻地一直等。
破坏必要条件2:打破“ 循环等待”这个条件
方案1:统一加锁顺序
在转账时,为了避免两个账户互相等待对方释放锁导致“卡住”,我们约定一个固定的加锁顺序:谁的账户ID小,就先锁谁。
这样不管谁转给谁,都按这个规则来,就不会出现死锁问题。
public void transfer(Account from, Account to, int amount) {
// 确定锁顺序:按账户ID排序
Account first = from.getId() < to.getId() ? from : to;
Account second = from.getId() < to.getId() ? to : from;
synchronized(first) { // 先锁ID小的账户
synchronized(second) { // 再锁ID大的账户
if (from.getBalance() >= amount) {
from.debit(amount);
to.credit(amount);
}
}
}
}
方案2:分层加锁
有些系统有多个层级(比如数据库、表、行),为了防止混乱,我们要按照从上到下的顺序加锁:
- 先锁数据库
- 再锁表
- 最后锁具体某一行
不能跳级也不能倒着来,否则会报错。
定义锁层级
public enum LockLevel {
DATABASE(1),
TABLE(2),
ROW(3);
private final int level;
public static void checkOrder(LockLevel current, LockLevel next) {
if (current.level >= next.level) {
throw new IllegalStateException("违反锁层级顺序");
}
}
}
使用
public void updateRecord(Database db, Table table, Row row) {
synchronized(db) {
LockLevel.DATABASE.checkOrder(LockLevel.TABLE);
synchronized(table) {
LockLevel.TABLE.checkOrder(LockLevel.ROW);
synchronized(row) {
// 执行操作
}
}
}
}
方案3:资源编号策略
当需要同时锁多个资源时,我们可以根据它们的编号(比如内存地址)进行排序,然后按顺序一个个加锁。
这样也能避免死锁。
示例代码:
public class CompositeResource {
private final Object[] resources;
public CompositeResource(Object... resources) {
this.resources = Arrays.stream(resources)
.sorted(Comparator.comparingInt(System::identityHashCode))
.toArray();
}
public void lockAll() {
for (Object res : resources) {
synchronized(res) {}
}
}
public void unlockAll() {
for (int i = resources.length - 1; i >= 0; i--) {
synchronized(resources[i]) {}
}
}
}
使用
CompositeResource cr = new CompositeResource(accountA, accountB);
try {
cr.lockAll();
// 执行转账操作
} finally {
cr.unlockAll();
}
死锁的定时检测和恢复
程序会每隔一段时间(比如每5秒)检查一次 JVM 中是否有线程陷入死锁。
如果发现死锁,就打印出详细信息,并尝试通过中断线程来恢复。
示例代码:
import java.lang.management.*;
/**
* 死锁检测线程类,定期检查JVM中是否存在死锁线程
*/
public class DeadlockDetector extends Thread {
// 检测间隔时间(毫秒)
private static final long CHECK_INTERVAL = 5000; // 每5秒检测一次
/**
* 主运行方法,持续检测死锁
*/
@Override
public void run() {
// 获取线程管理MXBean用于检测死锁
ThreadMXBean threadBean = ManagementFactory.getThreadMXBean();
// 持续运行直到线程被中断
while (!Thread.currentThread().isInterrupted()) {
// 查找死锁线程ID数组(返回null表示无死锁)
long[] deadlockedThreads = threadBean.findDeadlockedThreads();
// 如果发现死锁线程
if (deadlockedThreads != null) {
// 获取死锁线程的详细信息并处理
handleDeadlock(threadBean.getThreadInfo(deadlockedThreads));
}
try {
// 休眠指定间隔时间
Thread.sleep(CHECK_INTERVAL);
} catch (InterruptedException e) {
// 如果检测线程被中断,退出循环
break;
}
}
}
/**
* 处理死锁情况
* @param infos 死锁线程信息数组
*/
private void handleDeadlock(ThreadInfo[] infos) {
// 1. 打印死锁详细信息(实际应用中应替换为日志记录)
System.err.println("=== 死锁检测 ===");
for (ThreadInfo info : infos) {
System.err.println("死锁线程: " + info.getThreadName());
System.err.println("等待资源: " + info.getLockName()); // 线程正在等待的锁
System.err.println("被阻塞于: " + info.getLockOwnerName()); // 当前持有锁的线程
System.err.println("堆栈追踪:");
// 打印线程堆栈跟踪(可帮助定位死锁代码位置)
for (StackTraceElement ste : info.getStackTrace()) {
System.err.println("\t" + ste);
}
}
// 2. 自动恢复策略(示例:中断所有死锁线程)
// 注意:这是激进做法,实际应用需要根据业务场景设计恢复策略
for (ThreadInfo info : infos) {
Thread.getAllStackTraces().keySet().stream()
// 根据线程ID匹配到对应的Thread对象
.filter(t -> t.getId() == info.getThreadId())
.findFirst()
// 中断死锁线程(要求线程代码能响应中断)
.ifPresent(Thread::interrupt);
}
/*
* 实际生产环境建议:
* 1. 集成监控系统(如Prometheus)发送告警
* 2. 记录详细日志到文件系统
* 3、 根据业务场景选择恢复策略:
* - 对于可重试操作:记录状态后中断线程
* - 关键业务线程:尝试资源强制释放
* - 无法恢复的情况:触发故障转移
*/
}
}
如需进一步优化,可以根据具体业务逻辑调整恢复策略,例如重试、资源释放或触发故障转移机制等。