当前位置: 首页 > news >正文

当你的线程“互相等待”时:死锁的四个必要条件与 Java 代码中的“致命拥抱”

你打开jstack,发现几个线程都停在BLOCKED状态,谁也动不了。
日志里最后一行是“正在获取锁 A”,下一行“正在等待锁 B”……永远等不到。
恭喜,你遇到了并发编程的经典噩梦——死锁
这不是随机故障,而是四个条件恰好同时满足的必然结果。
学会识别它们,你就能从“死锁受害者”变成“死锁预防者”。

大家好,我是Evan,一个在知识汇秒杀系统中用tryLock避免过死锁的 Java+AI 学生。
今天,我们从操作系统的死锁四个必要条件出发,用真实的 Java 代码还原死锁现场,再用jstack亲手抓出它。
读完这篇,你不仅能背出“互斥、持有并等待、不可剥夺、循环等待”,还能在自己的代码里提前嗅到死锁的味道。

📌 写在前面

大二学操作系统,老师讲死锁时,我背了四个条件,但总觉得那是银行家算法里的抽象概念。
直到我在知识汇的优惠券秒杀中,用两个synchronized嵌套更新库存和用户额度,压测时线程池居然全部卡死。
jstack一拉,清清楚楚看到 Thread-1 持有锁 A 等待锁 B,Thread-2 持有锁 B 等待锁 A。
那一刻我才明白:死锁不是教科书上的古董,它就藏在你每天写的synchronized

一、死锁的四个必要条件(一个都不能少)

死锁的发生必须同时满足以下四个条件:

只有这四个条件同时成立,死锁才会发生。因此,打破任意一个,就能预防或解除死锁。

二、用 Java 代码还原一个经典死锁

public class DeadlockDemo { private static final Object LOCK_A = new Object(); private static final Object LOCK_B = new Object(); public static void main(String[] args) { Thread t1 = new Thread(() -> { synchronized (LOCK_A) { System.out.println("Thread-1 持有 LOCK_A,等待 LOCK_B"); sleep(100); // 模拟业务处理 synchronized (LOCK_B) { System.out.println("Thread-1 同时持有 LOCK_A 和 LOCK_B"); } } }); Thread t2 = new Thread(() -> { synchronized (LOCK_B) { System.out.println("Thread-2 持有 LOCK_B,等待 LOCK_A"); sleep(100); synchronized (LOCK_A) { System.out.println("Thread-2 同时持有 LOCK_B 和 LOCK_A"); } } }); t1.start(); t2.start(); } private static void sleep(int ms) { try { Thread.sleep(ms); } catch (InterruptedException e) {} } }

运行结果(大概率):

Thread-1 持有 LOCK_A,等待 LOCK_B Thread-2 持有 LOCK_B,等待 LOCK_A (然后程序卡死,永不退出)

四个条件检查

  • 互斥:synchronized保证互斥 ✅

  • 持有并等待:t1 持有 A 等 B,t2 持有 B 等 A ✅

  • 不可剥夺:t1 不会释放 A 去等 B(除非主动 unlock) ✅

  • 循环等待:t1 等 t2 释放 B,t2 等 t1 释放 A ✅

完美死锁。

三、如何检测死锁:jstack是你的火眼金睛

3.1 找到 Java 进程 PID

jps -l

3.2 用jstack打印线程堆栈

jstack <pid>

输出中会明确提示Found one Java-level deadlock

Found one Java-level deadlock: ============================= "Thread-1": waiting to lock monitor 0x00007f8c1400a500 (object 0x000000076b5a3b20, a java.lang.Object), which is held by "Thread-2" "Thread-2": waiting to lock monitor 0x00007f8c1400a2e0 (object 0x000000076b5a3b10, a java.lang.Object), which is held by "Thread-1"

还会告诉你哪一行代码导致的。

3.3 其他工具

  • VisualVM:图形化监控,可自动检测死锁。

  • JConsole:连接进程 → 线程 → 检测死锁按钮。

  • Linuxkill -3 <pid>:打印堆栈到标准错误(老式方法)。

四、开发中常见的死锁场景

4.1 嵌套synchronized

如上面的例子,两个锁顺序相反。

4.2 数据库行锁 + 表锁

-- 事务1 SELECT * FROM orders WHERE id = 1 FOR UPDATE; -- 锁住行1 SELECT * FROM users WHERE id = 1 FOR UPDATE; -- 等待锁住行2 -- 事务2 SELECT * FROM users WHERE id = 1 FOR UPDATE; -- 锁住行2 SELECT * FROM orders WHERE id = 1 FOR UPDATE; -- 等待锁住行1

数据库也会死锁,通常数据库会检测并回滚其中一个事务(返回Deadlock found when trying to get lock)。

4.3 线程池 + Future.get() 相互等待

ExecutorService pool = Executors.newFixedThreadPool(2); Future<?> f1 = pool.submit(() -> { Future<?> f2 = pool.submit(() -> {}); f2.get(); // 等待 f2 }); Future<?> f2 = pool.submit(() -> { Future<?> f1 = pool.submit(() -> {}); f1.get(); // 等待 f1 });

如果线程池只有 2 个线程,且两个任务互相提交并等待对方完成,就会死锁。

五、预防死锁的四种武器(打破任一条件)

5.1 顺序加锁(打破循环等待)

java

// 规定总是先锁 A 再锁 B synchronized (LOCK_A) { synchronized (LOCK_B) { // 安全 } }

5.2 使用tryLock超时(打破持有并等待 + 不可剥夺)

ReentrantLock lockA = new ReentrantLock(); ReentrantLock lockB = new ReentrantLock(); boolean gotA = lockA.tryLock(100, TimeUnit.MILLISECONDS); boolean gotB = lockB.tryLock(100, TimeUnit.MILLISECONDS); if (gotA && gotB) { try { // 执行业务 } finally { if (gotB) lockB.unlock(); if (gotA) lockA.unlock(); } } else { // 释放已获得的锁,避免死锁 if (gotA) lockA.unlock(); if (gotB) lockB.unlock(); // 重试或降级 }

5.3 一次性申请所有资源(打破“持有并等待”)

// 使用 All-or-Nothing 模式 ReentrantLock[] locks = {lockA, lockB}; while (true) { boolean acquired = true; for (ReentrantLock lock : locks) { if (!lock.tryLock(100, TimeUnit.MILLISECONDS)) { acquired = false; // 释放已获得的锁 for (ReentrantLock l : locks) l.unlock(); break; } } if (acquired) { try { /* 业务 */ } finally { for (ReentrantLock l : locks) l.unlock(); } break; } // 短暂等待后重试 }

六、数据库死锁的特殊处理

数据库死锁时,InnoDB 会检测并自动回滚其中一个事务(通常是开销较小的一方)。
你可以通过SHOW ENGINE INNODB STATUS查看最近死锁信息。

Java 中捕获

try { // 执行 SQL } catch (DeadlockLoserDataAccessException e) { // 重试 }

预防

  • 统一对表的访问顺序(例如先更新主表,再更新从表)。

  • 使用SELECT ... FOR UPDATE NOWAIT(部分数据库支持)。

📝 总结

核心结论

  • 死锁是并发编程中“四个条件同时满足”的必然结果。

  • jstack是检测 Java 死锁的第一利器。

  • 预防死锁最实用的两个方法:固定加锁顺序+使用tryLock超时

  • 数据库死锁不同,引擎会自动回滚,但你的代码仍需要重试机制。

🤔思考题
你有一个方法,需要同时获取三个锁:LOCK_ALOCK_BLOCK_C。你规定所有线程都按 A → B → C 的顺序获取。
但是,某些情况下,你调用的第三方库内部会反向获取LOCK_CLOCK_B。你的代码无法修改第三方库。
问题:这种情况下,如何避免死锁?请给出至少两种方案。

欢迎在评论区留下你的想法 —— 下一篇我会聊聊“I/O 多路复用与 Agent 循环:epoll 如何支撑你上千个并发 Tool 调用”

http://www.jsqmd.com/news/866707/

相关文章:

  • PET_RK3588_P01开发板深度评测:从硬件解析到AI实战应用
  • JTAG操作实战指南:从原理到嵌入式调试与Flash编程
  • 嵌入式AI实战:从模型量化到人形检测部署全流程解析
  • 蛋白质-配体相互作用分析终极指南:PLIP快速入门与实战应用
  • 2026最新北京本地国画艺考画室综合能力测评结果:央美国画培训与中国画校考集训怎么选 - 企业信息深度横评
  • Windows 10 21H1启用包机制解析与部署实战指南
  • SQL学习指南——再谈连接
  • Linux内核调度器心跳机制:scheduler_tick原理与性能调优
  • 新能源动力域系统级测试:从HIL仿真到自动化验证的完整解决方案
  • 基于EsDA平台实现串口设备联网:Modbus RTU转MQTT网关实战
  • Display Driver Uninstaller:彻底解决显卡驱动问题的3步终极指南
  • RISC-V嵌入式AI部署实战:NanoDet模型与ncnn框架移植指南
  • LangGraph实战:构建可控、可调试的复杂AI工作流
  • 抖音下载器:如何永久保存你喜欢的短视频内容?
  • 开源项目功能扩展技术方案:实现多账户管理与配置优化的完整指南
  • 抖音无水印下载终极指南:douyin-downloader让内容保存变得如此简单
  • 深入Linux调度器心跳:scheduler_tick原理、性能影响与调优实践
  • 网盘直链下载助手实战指南:八大平台免登录高速下载完整方案
  • 基于Linux内核list.h思想实现高效C语言单向链表
  • 专业鼠标加速配置指南:Raw Accel内核级驱动深度解析与实战优化策略
  • OpenRGB终极指南:一个软件统一控制所有RGB设备,告别厂商软件依赖
  • iOS 17.6.1系统更新深度解析:错误修复、安全加固与升级指南
  • Windows 10 21H1更新解析:聚焦混合办公安全与IT管理优化
  • Windows下OpenCore引导盘制作:5步打造完美Hackintosh启动盘
  • Python 爬虫实战:京东商品价格监控爬取与分析
  • 短剧出海AI工具推荐:翻译配音一站搞定
  • C语言字符串与指针核心函数手写实现与底层原理剖析
  • 深入解析Linux system()调用:从原理到安全实践
  • 汽车电子高效模型测试驱动开发:从需求到合规的零缺陷实践
  • 树莓派CM5工业应用实战:从核心模块到边缘AI系统构建