死锁的出现场景
1. 一个线程一把锁,这个线程针对这把锁,连续加锁了两次
死锁的场景1:
void func() {
//第一次能够加锁成功
synchronized (this) {
//第二次加锁的时候,锁对象已经被占用了
//第二次加锁就应该阻塞
synchronized (this) {
}
}
}
这个情况在代码实例中,并没有出现死锁,这是因为synchronized针对这个情况做了特殊处理~
C++ / Python中的锁就没有这样的功能,就会死锁(借助第三方库可以实现不出现死锁)
synchronized 是 “可重入锁”, 针对上述一个线程连续加锁两次的情况,synchronized 在加锁的时候,不仅需要判定当前的锁是否是被占用的状态,还要在锁中额外记录一下当前是哪个线程对这个锁加锁了~
对于可重入锁来说,发现加锁的线程就是当前锁的持有线程,并不会真正进行任何的加锁操作,也不会进行任何的"阻塞操作",而是直接往下执行.
那么问题就来了,计算机是怎么知道哪一个是需要真正释放锁的操作呢,换句话说,计算机是怎么知道哪一个是最外层的括号呢 ?
- 针对上述问题,我们可以引入一个计数器~
初始情况下,计数器是0
每次执行到 { 计数器 +1
每次执行到 } 计数器 -1
如果某次 -1 后,计数器为0了,那么就说明这次就要真正的释放锁了~
这是计算机中非常常见的思想方法,它在JVM中的垃圾回收机制,C++智能指针,Linux等等都用到了.
2. 两个线程,两把锁
死锁的场景2:
- 首先线程1 现针对 A 加锁,线程2 针对 B 加锁
- 之后线程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个哲学家会做两件事:
- 思考人生,放下手里的筷子
- 吃面条,拿起左右手两边的筷子.
通常情况下,这个模型是可以运转的,但是一旦出现极端情况,就会死锁.
比如,每个哲学家同时拿左手边的筷子,此时每个筷子都被拿起来了,哲学家的右手就拿不起筷子了(因为桌子上没有了),由于哲学家非常固执,当他吃不到面条的时候,也绝对不会放下左手的筷子.
于是谁都吃不到面条(哲学家: 没错我就是这么固执 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 这样的判定,
- 从内存读取数据到寄存器中(读取内存,相比之下,这个操作执行的速度非常慢)
- 通过类似于cmp指令,比较寄存器和0的值(这个指令的执行速度非常快)
对于计算机来说,存储数据的设备,有一下几个层次
- CPU寄存器: 空间小,速度快,成本高,数据掉电后丢失
- 内存: 空间中等,速度中等,数据掉电后丢失
- 硬盘: 空间大,速度慢,成本低,数据掉电后不丢失
每一个层次之间大约相差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拿到了锁A,如果线程1不主动释放A,线程2是不能把锁A抢过来的 [锁的基本特性]
- 请求和保持.线程1拿到锁之后,不释放A的前提下,去拿锁B [代码结构](我们在写代码时要避免出现锁的嵌套.)
- 循环等待 / 环路等待 / 循环依赖. 多个线程获取锁的过程,存在 循环等待~[代码结构]
假设代码按照请求和保持的方式,获取到N个锁,那么该如何避免出现循环等待呢?
一个简单有效的办法: 给锁编号,并约定所有的线程在加锁的时候都必须按照一定的顺序来加锁.
内存可见性问题:
内存可见性问题是指: 当一个线程对共享变量进行了修改后,其他线程可能无法立即看到这个修改。
出现内存可见性问题的原因是编译器会对代码进行优化,结果给整出bug了.
volatile 关键字:修饰某个指定的变量,告诉编译器,这个变量的值是"易变"的,编译器看到这个标志,就不会把读取内存操作,优化成读取寄存器 / cache.也就是说,volatile可以保持指定的变量,对应的内存,总是可见的~