JavaEE: 死锁问题详解(5000字)

10 篇文章 0 订阅


死锁的出现场景

1. 一个线程一把锁,这个线程针对这把锁,连续加锁了两次

死锁的场景1:

void func() {
	//第一次能够加锁成功
	synchronized (this) {
		//第二次加锁的时候,锁对象已经被占用了
		//第二次加锁就应该阻塞
		synchronized (this) {
		
		}
	}
}

这个情况在代码实例中,并没有出现死锁,这是因为synchronized针对这个情况做了特殊处理~

C++ / Python中的锁就没有这样的功能,就会死锁(借助第三方库可以实现不出现死锁)

synchronized 是 “可重入锁”, 针对上述一个线程连续加锁两次的情况,synchronized 在加锁的时候,不仅需要判定当前的锁是否是被占用的状态,还要在锁中额外记录一下当前是哪个线程对这个锁加锁了~
对于可重入锁来说,发现加锁的线程就是当前锁的持有线程,并不会真正进行任何的加锁操作,也不会进行任何的"阻塞操作",而是直接往下执行.
在这里插入图片描述

那么问题就来了,计算机是怎么知道哪一个是需要真正释放锁的操作呢,换句话说,计算机是怎么知道哪一个是最外层的括号呢 ?

  • 针对上述问题,我们可以引入一个计数器~
    初始情况下,计数器是0
    每次执行到 { 计数器 +1
    每次执行到 } 计数器 -1
    如果某次 -1 后,计数器为0了,那么就说明这次就要真正的释放锁了~
    在这里插入图片描述

这是计算机中非常常见的思想方法,它在JVM中的垃圾回收机制,C++智能指针,Linux等等都用到了.

2. 两个线程,两把锁

死锁的场景2:

  1. 首先线程1 现针对 A 加锁,线程2 针对 B 加锁
  2. 之后线程1 不释放锁A 的情况下,再针对 B 加锁.同时线程 2 不释放 B 的情况下针对 A 加锁

也就是说出现了"循环依赖".

举个例子:
程序员来到公司楼下,被保安拦住了.
保安: 请出示一码通.
程序员: 我得上楼,修了bug,才能出示一码通.
保安: 你得出示一码通,才能上楼.┗( ▔, ▔ )┛

写成代码:

public class Demo12 {
    private static String lock1 = "";//锁1
    private static String lock2 = "1";//锁2

    public static void main(String[] args) throws InterruptedException {
    	//线程1
        Thread t1 = new Thread(()->{
            synchronized(lock1) {
                System.out.println("t1 lock1");

				//这里的sleep是为了确保t1和t2都分别拿到lock1和lock2,然后再分别拿对方的锁
				//如果没有sleep,那么执行顺序就不可控,可能会出现某个线程一口气拿到两把锁,另一个线程还没执行呢,无法构造出死锁
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized(lock2) {
                    System.out.println("t1 lock2");
                    //没有打印出来.说明被线程1被阻塞了
                }
            }
        });

		//线程2
        Thread t2 = new Thread(()->{
            synchronized(lock2) {
                System.out.println("t2 lock2");
                try {
                    Thread.sleep(1000);
                } catch(InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized(lock1) {
                    System.out.println("t2 lock1");
                    //没有打印出来.说明被线程2被阻塞了
                }
            }
        });

        t1.start();
        t2.start();
    }
}

在这里插入图片描述

3. N个线程 , M个锁

有一个经典模型: 哲学家就餐问题

有5个哲学家坐在一块吃面条,任意一个哲学家想要吃到面条都需要拿起左手和右手的筷子~
这5个哲学家会做两件事:

  1. 思考人生,放下手里的筷子
  2. 吃面条,拿起左右手两边

在这里插入图片描述

通常情况下,这个模型是可以运转的,但是一旦出现极端情况,就会死锁.
比如,每个哲学家同时拿左手边的筷子,此时每个筷子都被拿起来了,哲学家的右手就拿不起筷子了(因为桌子上没有了),由于哲学家非常固执,当他吃不到面条的时候,也绝对不会放下左手的筷子.
于是谁都吃不到面条(哲学家: 没错我就是这么固执 o(´^`)o).

想一想该如何解决上述问题呢?
很简单,给每个筷子编个号(1,2,3,…,N),然后让所有的哲学家先拿起编号小的筷子,后拿起编号大的筷子.
在这里插入图片描述
只要遵守上述的拿起筷子的顺序,无论接下来这个模型的运行顺序如何,无论出现多么极端的情况,都不会再死锁了.

把哲学家看做线程,把筷子看做锁,这就是死锁的第三种情况了~

4. 内存可见性

内存可见性问题是指: 当一个线程对共享变量进行了修改后,其他线程可能无法立即看到这个修改。

这么说可能有点抽象,举个栗子:

import java.util.Scanner;

public class Demo13 {
    private static int n = 0;
    public static void main(String[] args) throws InterruptedException{
        Thread t1 = new Thread(()->{
            while (n == 0) {
                //啥都不写
            }
            System.out.println("t1线程结束");
        });
        Thread t2 = new Thread(()->{
            Scanner scanner = new Scanner(System.in);
            System.out.print("输入n的值: ");
            n = scanner.nextInt();
           System.out.println("t2线程结束");
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("主线程结束");
    }
}

运行结果:
在这里插入图片描述
这这这,这不对吧,我们不是已经输入非0的值了吗,n应该不是0了呀,线程t1中的循环的条件不成立了,t1应该结束啊.
但是实际上,我们输入10后,t1没有任何动静!!
通过jconsole看到t1线程(Thread-0)仍然是持续工作的~
在这里插入图片描述
出现上述问题的原因,就是"内存可见性问题"

为什么会出现内存可见性问题呢?

Thread t1 = new Thread(()->{
            while (n == 0) {
                //啥都不写
            }
            System.out.println("t1线程结束");
        });

在t1线程中的循环会执行非常多次, 每次循环都需要执行n == 0 这样的判定,

  1. 从内存读取数据到寄存器中(读取内存,相比之下,这个操作的非常慢)
  2. 通过类似于cmp指令,比较寄存器和0的值(这个指令的执行速度非常快)

对于计算机来说,存储数据的设备,有一下几个层次

  1. CPU寄存器: 空间小,速度快,成本高,数据掉电后丢失
  2. 内存: 空间中等,速度中等,数据掉电后丢失
  3. 硬盘: 空间大,速度慢,成本低,数据掉电后不丢失

每一个层次之间大约相差3~4个数量级

此时JVM执行这个代码的时候,发现:
每次循环的过程中,执行"读取内存"这个操作,开销非常大.
而且每次执行"读取内存"这个操作,结果都是一样的呀.
并且JVM根本没有意识到,用户可能在未来会修改n
于是JVM就做了个大胆的操作—直接把"读取内存"这个操作给优化掉了.

每次循环,不会重新读取内存中的数据,而是直接读取寄存器/cache中的数据(缓存中的结果)

当JVM做出上述决定之后,此时意味着循环的开销大幅度降低了~
但是当用户修改n的时候,内存中的n已经改变了
但是由于t1线程每次循环,不会真的读内存,于是就感知不到n的改变

这样就引起了bug — “内存可见性问题”

内存可见性问题,本质上,是编译器/JVM 对代码进行优化的时候,优化出bug了
如果代码是单线程的,编译器/JVM对代码的优化一般是非常准确的,优化之后,不会影响到逻辑.

但是代码如果是多线程的,编译器/JVM 的代码优化,就有可能出现误判(编译器 / JVM的bug)
导致不该优化的地方,也给优化了,于是就造成了内存可见性问题.

说点题外话:

编译器为啥要做上述的代码优化,为啥不老老实实地按照程序员写的代码,一板一眼的执行呢?

主要是因为,有的程序员,写出来的代码,太低效了.
为了能够降低程序员的门槛,即使你代码写的一般,最终的执行速度也不会落下风.
因此,主流编译器,都会引入优化机制.
也就是说,编译器会自动调整你的代码,使其在保持原有逻辑不变的情况下,提高代码的执行效率.

编译器优化,本身也是一个复杂的话题
某个代码,何时优化,优化到啥程度,都不好说~
开放编译器的大佬们,有一系列的策略来实现这里的优化功能.
咱们站在外行人的角度,是很难判断某个代码是否优化的. 代码稍微改变一点,优化结果就会截然不同~

解决方法 volatile关键字

如果我们希望代码正常运行,该咋办呢[・ヘ・?]

说白了,之所以会出现"内存可见性问题",这不就是因为编译器优化出bug了吗,我们告诉编译器:“誒,你别优化这里~”.不就可以啦!
锵锵锵锵,"volatile"关键字就可以做到上述操作!

volatile关键字: 修饰一个变量,提示编译器说,这个变量是"易变"的.
编译器进行上述优化的前提,是编译器认为针对这个变量的频繁读取,结果都是固定的.

对变量加上volatile关键字后,编译器就会禁止上述的优化,从而确保每次循环都从内存中重新读取数据~

对volatile关键字更进一步的理解:
在引入volatile关键字后,编译器在生成这个代码的时候,就会在这个变量的读取操作附近生成一些特殊的指令,称为"内存屏障".
后续JVM执行到这些特殊指令,就知道了,不能进行上述优化了~

总结

synchronized:

  • 是可重入锁
  • 可重入锁内部记录了当前是哪个线程持有的锁,后续加锁的时候都会进行判定~
  • 它还会通过一个引用计数,来维护当前的加锁次数,从而描述出何时真正释放锁.

死锁的四个必要条件(缺一不可)[重点]:

  1. 锁是互斥的[锁的基本特性]
  2. 锁是不可抢占的,线程1拿到了锁A,如果线程1不主动释放A,线程2是不能把锁A抢过来的 [锁的基本特性]
  3. 请求和保持.线程1拿到锁之后,不释放A的前提下,去拿锁B [代码结构](我们在写代码时要避免出现锁的嵌套.)
  4. 循环等待 / 环路等待 / 循环依赖. 多个线程获取锁的过程,存在 循环等待~[代码结构]

假设代码按照请求和保持的方式,获取到N个锁,那么该如何避免出现循环等待呢?
一个简单有效的办法: 给锁编号,并约定所有的线程在加锁的时候都必须按照一定的顺序来加锁.

内存可见性问题:

内存可见性问题是指: 当一个线程对共享变量进行了修改后,其他线程可能无法立即看到这个修改。

出现内存可见性问题的原因是编译器会对代码进行优化,结果给整出bug了.

volatile 关键字:修饰某个指定的变量,告诉编译器,这个变量的值是"易变"的,编译器看到这个标志,就不会把读取内存操作,优化成读取寄存器 / cache.也就是说,volatile可以保持指定的变量,对应的内存,总是可见的~


本文到这里就结束了~
╰( ´・ω・)つ──☆✿✿✿

  • 12
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

月临水

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值