(目录)
一、常见的锁策略
1. 悲观锁 VS 乐观锁
悲观锁 :
总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别
人想拿这个数据就会阻塞直到它拿到锁。
预期锁冲突的概率很高
就是它认为,只要它一加锁,就会出现锁冲突。
乐观锁:
假设数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会正式对数据是否产生并发冲
突进行检测,如果发现并发冲突了,则让返回用户错误的信息,让用户决定如何去做.
预期锁冲突的概率很低
就是说:它加锁没有那么容易就会出现锁冲突。
思考一个问题:
是悲观锁背后做的事情多,还是乐观锁背后做的事情多?
悲观锁的事情做得多
举例:
疫情期间,谁也不知道下一步疫情会不会更严重, 疫情一旦严重,吃饭都成问题。可能会买不到菜! 悲观锁,就是在认为下一时刻就会出现这样的问题! 为此,它去超市菜场买了大量的各种各样的生活用品 和 菜,屯在家里,以防不时之需。 乐观锁,就是认为在国家的管控下,疫情很难复发。为此它认为不需要屯那么多货。 即使疫情真的复发,也不会支持太久,菜也应该是能买到,物资完全够用。
更直观来说:悲观锁,所做的一切,是需要花费大量的钱财,买来的东西也需要空间来存放,还费时间。
【放在代码中就是 执行效率低,还需要占用一定的空间。简单来说就需要花费大量的资源】相比于乐观锁,悲观锁做的事情实在太多了。
要做的事情更多,就以为意味着要付出更多的成本和代价。 所以说:乐观锁做的事情很少
,整体较轻量
。我们就认为乐观锁更高效
、
悲观锁做的事情很多
,整体较重
。我们就认为 悲观锁更低效
。
分析:
这两种思路不能说谁优谁劣, 而是看当前的场景是否合适.
如果资源确实比较忙, 那么使用悲观锁的策略更合适, 使用乐观锁会导致 “白跑很多趟”, 耗费额外的资源.
如果资源确实比较闲, 那么使用乐观锁的策略更合适, 使用悲观锁会让效率比较低.
总的来说:
悲观锁,做的工作更多,付出的成本更多,更低效
乐观锁,做的工作更少,付出的成本更低,更高效
2. 读写锁 vs 普通的互斥锁
对于
普通的互斥锁
,只有两个操作。
加锁
解锁
==只要两个线程对同一个对象加锁,就会产生互斥==
对于读写锁来说,分成了三个操作
加读锁:
如果代码只进行了读操作,就加读锁加写锁:
如果代码进行了修改操作,就加写锁解锁:
针对读锁和读锁之间,是不存在互斥关系的 读锁和写锁之间,写锁和写锁之间,才需要互斥
对于读写锁
,就相当于将 读锁 和 写锁 给分开了
。
分开了有什么好处呢 ?
我们针对
读锁 和 读锁 之间,是不存在互斥关系的
。这是因为多线程同时读取一个数据,是
不会有线程安全的问题
的,只有修改才会存在线程安全问题
。
读锁 和 写锁 之间,写锁和写锁之间,才需要互斥
。 因此,我们的读写锁,就在读写操作之间,给它天然分离出来了。而且,我们在很多实际场景中都是读操作多,写操作少。
数据库中的索引就适用于读多写少的情况,因为我们去针对一个有索引的表进行修改,这个操作本身就很低效,但是如果是查询(读操作),那就非常的高效。 多线程的情况也一样,很多场景都是读多写少。 那么这个时候,本来 读 和 读 之间,就没有线程安全问题,也就不需要互斥。 我们就直接让它们共享就行了。 这就是读写锁要起到的一个效果。
关键就是将读操作单独拿出来,让读与读操作之间,不用互斥了
因此,我们的
读取数据的速度就会变快了
读写锁在Java 标准库提供了 ReentrantReadWriteLock 类, 实现了读写锁.
Reentrant: 可重入
ReadWriteLock:读写锁
-
ReentrantReadWriteLock.ReadLock 类
表示一个读锁
. 这个对象提供了lock / unlock
方法进行加锁解锁. -
ReentrantReadWriteLock.WriteLock 类
表示一个写锁
. 这个对象也提供了lock / unlock
方法进行加锁解锁.
3. 重量级锁 vs 轻量级锁
这个两个锁 和 上面的悲观乐观两个锁,含义上有一定的重叠。
可以这么理解:
上面的悲观和乐观锁,这是原因;
而重量级和轻量级锁,这是结果。
也就可以这么去认为:
悲观锁
一般
都是重量级锁。乐观锁
一般
都是轻量级锁。
重量级锁就是做了更多的事情,开销很大。
轻量级锁,做的事情很少,开销也就很小。
重量级锁和轻量级锁
,表示的是“处理锁冲突的结果
”。就是说:我们已经处理好锁冲突,已经把代码实现了。
然后,发现这种实现方式有点“重”(开销有点大),或者说发现这种实现方式 “很轻”(开销很小)。 这么说吧,因为我们对这件事很悲观,所以,我们做了更多的操作。
也就是 悲观在前,实现后重量更重 在后。
更具体的来说:
在使用的锁中,如果
锁是基于内核的功能来实现
的。【内核态】
比如:调用了
操作系统提供的 mutex 接口
,此时一般认为这是一个重量级锁
。因为操作系统的锁,会在内核中做很多的事情,开销也就很大,“重量”有点重。 如果
锁是用户态去实现的
,此时一般认为这是一个轻量级锁
。一般认为
用户态的代码要更可控,也更高效
。
4. 挂起等待锁 vs 自旋锁
挂起等待锁:
往往就是通过内核的一些机制来实现的
,往往较重
[重量级锁的一种典型实现]自旋锁
:往往是通过用户态代码来实现的
,往往较轻
[轻量级锁的一种典型实现]
==理解自旋锁 vs 挂起等待锁==
想象一下, 去追求一个女神. 当男生向女神表白后, 女神说: 你是个好人, 但是我有男朋友了~~
挂起等待锁:
陷入沉沦不能自拔… 过了很久很久之后, 突然女神发来消息, “咱俩要不试试?”(
注意
,这个很长的时间间隔里, 女神可能已经换了好几个男票了).
自旋锁:
死皮赖脸坚韧不拔. 仍然每天持续的和女神说早安晚安. 一旦女神和上一任分手, 那么就能立刻抓住机会上位
小结
想必大家会发现,除了读写锁比较好理解之外,这四种锁策略,翻来覆去好像都是讲得同一件事。
其实也确实如此。只不过就是说法,越来越详细。 其中悲观和乐观锁的说法是最笼统的。 而重量级锁 和 轻量级锁 的 说法稍微详细了一点点。 最后的挂起等待所 和 自旋锁 的说法,就涉及到更具体的实现了。 所以上面的 策略,这四组锁策略,其实之间的描述是非常接近的。只不过就是把抽象的概念,一步步的变得详细,更狭义。 我们当前可以将它当做是同一个东西。 上述的这些锁策略之间,彼此并不是完全没有没有联系,而是有着千丝万缕的关系。 所以大家也不要去把它们分割开来,去看待。 其实它们就是说的就是同一件事。 只不过是一件事,站在不同的角度,不同的范围来去进行描述。
5. 公平锁 VS 非公平锁
这俩可能有点容易搞反,要记住
公平锁:
多个线程在等待一把锁的时候,谁是先来的,谁就先获得这把锁(遵循先来后到原则
)非公平锁:
多个线程等待同一把锁,不遵守先来后到原则
,每个人等待线程获取锁的概率是均等的
对于操作系统来说,本身线程之间的调度就是随机的(机会均等)
,操作系统提供的mutex这个锁,就属于非公平锁
线程之间不是存在优先级嘛?优先级难道不会影响调度吗?
是的,会影响。所以,我们这里考虑的是优先级相等的情况下。 其实开发中很少会手动修改线程的优先级。 改了之后,在宏观上的效果并不明显。 要想
实现公平锁,反而要付出更多的代价
需要弄个队列
,来把这些参与竞争的线程给排序一下(先来后到)。
6. 可重入锁 和 不可重入锁
一个线程针对一把锁,能连续加锁两次,
会死锁
就是不可重入锁
,
不会死锁
就是可重入锁
重入锁实现
`可重入性原理或机制是:每一个锁关联一个线程持有者和计数器,当计数器为 0 时表示该锁没有被任何线程持有,那么任何线程都可能获得该锁而调用相应的方法;当某一线程请求成功后,JVM会记下锁的持有线程,并且将计数器置为 1;此时其它线程请求该锁,则必须等待;而该持有锁的线程如果再次请求这个锁,就可以再次拿到这个锁,同时计数器会递增;当线程退出同步代码块时,计数器会递减,如果计数器为 0,则释放该锁。
理解 “把自己锁死”
一个线程没有释放锁, 然后又尝试再次加锁
// 第一次加锁, 加锁成功
lock();
// 第二次加锁, 锁已经被占用, 阻塞等待.
lock();
例子:
一个人闪现进了上锁的厕所,里面出不去,外面进不来
synchronized 与 锁策略的对应关系
谈谈常用的synchronized这把锁:
1:既是一个乐观锁,也是一个悲观锁(根据竞争的激烈程度,自适应)
2:不是读写锁,只是一个普通互斥锁
3:既是一个重量级锁,也是一个轻量级锁(根据竞争的激烈程度,自适应)
4:轻量级锁的部分基于自旋来实现,重量级的部分基于挂起等待锁来实现
5:非公平锁
6:可重入锁
二、相关面试题
1. 你是怎么理解乐观锁和悲观锁的,具体怎么实现呢?
悲观锁认为多个线程访问同一个共享变量冲突的概率较大, 会在每次访问共享变量之前都去真正加锁。 乐观锁认为多个线程访问同一个共享变量冲突的概率不大. 并不会真的加锁, 而是直接尝试访问数据. 在访问的同时识别当前的数据是否出现访问冲突.
悲观锁的实现就是先加锁(比如借助操作系统提供的 mutex), 获取到锁再操作数据. 获取不到锁就等待.
乐观锁的实现可以引入一个版本号. 借助版本号识别出当前的数据访问是否冲突.
2. 介绍下读写锁?
读写锁就是把读操作和写操作分别进行加锁.
- 读锁和读锁之间不互斥.
- 写锁和写锁之间互斥.
- 写锁和读锁之间互斥.
读写锁最主要
用在 “频繁读, 不频繁写” 的场景中
3. 什么是自旋锁,为什么要使用自旋锁策略呢,缺点是什么?
如果获取锁失败, 立即再尝试获取锁, 无限循环, 直到获取到锁为止. 第一次获取锁失败, 第二次的尝试会在极短的时间内到来. 一旦锁被其他线程释放, 就能第一时间获取到锁.
相比于挂起等待锁优点:
没有放弃 CPU 资源, 一旦锁被释放就能第一时间获取到锁, 更高效. 在
锁持有时间比较短的场景
下非常有用.
缺点:
如果锁的持有时间较长, 就会浪费 CPU 资源
4. synchronized 是可重入锁么?
是可重入锁.
举例:
public class Xttblog extends SuperXttblog {
public static void main(String[] args) {
Xttblog child = new Xttblog();
child.doSomething();
}
public synchronized void doSomething() {
System.out.println("child.doSomething()" + Thread.currentThread().getName());
doAnotherThing(); // 调用自己类中其他的synchronized方法
}
private synchronized void doAnotherThing() {
super.doSomething(); // 调用父类的synchronized方法
System.out.println("child.doAnotherThing()" + Thread.currentThread().getName());
}
}
class SuperXttblog {
public synchronized void doSomething() {
System.out.println("father.doSomething()" + Thread.currentThread().getName());
}
}
结果:
child.doSomething()Thread-5492
father.doSomething()Thread-5492
child.doAnotherThing()Thread-5492
现在可以验证出 synchronized 是可重入锁了吧!因为这些方法输出了相同的线程名称,
表明即使
递归使用 synchronized也没有发生死锁
,证明其是可重入的。
还看不懂?那我就再解释下!
这里的对象锁只有一个,就是 child 对象的锁,当执行 child.doSomething 时,该线程获得 child 对象的锁,在 doSomething 方法内执行 doAnotherThing 时再次请求child对象的锁,因为synchronized 是重入锁,所以可以得到该锁,继续在 doAnotherThing 里执行父类的 doSomething 方法时第三次请求 child 对象的锁,同样可得到。
如果不是重入锁的话,那这后面这两次请求锁将会被一直阻塞,从而导致死锁。
所以在 java 内部,同一线程在调用自己类中其他 synchronized 方法/块或调用父类的 synchronized 方法/块都不会阻碍该线程的执行。就是说同一线程对同一个对象锁是可重入的,而且同一个线程可以获取同一把锁多次,也就是可以多次重入。因为java线程是基于“每线程(per-thread)”,而不是基于“每调用(per-invocation)”的(java中线程获得对象锁的操作是以线程为粒度的,per-invocation 互斥体获得对象锁的操作是以每调用作为粒度的)。
可重入锁指的就是连续两次加锁不会导致死锁
.【一个线程,针对一把锁的情况
】实现的方式是在锁中记录该锁持有的线程身份, 以及一个计数器(记录加锁次数). 如果发现当前加锁的线程就
是持有锁的线程, 则直接计数自增.【解锁,就只直接计数自减,为0就解锁成功了】