目录
[1. 一个线程,一把锁。](#1. 一个线程,一把锁。)
[3. N 个线程 M 把锁](#3. N 个线程 M 把锁)
小结复习:
线程安全:
-
【根本原因】 线程的随机调度,抢占式实行(罪魁祸首,万恶之源),多个线程执行的顺序,存在诸多变数,我们要做的就是需要保证任何一种变数下,执行结果都是正确的,无 bug 的。
-
多个线程同时修改同一个变量。
-
修改操作不是原子的。count++ 本质上是 3 个指令,load,add,save...类似的 +=,-= 也是非原子的,但是 = 这种操作,一般是原子的。
-
内存可见性
-
指令重排序
如何解决线程安全问题?
加锁 ==》 synchronized 关键字,(锁对象),锁对象随便放一个 Object 都行,需要关注两个线程是否是针对同一个对象加锁。{代码块},Java 代码进入代码块,加锁,离开代码块,解锁。
synchronized 修饰普通方法,相当于给 this 加锁(锁对象 this),synchronized 修饰静态方法,相当于给类对象加锁。
synchronized
synchronized 加锁的效果,也可以称为"互斥性"。synchronized 还有一些其他的特性。
举一个例子:

上述例子,是否可以正常运行,打印 hello 呢? absolutely,可以正常打印 hello。
背后的运行过程:在第一个 synchronized 中,针对 locker 进行加锁,这里的加锁是可以顺利获取到的。下一个 synchronized 中,直观上感觉,这个加锁,应该不能成功呀,此时的 locker 对象处于一句加锁的状态,这个时候,如果要是再尝试对 locker 进行加锁,不应该会出现"阻塞"的情况吗?
但程序又是可以正常运行的呀,关键的问题是,这两次加锁,其实是在同一个线程中进行的。
当前由于是同一个进程,此时锁对象,是知道,第二次加锁的线程,就是持有锁的线程,第二次操作,就可以直接放行通过,不会出现阻塞。
这个特性,就被称为 "可重入性",使用可重入锁,就可以避免上述代码出现死锁的情况。
那这种双重加锁有什么应用场景吗? ==》 根本没有,这种情况本身就是代码有问题,没有应用场景,但是我们写代码的时候,很容易一不小心,就写出这种双重加锁的效果,可重入锁,就是为例防止,我们在"不小心"中,引入问题,即,就算我们不小心写了双重加锁,也不至于出现死锁情况。
如下:

直观上看,每个地方都只是加了一次锁,但是由于复杂的调用关系,就可能导致,加锁重复了。
那这种情况,就是默认把多个锁看作是一个锁吗? 不完全是。

如上图,红色圈 1,此时是真正的加锁,同时 synchronized 中的计数器会 +1(初始情况为0, +1 之后变成了1,说明当前这个对象被该线程加锁了)同时记录线程是谁。
红色圈 2,是第二次加锁的时候,发现加锁线程和持有锁的线程是同一个线程,则计数器++,没有别的操作了,如果不是同一个线程,则会发生阻塞。
蓝色圈 3,把计数器 -1,2 =》 1,由于计数器不为0,不会真的解锁。
蓝色圈 4,再把计数器 -1,1 =》 0,此时计数器归零,真正进行解锁了。
对如可重入锁来说,内部会持有两个信息 ==》 1. 当前这个锁是被那个线程所持有的 2.加锁次数的计数器。
注意,虽然有两个 synchronized,但是,只有一个锁对象,只有一把锁!
总结:
如果对一个线程,不小心使得有多个 synchronized 的情况,则会在最外层的 { 进行加锁,在最外层的 } 进行解锁。
这样,即使上述是 synchronized 嵌套 10 层或者 8 层的,也不会使得解锁操作混乱,并且始终能够保证在正确的时机解锁。
此处的计数器,是真正用来识别解锁时机的关键要点,这个源码是在 JVM 中利用 C++ 代码实现的,在 IDEA 中看不到。
"死锁"
死锁,是多线程代码中的一类经典问题,加锁是能解决线程的安全问题,但如果加锁方式不当,就有可能产生死锁!!!
死锁的三种经典场景:
1. 一个线程,一把锁。
就像上面的场景,如果上面的锁是不可重入锁,并且一个线程对这把锁,加锁两次,就会出现死锁现象。(钥匙锁在屋子里了)
2.两个线程,两把锁。
线程 1 获得到锁 A,线程 2 获得到锁 B,接下来,1 尝试获取锁 B,2尝试获取锁 A,就同样出现死锁了!!!一旦出现死锁,线程就"卡住了",无法继续工作,死锁,是属于进程中最严重的一类 bug !!!
示例如下:

运行上述进程,我们可以使用 jconsole 来观察
Thread - 0 被 Thread - 1锁住了,状态为 BLOCKED

Thread - 1 被 Thread - 0锁住了,状态为 BLOCKED

3. N 个线程 M 把锁
这种情况,就会牵扯到一个被称为 "哲学家就餐" 的问题。
假设有五个哲学家围坐在一张圆桌旁,他们的生活方式就是思考和进餐。圆桌上有五根筷子,每两个哲学家之间放一根。哲学家在思考时不需要任何资源,而当他们进餐时,必须同时拿起左右两边的筷子。如果筷子已经被其他哲学家占用,那么该哲学家必须等待,直到筷子可用。

如果某个哲学家,在吃中间的面条的过程中,旁边的两位哲学家,就需要阻塞等待(五个筷子,就相当于五把锁,每个哲学家,就是一个线程)。当线程拿到锁的时候,就会一直持有,除非他吃完了,主动放下筷子,(哲学家都是有身份的人),其他哲学家不能硬抢。
虽然筷子的数量并不充裕,但其实也还还好,每个哲学家,除了吃面条之外,还要做一件事,"思考人生",在做这件事的时候,哲学家是会放下筷子的。
由于每个哲学家,什么时候吃面条,什么时思考人生,这个事情是不确定的(随即调度),绝大部分情况下,上述模型都是可以正常工作的。
但是,有一些极端的特殊情况,是无法正常工作的。
假设同一时刻,所有的哲学家,都想要吃面条,同时拿起了左手的筷子,这个时候,他们继续尝试拿起右边的筷子,唉,发现拿不起来了,右边的筷子被别人给拿着了!!!

此时,由于所有的哲学家,都不想放下已经拿起来的筷子,就要等待旁边的人放下筷子...没有人能吃到面,也就没有人释放,也就形成死锁了。
解决死锁问题,方案有很多种:
先解释一下,产生死锁的 四 个必要条件:
-
互斥使用,获得锁的过程是互斥的。即,一个线程拿到了这把锁,另一个线程如果也想获取,就需要阻塞等待。
-
不可抢占。一个线程拿到锁之后,就只能主动解锁,不能让别的线程,强行把锁抢走。
-
请求保持。一个线程拿到锁 A 之后,在持有 A 的前提下,尝试获取 B。
-
循环等待 / 环路等待。
那如何解决死锁问题呢?核心思路,就是破坏上述的 四 必要条件呗,且只要能破坏其中一个,就可以解决死锁问题。
1.互斥使用。这是锁的最基本特性,我们不太好破坏。
-
不可抢占。同样的,这也是锁的最基本特性,同样不好破坏。
-
请求保持。 这个操作是取决于代码结构的,不一定可以破坏,要看实际的代码需求。
4.循环等待 / 环路等待。 这个是代码结构中,且是最容易破坏的。
我们可以指定加锁顺序,针对五把锁,都进行编号,约定每个线程获取锁的顺序,一定要先获取编号小的锁,后获取编号大的锁。

如果我们指定了加锁顺序,也就是指定了,哲学家拿筷子顺序,因为哲学家只能拿自己面前的两只筷子,且我们又指定了,要先获取编号小的筷子。
那么,如果从 2 号哲学家开始,当他拿筷子的时候,眼前的 1 2 两只筷子,他需要先拿 1 筷子:

2 号哲学家拿完之后,轮到 3 号哲学家,因为我们指定,需要拿编号小的筷子,所以他只能拿 2 号筷子。

4 5 号哲学家类似,他们分别只能拿 3 4 号筷子

但轮到 1 号哲学家的时候,因为我们指定了,要先获取编号小的筷子,即 1 号哲学家,要先获取面前的 1 号筷子,但此时不好意思,2 号哲学家正霸占着 1 号筷子,1 号哲学家就只能等着啦(阻塞等待)。但其实这时候 5 号筷子是空闲的,则 5 号哲学家,就可以拿 5 号筷子,和 4 号筷子组成一双筷子吃面啦,当 5 号哲学家吃完后,放下 4 5 号筷子,4 号哲学家就可以吃面了,依次 3 2 号哲学家可以吃面了,当 2 号哲学家吃完之后,放下 1 号筷子,1 号哲学家真的痛哭流涕,终于能轮上他吃面了...(虽然吃的迟,但也迟到了,这算是牺牲小自我,换取大家都能吃上面...)
对于我们的示例代码,只需要修改一下线程 2 中 锁的顺序即可打破死锁啦!

补充:那能不能我们指定,编号小的哲学家先吃面呢? ==》这是不可行的,因为大前提是"随机调度" "抢占式执行",想办法让某个线程先加锁,未被了"随机调度"的根本原则,可行性是不高的,而约定加锁顺序,在写代码的层面上是非常容易做到的。'
解决死锁,其实是有很方案:
-
引入额外的筷子 ==》 引入额外的锁
-
去掉一个线程
-
引入计数器,限制最多同时多少个线程存在
上面三种方案,其实现虽然并不复杂,但其普适性并不是很高,有时候用不了。
-
引入加锁顺序的规则...(这种方法,普适性非常高,方案容易落地实现)
-
学校中的操作系统课程中,会有一种"银行家算法",这个方案,确实可以解决死锁问题,但我们在实际开发中,一般不会这么做。因为这种方法实在的太复杂了,为了解决死锁问题,实现"银行家算法"....死锁解没解决不确定,搞不好,我们在实现"银行家算法"的过程中,就出现了bug(即这种方法,理论上是可行的,实际中并不推荐。)