锁策略
- 乐观锁 vs 悲观锁
- 重量级锁 vs 轻量级锁
- 自旋锁 vs 挂起等待锁
- 读写锁 vs 互斥锁
- 公平锁 vs 非公平锁
- 可重入锁 vs 不可重入锁
- 死锁
- 死锁产生的必要条件
- 如何简单的解决死锁问题
- 小结
这里不是描述的某个特定锁,而是描述的锁的特性,描述的是"一类锁".
乐观锁 vs 悲观锁
乐观锁: 预测在该场景中,不太会出现锁冲突的情况.
悲观锁: 预测在该场景中,非常容易出现锁冲突.
锁冲突: 两个线程尝试获取同一把锁,一个线程能获取成功,另一个线程阻塞等待.
重量级锁 vs 轻量级锁
重量级锁: 加锁的开销比较大(花的时间多,占用系统资源多), 大多是悲观锁.
轻量级锁: 加锁的开销比较小(花的时间少,占用系统资源少), 大多是乐观锁.
自旋锁 vs 挂起等待锁
自旋锁: 是轻量级锁的一种典型实现,在用户态下,通过自旋的方式(while循环), 实现加锁效果的.
自旋锁会消耗一定的cpu资源,但是可以做到最快速度拿到锁.
挂起等待锁: 是重量级锁的一种典型实现, 通过内核态,借助系统提供的锁机制,当出现锁冲突的时候,
会牵引到内核对于线程的调度,使冲突的线程阻塞等待.
挂起等待锁消耗的cpu资源更少,无法保证第一时间拿到锁.
读写锁 vs 互斥锁
读写锁: 很多读数据的线程之间并不互斥,而写操作要求与任何人互斥.
1. 两个线程,一个线程读加锁,另一个线程也是读加锁,不会产生锁竞争.
2. 两个线程,一个线程读加锁,另一个线程是写加锁,会产生锁竞争.
3. 两个线程,一个线程写加锁,另一个线程也是写加锁,会产生锁竞争.
互斥锁: 锁本身是靠互斥性发挥作用的.
公平锁 vs 非公平锁
公平锁: 遵守先来后到的锁.
非公平锁: 那就是不遵守先来后到的锁.
操作系统内部的线程调度是随机的,如果不做任何额外的限制,锁就是非公平锁.
要想实现公平锁,就需要一些额外的数据结构来支持.(需要记录每个线程的阻塞等待的时间).
可重入锁 vs 不可重入锁
不可重入锁: 一个线程,针对同一把锁,连续加锁两次,产生死锁了.
可重入锁: 一个线程,针对同一把锁,连续加锁两次,没产生死锁.
public synchronized void add() {synchronized (this) {count++;}
}
// 先调用方法add(),这里假设加锁成功.
// 接下来进入代码块,再次针对this对象加锁.
我们对this对象加两次锁(如果是静态方法,是针对类对象加锁.普通方法是针对this对象加锁),此时就会产生锁竞争.(这里写的synchronized是可重入锁,并不会产生死锁,只是用它来表示锁)
为什么呢?
在代码块中,this上的锁必须要等到add方法执行完毕释放后,才能释放.可是这里的逻辑是又加了一个锁,add方法没有释放锁,第二次加锁进入阻塞等待,这里就一直等下去了,也就是产生死锁了.
如果是不可重入锁,这把锁不会保存,是哪个线程对它进行加锁,只要它处于加锁状态,
又收到了"加锁"请求,就会拒绝加锁,此时就会产生死锁.
如果是可重入锁,则是会让这把锁保存,是哪个线程对它进行加锁,就会先对比一下,
看看加锁的线程是不是持有这把锁的线程,就可以灵活判定了.
synchronized 是可重入锁.
public synchronized void add() {synchronized (this) {count++;synchronized (this) {count++;}}
}
// 只有第一个synchronized真正加锁了,后面的都只是虚晃一枪,并没有真正加锁.
// 释放锁也是在最外层的synchrinized中释放的.
// 那么我们怎么知道哪个是最后一个锁呢?
// 让锁这里有一个计数器就可以了
死锁
死锁: 多个线程同时被阻塞,它们中的一个或者全部都在等待资源释放.由于线程无限被阻塞,因此程序不可能正常终止.
- 一个线程, 一把锁, 是不可重入锁, 该线程连续加锁两次, 就会出现死锁.
- 两个线程, 两把锁, 这两个线程先分别获取到一把锁, 然后再同时尝试获取对方的锁.
public class Demo27 {private static Object locker1 = new Object();private static Object locker2 = new Object();public static void main(String[] args) {Thread thread1 = new Thread(() -> {synchronized (locker1) {try {Thread.sleep(1000);} catch (InterruptedException e) {throw new RuntimeException(e);}synchronized (locker2) {System.out.println("thread1加入了两把锁");}}});Thread thread2 = new Thread(() -> {synchronized (locker2) {try {Thread.sleep(1000);} catch (InterruptedException e) {throw new RuntimeException(e);}synchronized (locker1) {System.out.println("thread2加入了两把锁");}}});thread1.start();thread2.start();}
}
此时代码就会进入阻塞等待,thread1要想结束,就必须获取到locker2, thread2要想结束,就必须获取到locker1,这两个线程互相冲突,构成死锁.
3. N个线程M把锁
这里有一个哲学家就餐问题.
假设有5个哲学家, 5根筷子,哲学家主要做两件事,
- 思考人生,放下筷子.
- 吃面,会分别拿起左手和右手的筷子,再去夹面条吃.
基于上述模型,绝大多数情况下,这些哲学家都可以很好的工作的.但是如果出现极端情况,就会出现死锁.比如说,同一时刻,5个哲学家都想吃面,同时拿到左手的筷子,此时拿不到右手的筷子,就会进入阻塞等待,会进入死锁.
死锁产生的必要条件
只要破坏其中任意一个条件,就可以避免出现死锁.
- 互斥使用: 一个线程获取到一把锁后,别的线程不能获取到这个锁.(锁的基本特性)
- 不可抢占: 锁只能被持有者主动释放, 而不能被其它线程直接抢走.
- 请求和保持: 一个线程去尝试获取多把锁, 在获取多把锁的过程中, 会保持对第一把锁的获取状态.
- 循环等待: 相当于thread1要想结束,就必须获取到locker2, thread2要想结束,就必须获取到locker1,这种情况.
如何简单的解决死锁问题
针对锁进行编号, 并且规定加锁的顺序.约定,每个线程如果要获取多把锁, 就必须先获取编号小的锁, 后获取编号大的锁.
只要所有的线程加锁顺序,严格遵守上述情形, 就一定不会出现循环等待.
比如在哲学家就餐问题中, 给每个筷子(锁)进行编号, 按照编号从小到大获取,就不会出现循环等待.
小结
本篇博客讲述了关于锁的特性, 有不足的地方还请多多补充, 希望有收获的小伙伴多多支持.