1. 说在前面
这里的锁策略内容,属于典型的面试八股文!如果未来工作,需要实现一把锁,那么得好好研究下锁策略,但基本上不会让我们自己设计一把锁的。
而这里的锁策略内容不局限于 Java,任何 "锁" 相关的话题都是可以应用本节锁策略的!
对于咱们来说,了解下锁策略也不是坏事,对于合理使用锁也是有点帮助的。
2. 常见的锁策略
2.1 乐观锁和悲观锁
乐观锁:预测锁竞争不是很激烈(做的工作相对更少)
悲观锁:预测锁竞争特别的激烈(做的工作相对更多)
如何理解乐观锁和悲观锁?
乐观锁又乐观在哪呢?乐观锁会假设数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会正式的对数据是否产生冲突而发生检测,如果并发冲突了,返回用户错误的信息,让用户决定如何去做。
悲观锁又悲观在哪呢?悲观锁每次都会假设是最坏的情况,所以每次去拿数据的时候都认为别人会修改,所以每次拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞等待!
上述干巴巴的叙述感觉还是很抽象,这里我们举一个现实中的例子吧:
我每天很忙,假设这时候有个妹子想找我陪她去逛街,那么妹子如何跟我表达她内心的想法呢?妹子有两种方案:
● 妹子并不觉得我很忙,觉得我肯定有时间能陪她逛街,所以妹子就直接来找我了(没有加锁,直接访问资源),如果我此时确实闲着了,那么就能直接陪这个妹子逛街了,如果我现在很忙,妹子来了后看见我在忙,就不会打扰我了,就会过一会再来找我(虽然没加锁, 但是能识别出数据访问冲突) 这就是乐观锁。
● 妹子是一个谨慎的妹子,总觉得我天天很忙,可能没时间陪她逛街,于是在找我之前,就给我发个消息:"你现在有时间没我逛街吗?"(相当于尝试加锁),如果我现在闲着,肯定会回复她有时间,于是等得到我的答复后,她才会过来找我逛街,如果我很忙,那就自然拒绝她了,那她就会等一段时间再来发消息问问我有没有时间陪她逛街,这就是悲观锁。
对于乐观锁和悲观锁,谁好谁坏具体看场景需求,具体在后面介绍 synchronized 章节介绍。
2.2 轻量级锁和重量级锁
轻量级锁:加锁解锁开销比较小,效率更高,多数情况下,乐观锁也是一种轻量级锁
重量级锁:加锁解锁开销比较大,效率更低,多数情况下,悲观锁也是一种重量级锁
在了解轻量级锁和重量级锁之前,我们需要理解为什么锁能保证原子性?
锁能保证原子性,追根溯源是 CPU 这样的硬件设备提供的机制。
首先 CPU 提供了 "原子操作指令",操作系统基于 CPU 的原子指令,实现了 mutex 互斥锁,JVM 基于操作系统提供的 mutex 互斥锁,实现了 synchronized 和 ReentrantLock 等关键字和类。
那么问题来了,轻量级锁相较于重量级锁,为什么轻在加锁解锁上?
轻量级锁加锁机制尽可能不使用 mutex,也就是不适用操作系统提供的互斥锁,而是尽量在用户态代码完成加锁解锁操作,实在搞不定,再使用 mutex,这样一来,就会进行少量的内核态和用户态切换,不太容易引发线程调度。
重量级锁加锁机制重度依赖了操作系统提供的 mutex,这样会大量引起内核态用户态切换,也很容易引发线程的调度,前面我们讲解线程池的时候,也了解过用户态是可控的,所以重量级锁的成本会更高,涉及到交给内核办事,就意味着,你不知道内核身上背负着多少的任务,效率自然也就不明确了 。
2.3 自旋锁和挂起等待锁
自旋锁是一种典型的轻量级锁,挂起等待锁是一种典型的重量级锁。
如何理解这两把锁呢,此时举一个形象的例子来方便大家理解:
有一个痴情小伙,准备跟心系已久的妹子表白(想对妹子加锁),没想到被妹子发了好人卡,原因居然是妹子已经有男朋友了(被其他人加锁了)。
这个小伙子虽然被妹子拒绝了,但是小伙很坚强!心里不甘!于是就想着妹子分手后他去上位!
此时妹子还在谈恋爱呢,小伙子是干等着吗?不一定!小伙子此时就有两种做法:
每天都跟妹子聊天,有事没事给妹子点外卖请喝奶茶,这样一来,一旦妹子分手了,由于小伙子天天与妹子聊天,所以小伙是能第一时间知道妹子分手的,也就是一旦锁被释放,就能第一时间感知到,从而有机会获取到锁,很明显这样很舔,也就相当于随时都在被 CPU 调用,随时都在判断锁有没有被释放,显然占用了大量的系统资源,这就是自旋锁。
小伙子被妹子拒绝了后,就默默在心里爱着她,默默的等着她,也不打扰妹子的恋爱生活,只是想着有一天妹子分手了,再去追求,过了一段时间,妹子分手了(锁被释放),可能会想起来这个小伙子,这时小伙子才有可能获取到锁,但是由于小伙子默默无闻,所以妹子大概率已经把小伙子给忘了,等想起来小伙子的时候,可能又谈了好几个男朋友了,这样的小伙子没有无时无刻在关注妹子是否分手,就相当于减少了对 CPU 的占用率,减少的时间 CPU 能去干其他事,但是啥时候能获取到锁,大概率是没有自旋锁来的快,这就是挂起等待锁!
2.4 互斥锁和读写锁
互斥锁就是前面学习过的 synchronized 这样的锁,提供加锁和解锁,如果一个线程对 A 加锁了,另一个线程也尝试对 A 加锁,此时就会阻塞等待。
读写锁见名知意,执行加锁的时候,需要额外表明读写意图,一个线程对于数据的访问,主要是存在两个操作:读数据和写数据。
两个线程同时读一个数据,并没有线程安全问题!
两个线程都去写一个数据,是有线程安全问题的!
一个线程读,一个线程写同一个数据,是有线程安全问题的!
读写锁就是把读操作和写操作区别对待了,Java 标准库提供了 ReentrantReadWriteLock 类,实现了读写锁。
通过 ReentrantReadWriteLock 对象里的方法,可以获取读锁和写锁!
针对读加锁:ReentrantReadWriteLock.ReadLock,这个类表示了一个读锁,提供了 lock 和 unlock 方法进行加锁和解锁。
针对写加锁:ReentrantReadWriteLock.WriteLock,这个类表示了一个写锁,提供了 lock 和 unlock 方法进行加锁和解锁。
这里我们用两个线程尝试都就加读锁,看看会不会有互斥效果:
public static void main(String[] args) {
ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock();
// readLock 是一个读锁, 提供 lock() 加锁, unlock() 解锁
// reentrantReadWriteLock.readLock(); 获取读锁
ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock.readLock();
Thread t1 = new Thread(() -> {
readLock.lock();
System.out.println("t1 针对读加锁了");
try {
Thread.sleep(10_000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
readLock.unlock();
});
Thread t2 = new Thread(() -> {
readLock.lock();
System.out.println("t2 针对读加锁了");
readLock.lock();
});
t1.start();
t2.start();
}
上述代码,一执行 t1 线程就会对 reentrantReadWriteLock 对象加读锁,然后打印,接着休眠 10s,再解锁,此时如果发生锁互斥,t2 就得等 t1.unlock() 之后才能执行 readLock.lock() 加锁操作,才能接着往后执行。
但是实际上 t2 并不用等 t1 解锁后才能加锁,因为 t1 和 t2 都是加的读锁,不会产生互斥!所以 t1 和 t2 是并发执行的!并不会受到加锁的影响,所以执行代码会发现 t2 并不会由于没有竞争到锁而阻塞等待,而是与 t1 并发执行!
那么一个线程加读锁,一个线程加写锁,是否会产生互斥呢?
public static void main(String[] args) {
ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock();
// readLock 是一个读锁, 提供 lock() 加锁, unlock() 解锁
ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock.readLock();
// writeLock 是一个读锁, 提供 lock() 加锁, unlock() 解锁
ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock.writeLock();
Thread t1 = new Thread(() -> {
readLock.lock();
System.out.println("t1 针对读加锁了");
try {
Thread.sleep(10_000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("t1 解锁了!");
readLock.unlock();
});
Thread t2 = new Thread(() -> {
writeLock.lock();
System.out.println("t2 针对写加锁了");
writeLock.lock();
});
t1.start();
t2.start();
}
这段代码就是 t1 线程对 reentrantReadWriteLock 加读锁,t2 线程对 reentrantReadWriteLock 加写锁,执行上述代码,t1 已经加读锁了,此时 t2 想加上写锁就得等!等 t1 执行到 readLock.unlock() 把这个 reentrantReadWriteLock 的读锁给释放了(解锁),此时 t2 才能加锁!
所以执行上述程序,就会发现,t1 执行完了,t2 才能加上写锁并往后执行代码。所以加写锁和读锁之间,存在互斥!
解答疑问:
可能看到这,有些小伙伴还有点懵,什么读锁,写锁的,这里我们一定要弄清楚,这里的读锁和写锁是从哪来的!
是通过这个类: ReentrantReadWriteLock ,实例化的对象,调用对应方法,来获取一个能加锁解锁的对象的,如果调用 readLock() 就能得到读锁,如果调用 writeLock() 就会得到写锁,所以我们上述说加读锁,加写锁,本质都是对 ReentrantReadWriteLock 实例出来的对象进行加读锁或写锁!
那么 t1 加写锁,t2 加写锁,是否也会互斥呢?也是会的!具体大家可以下来自行实验下!
总结:
读锁和读锁之间没有互斥!
写锁和读锁之间存在互斥!
写锁和写锁之间存在互斥!
2.5 公平锁和非公平锁
公平不公平?如何理解?这是一个值得思考的问题!
这里就不讨论哲学问题了,直接来学习什么是公平锁,什么是非公平锁。
假设有好多人在追同一个妹子!但是这个妹子在谈恋爱,所以追这个妹子的人只能等着!
此时妹子分手了,谁上位呢?公平锁就是谁先来的,也就是谁最先阻塞等待,谁就能在锁释放后(妹子分手),第一时间获取锁,后面的则继续阻塞等待!操作系统内部线程的调用,是随机的,想实现公平锁,就需要依赖额外的数据结构,记录线程的先后顺序了。上述情况就是公平锁!
什么是非公平锁?
这种情况就是非公平锁,一旦某个线程释放了锁,此时等待该锁的线程都会蜂拥而上,也就会出现锁竞争,那么谁能获取到锁呢?此时就是看 CPU 随机调度了,调度到谁,那就是谁获取到锁!
2.6 可重入锁和不可重入锁
这个我们前面讲死锁的时候讲到过,如果一个线程对同一个锁对象加锁两次,出现了死锁,那么就是不可重入锁。而 synchronized 是一把可重入锁!
那么这里我们就直接说结论了:
如果一个线程对同一个对象加锁两次,出现死锁了,这就是不可重入锁。
如果一个线程对同一个对象加锁多次,都不会出现死锁,这就是可重入锁。
下期预告:【多线程】CAS原理