上一篇我们已经知道了synchronized加锁来解决线程安全问题(修改操作不是原子的),synchronized修饰普通方法相当于给this进行加锁,synchronized修饰静态方法相当于给类对象加锁。
synchronized-监视器锁 monitor lock:JVM中采用的一个术语,使用锁的过程中抛出一些异常,可能会看到监视器锁这样的报错信息。
1 synchronized的特性:
1)互斥
多个线程针对同一个对象加锁才会产生互斥(锁冲突/锁竞争),这个上一篇已经说过了
2)可重入
要想理解可重入,我们先来了解一下死锁的一种情况
直接的代码示例:

在这段代码中,第二次synchronized就得阻塞等待,等到第一次加锁被释放,第二次加锁的阻塞才会继续执行。看起来是两次一样的加锁,没有必要,但是在实际开发中,很容易写出这样的代码
比如:


这样的代码,我们稍不注意就会写出来。这样的代码第一次加锁是可以成功的,因为锁没有被使用,第二次进行加锁,锁是被占用的状态,就得阻塞等待。要想解除阻塞,需要往下执行才可以,要想往下执行,就需要等到第一次的锁被释放......,这样的问题,就成为"死锁"。
值得注意的是这只是死锁的一种情况:一个线程,一把锁,连续加锁多次。其他的情况稍后再说
死锁是一个非常严重的bug,会使代码执行到这一块之后,就会被卡住。为了解决上述的问题,Java的synchronized就引入了可重入的概念。
可重入:当某个线程针对一个锁,加锁成功之后,后续该线程再次针对这个锁进行加锁。不会触发阻塞,而是继续往下走。但是如果是其他线程尝试加锁,就会正常阻塞。
可重入锁的实现原理:关键在于让锁对象内部保存当前是哪个线程持有的这把锁。后续有线程针对这把锁加锁的时候,进行对比,看锁持有的线程是否和当前加锁的线程是同一个
可以连续加锁多次:

最外层是真正加锁,最外层也是真正解锁。站在JVM的视角,看到多个}需要执行,那它如何知道哪个}是真正解锁的那个?
会先引入一个变量,计数器。每次触发{,计数器++,每次触发},计数器--,当计数器-- 为0的时候,就是真正需要解锁的时候。
那如何实现一个可重入锁呢?
1)在锁内部记录当前是哪个线程持有的锁,后续每次加锁,都进行判定
2)通过计数器,记录当前加锁的次数,从而确定何时真正解锁
2 关于死锁
上面已经知道了一种情况:一个线程,一把锁,连续加锁多次
第二种情况:两个线程,两把锁,每个线程获取到一把锁之后,尝试获取对方的锁
代码示例:
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(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (locker2){
System.out.println("t1线程两个锁都获取到");
}
}
});
Thread t2 = new Thread(()->{
synchronized (locker2){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (locker1){
System.out.println("t2线程两个锁都获取到");
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
}
值得注意的是:必须是拿到第一把锁,再拿第二把锁。必须是嵌套的关系
这样的代码就构成了死锁
那如果不加sleep,是否还会出现一样的现象呢?
那就得看具体的调度的顺序了。咱们加上sleep是为了确保t1拿到locker1,t2拿到locker2,等待1秒,t1尝试拿locker2,t2尝试拿locker1。如果不加sleep,很可能t1一口气把locker1和locker2都拿了,这个时候,t2还没开动呢,自然就构不成死锁。
第三种情况:N个线程M把锁
一个经典的模型:哲学家就餐问题

哲学家相当于线程。筷子相当于锁。大部分情况下,这个模型可以很好的运转,只有在一些极端情况下会造成死锁。
极端情况:同一时刻,大家都想吃面条,同时拿起了左手边的筷子(如上图所示),此时,任何一个线程都无法拿起右手边的筷子,任何一个哲学家都吃不成面条
那如何避免代码中出现死锁呢???
要想避免,得先知道死锁是如何构成的
构成死锁的四个必要条件(重点)
1)锁是互斥的
一个线程拿到锁之后,另一个线程在尝试获取锁,就得阻塞等待
2)锁是不可抢占(不可剥夺)的
线程1拿到锁,线程2也尝试获取这个锁,线程2必须阻塞等待,而不是线程2直接把锁抢过来
1)和 2)是锁的基本特性,Java的synchronized是遵守这两点的
3)请求和保持
一个线程拿到锁1之后,在不释放锁1的情况下,获取锁2
在上面的哲学家的极端情况中,如果先放下左手的筷子,再拿右手的筷子,就不会够成死锁
那可能就说了,代码中加锁的时候,不去嵌套不就可以了。但是,这种做法,不是通用的,有些时候就得嵌套
4)循环等待
多个线程,多把锁之间的等待过程,构成了"循环"。
A等待B,B也等待A或者A等待B,B等待C,C等待A
解决办法:约定好加锁的顺序,就可以破除循环等待了
比如:约定每个线程加锁的时候,永远是先获取序号小的锁,后获取序号大的锁
代码示例:


运行结果:

避免死锁:
上述1)和 2)的情况是不可避免的,3)可以把嵌套的锁改为并列的锁,4)对加锁的顺序作出约定
3 内存可见性
是造成线程安全问题的原因之一
代码示例:

运行结果:

我们可以看到,虽然输入了非0的值,但是此时t1线程循环并没有结束。很明显,这是个bug,是线程安全问题。
一个线程读取,一个线程修改,修改线程修改的值,但并没有被线程读取到,这就是内存可见性问题。
要想知道内存可见性问题,得先谈谈编译器优化
我们知道程序员的水平是参差不齐的,研究JDK的大佬们,就希望通过让编译器 & JVM对程序员写的代码自动的进行优化。本来程序员写的代码是xxxx,编译器/JVM会在你原有逻辑不变的前提下,对你的代码进行调整,使你的程序效率更高。
值得注意的是,编译器,虽然声称优化操作是能够保证逻辑不变。但是在多线程的程序中,编译器的判断可能会出现失误,可能导致优化后的逻辑和优化前的逻辑出现细节上的偏差
对于
,cmp这样的指令(条件跳转):
,load是读内存操作,cmp是纯cpu寄存器操作,load的时间开销可能是cmp的几千倍。短时间之内,这个循环,就会循环很多次,执行过程中,JVM就能感知到load反复执行的结果好像是一样的。这时,JVM心想:我执行这么多次读flag的操作,值始终都是0,既然都是一样的结果,何必要反复执行这么多次呢。
这个flag的值取决于用户输入,不知道用户过多久才能输入
于是就把读内存的操作优化成读取寄存器的操作(把内存的值读到寄存器了,后续再load,就不再重新读内存了,而是直接从寄存器里取)。于是等到很多秒之后,用户真正输入新的值,真正修改flag,此时t1线程就感知不到了(编译器优化,使得t1线程的读操作,不是真正读内存)
如果我们微调上述代码,就会得到不一样的结果
代码示例:

运行结果:

本来这个while循环转的飞起,1秒钟转几千万次......,但是加了sleep(1)之后,循环次数大幅度下降了,当引入sleep之后,sleep消耗的时间相比于上面的load flag的操作,就不知道高了多少了。
但是针对内存可见性问题,不能指望通过sleep来解决,因为使用sleep会大大影响到程序的效率。
那该如何解决呢?
在语法中,引入了volatile 这个关键字,通过这个关键字来修饰某个变量 ,此时编译器通过对这个变量的读取操作,就不会优化成读寄存器。
代码示例:

运行结果:

OK啦,到此结束!!!!!