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

Java 并发编程解析:死锁成因、可重入锁与解决方案

引言

在多线程中,“锁” 是保证线程安全的核心工具 —— 它能避免多个线程同时操作共享资源导致的数据错乱。
但在实际开发中,不合理的加锁逻辑往往会引发新的问题,其中 “死锁” 就是最棘手的场景之一。在前一篇博客中,我们已经梳理了锁的基本概念与作用,今天就来聊一聊死锁。

单线程连续加锁:可重入锁如何避免 “自阻塞”

我们首先来观察下面这些代码:

class Counter2{private int count=0;public void add(){synchronized (this){count++;}}public int get(){return count;}
}public class Demo4 {public static void main(String[] args) throws InterruptedException {Counter2 counter=new Counter();Thread t1=new Thread(()->{for (int i = 0; i < 50000; i++) {synchronized (counter){synchronized (counter) {synchronized (counter){counter.add();}}}}});t1.start();t1.join();System.out.println("count="+ counter.get());}
}

运行结果如下:

当我们第一次进行加锁操作的时候(锁没有人使用)是可以执行成功的。
当第二次进行加锁操作的时候,这时的锁对象已经是处于被占用的状态,当我们再尝试进行加锁的时候,就会触发阻塞等待
此时我们要是想解除阻塞,需要往下执行才可以,但是不要忘记的是,要想往下执行,需要等到第一把锁被释放。
我们把这样的问题就称为“死锁”。

为了解决上述问题,Java中的synchronized就引入了“可重入”的概念,接下来我们来细说一下这个概念。

可重入

所谓可重入就是当一个线程针对某一个锁加锁成功之后,后续该线程再次针对这把锁进行加锁,不会触发阻塞等待,而是会接着往下进行。
但是如果这时候其他线程尝试进行加锁操作,就会正常触发阻塞等待。
因为当前这把锁就是被这个线程所持有的~~~

这里要明确两个关键点是,必须是同一个线程针对同一把锁

可重入的实现原理,关键在于让锁对象内部保存两个关键信息:持有锁的线程重入次数。
这样在每次有线程尝试对这把锁进行加锁的时候,会先进行对比,看看这个锁持有者的线程和当前要加锁的线程是否是同一个。
若是同一个,直接让“重入次数+1”,无需阻塞等待;
若不是,则会触发阻塞等待,直到锁被释放。

当线程释放锁时,会让 “重入次数 - 1”,直到重入次数为 0 时,才真正释放锁(其他线程可获取)

所以,“可重入”,本质是让锁能识别 “当前持有者”。

多线程多锁竞争

 public static void main(String[] args) throws InterruptedException {Object locker1=new Object();Object locker2=new Object();Thread t1=new Thread(()->{synchronized (locker1){try {Thread.sleep(3000);} catch (InterruptedException e) {e.printStackTrace();}synchronized (locker2){System.out.println("t1线程将两把锁都拿到");}}});Thread t2=new Thread(()->{synchronized (locker2){try {Thread.sleep(3000);} catch (InterruptedException e) {e.printStackTrace();}synchronized (locker1){System.out.println("t2线程将两把锁都拿到");}}});t1.start();t2.start();t1.join();t2.join();}

这里需要注意的细节是,必须是拿到第一把锁之后,再尝试拿起第二把锁(前提是不可以释放第一把锁

下面我们来思考这样一个问题:
如果我们把这两个休眠操作去掉,还会出现死锁的情况吗?
答案是不一定~~~

我们这里添加了2个休眠操作是为了确保t1拿到locker1并且t2拿到了locker2,
然后再让t1去拿locker2,t2去拿locker1。
如果取消了休眠操作,那么很有可能一开始t1就拿到了这两把锁,此时t2还没启动。
那么这时就不会出现死锁的情况了

N个线程,M把锁

我们以哲学家就餐问题来说明这种情况:
假设现在有5个哲学家围在桌子前,桌子上有5个筷子(不是5双),还有5碗面。
每个哲学家(线程)要想吃面,得同时拿到左右两根筷子(两把锁)才能开始吃。

正常情况(大部分时候能运转)

比如哲学家 1 先拿到左边筷子(锁 1),接着顺利拿到右边筷子(锁 2),吃完后放下筷子(释放锁);哲学家 2 也能按类似逻辑,拿到自己的左右筷子,正常就餐。
此时多个线程(哲学家)按需获取锁(筷子),不会互相卡住。

死锁情况(极端场景)

如果同一时刻,所有哲学家都想吃饭,且都先拿起了左手边的筷子

  • 哲学家 1 拿着筷子 1(锁 1),等筷子 2(锁 2);
  • 哲学家 2 拿着筷子 2(锁 2),等筷子 3(锁 3);
  • ……
  • 哲学家 5 拿着筷子 5(锁 5),等筷子 1(锁 1)。

这时每个线程(哲学家)都握着一把锁(一根筷子),又都在等其他线程(哲学家)手里的锁(另一根筷子),这就形成了循环等待。
这就是N个线程(5 个)争夺M把锁(5 把)导致的死锁

核心逻辑总结

当多个线程(N个)需要按顺序获取多把锁(M把),如果线程间出现循环等待锁的情况(你等我的锁,我等他的锁,最后又绕回来等你的锁),就会触发死锁。
哲学家就餐问题是 “N个线程 + M把锁” 引发死锁的经典案例。

下面我们来总结一下构成死锁的四个必要条件:

构成死锁的四个必要条件

通过上面的案例,我们可以总结出死锁的四个必要条件—— 只要同时满足这四个条件,死锁就一定会发生;反之,只要破坏其中任意一个,就能避免死锁。

  1. 锁是互斥的:当线程A拿到锁Z之后,线程B再尝试获取锁Z,就会阻塞等待。
    ( 同一时间,一把锁只能被一个线程持有)
  2. 锁是不可抢占(剥夺)的:当线程A拿到锁X之后,线程B也想尝试获取锁Z,那么线程B就必须阻塞等待,不可以直接把锁抢过来。
    (线程持有锁后,其他线程不能 “强制剥夺” 这把锁,只能等待持有者主动释放)
  3. 请求和保持:当线程A拿到锁Z之后,在不释放锁Z的情况下,就尝试获取锁X。
    (线程持有已获取的锁后,不释放它,同时去请求其他锁)
  4. 循环等待:多个线程,多把锁之间的等待过程,形成了“循环”。比如:A等待B,B等待C,C等待A。
    (多个线程之间形成 “你等我、我等他、他等你” 的循环等待链)

如何避免死锁?

1.破坏 “请求与保持条件”:不嵌套加锁,释放后再请求

下面是修改之前构成死锁的代码:

 Thread t1=new Thread(()->{synchronized (locker1){try {Thread.sleep(3000);} catch (InterruptedException e) {e.printStackTrace();}synchronized (locker2){System.out.println("t1线程将两把锁都拿到");}}});

将 “持有 A 锁时请求 B 锁” 的嵌套逻辑,改为 “释放 A 锁后再请求 B 锁”。所以上述代码可修改为:

  Thread t1=new Thread(()->{synchronized (locker1){try {Thread.sleep(3000);} catch (InterruptedException e) {e.printStackTrace();}}synchronized (locker2){System.out.println("t1线程将两把锁都拿到");}});

这种方式的核心是:每次只持有一把锁,请求新锁前必须释放已持有的锁 —— 直接破坏 “请求和保持”,自然不会触发死锁。

2. 破坏 “循环等待条件”:约定统一的加锁顺序

让所有线程都按 “固定顺序” 获取多把锁,避免循环等待。以上述代码为例,我们可以让这两个线程都先拿locker1再拿locker2。

// t1线程:先locker1,再locker2
Thread t1=new Thread(()->{synchronized (locker1){try {Thread.sleep(3000);} catch (InterruptedException e) {e.printStackTrace();}synchronized (locker2){System.out.println("t1线程将两把锁都拿到");}}
});// t2线程:同样先locker1,再locker2(统一顺序)
Thread t2=new Thread(()->{synchronized (locker1){try {Thread.sleep(3000);} catch (InterruptedException e) {e.printStackTrace();}synchronized (locker2){System.out.println("t2线程将两把锁都拿到");}}
});

总结

死锁是并发编程中的 “隐形陷阱”,但并非无法预防。
核心在于理解它的四个必要条件,并通过 “避免嵌套加锁”“约定加锁顺序” 等方式破坏这些条件。

http://www.xdnf.cn/news/20084.html

相关文章:

  • 人工智能机器学习——逻辑回归
  • go 初始化组件最佳实践
  • ai生成ppt工具有哪些?10款主流AI生成PPT工具盘点
  • 中州养老:角色管理的角色分页查询
  • 渗透测试与网络安全审计的关系
  • (论文速读)Navigation World Models: 让机器人像人类一样想象和规划导航路径
  • MySQL主从复制之进阶延时同步、GTID复制、半同步复制完整实验流程
  • aippt自动生成工具有哪些?一文看懂,总有一款适合你!
  • Java数据结构——栈(Stack)和队列(Queue)
  • Qt---状态机框架QState
  • 【Sharding-JDBC】​Spring/Spring Boot 集成 Sharding-JDBC,分表策略与 API、YAML 配置实践​
  • 达梦数据库-共享内存池
  • 3.3.3 钢结构工程施工
  • Kubernetes知识点(三)
  • 探究Linux系统的SSL/TLS证书机制
  • 河南萌新联赛2025第(七)场:郑州轻工业大学
  • 直接让前端请求代理到自己的本地服务器,告别CV报文到自己的API工具,解放双手
  • android View详解—自定义ViewGroup,流式布局
  • 亚洲数字能源独角兽的 “安全密码”:Parasoft为星星充电筑牢软件防线
  • MongoDB 高可用部署:Replica Set 搭建与故障转移测试
  • SpringCloud微服务基于nacos注册中心的服务发现模式及OpenFeign的使用
  • Redis在商城开发中起到什么作用?
  • 漏洞修复 Nginx TLSSSL 弱密码套件
  • 2025国赛C题保姆级教程思路分析 NIPT 的时点选择与胎儿的异常判定
  • 【完整源码+数据集+部署教程】陶瓷物品实例分割系统源码和数据集:改进yolo11-LVMB
  • 第22节:性能监控与内存管理——构建高性能3D应用
  • 3ds Max流体模拟终极指南:打造逼真液体效果,从瀑布到杯中溢出的饮料!
  • 240. 搜索二维矩阵 II
  • 2025年含金量高的经济学专业证书工科!【纯干货分享】
  • 文件系统-哈希结构文件