(各位观众老爷下午好, 创作不易勒, 大家多多点赞收藏😘)
咱们废话不多讲, 下面细🔒
目录
前言
乐观锁 VS 悲观锁
乐观锁
悲观锁
悲观乐观锁优缺点
轻量级锁 VS 重量级锁
重量级锁
轻量级锁
读写锁
自旋锁
互斥锁
可重入锁 VS 不可重入锁
死锁
公平锁 VS 非公平锁
锁策略相关面试题
CAS
概念
原理
实现自旋锁
CAS的ABA问题
synchronized关键字
前言
我们常见的锁策略不仅仅只是局限于Java这一门语言, 基本上任何和锁相关的, 都会涉及到锁策略, 之前所用的synchronized就是一种锁, 只不过它既是乐观锁也是悲观锁, 同时它还是一个互斥锁, 至于什么是乐观锁, 悲观锁, 我们下面将会进行讲解
乐观锁 VS 悲观锁
那什么叫做乐观, 什么又叫做悲观呢? 所谓悲观, 就是总是考虑最坏的结果, 而乐观则是等到事情快要到的时候才去做.
举个例子: 例如有两个同学A和B, 他们两个去问老师问题, 对于A, A提前预测了老师可能有事情(悲观), 所以A同学就提前给老师发送了个消息 ,问老师在忙吗,下午我可以问一个问题吗, 如果老师回复了可以, 则A同学就可以直接去问问题, 如果不可以则A同学就需要等待, 这个就是悲观锁.
对比于A同学, B同学就直接认为, 老师是比较闲的, 就直接去问问题(没有加锁, 直接访问资源), 如果老师确实比较忙, 那么B也就不会去打扰老师, 就下次再来, 如果老师真闲着, 那么问题就直接解决了, 这就是乐观锁
乐观锁
假设数据一般情况下不会产生并发冲突, 所以就在数据进行提交更新的时候, 才会正式对数据是否产生冲突进行检测, 如果产生并发冲突了, 则返回用户的错误信息, 让用户自己去解决
悲观锁
总是假设最坏的情况发生, 每次拿数据的时候都认为别人会修改, 所以就在拿数据的时候上锁, 这样子别人想拿到这个数据就需要阻塞等待.
悲观乐观锁优缺点
我们知道, 加锁操作是会影响性能的, 对于锁来说, 我们称之为, 非必要不加锁, 所以在一般情况下, 悲观锁是提前就认为在读取的过程中都认为别人会修改这个数据, 所以就给拿的时候上了锁, 这个时候对比于乐观锁来说, 悲观锁的加锁操作就会相较于频繁一些, 所以通常认为悲观锁的效率会更低一点, 乐观锁的效率会更高一点(但也不是绝对的)
synchronized就是默认乐观锁的策略, 当发现锁竞争比较频繁的时候就会自动切换为悲观锁.
轻量级锁 VS 重量级锁
简单来说就是, 轻量级加锁解锁操作过程更高效, 重量级加锁解锁过程更慢,更低效. 这种结论可能与前面的乐观锁, 悲观锁类似, 在对比区分上有一定的重合.
锁的核心特性"原子性", 这种机制的出现可以追踪到CPU这样的硬件设备身上, 也就是说:
- CPU提供了"原子性操作"的指令
- 操作系统基于CPU的原子性操作指令, 实现了mutex互斥锁
- 然后JVM基于这个操作系统提供的mutex互斥锁, 实现了synchronized和ReentrantLock等关键字和类(此处的synchronized并不仅仅是对mutex进行封装, 在synchronized里面还实现了许多其他的功能)
重量级锁
重量级锁加锁机制重度依赖于操作系统提供的mutex互斥锁, 这也就意味着重量级锁将会涉及到大量的用户态和内核态的切换, 也就是说明将会消耗更多的资源
轻量级锁
轻量级锁加锁机制上尽可能的不使用mutex, 而是尽量在用户态完成, 随后才可能在没有完成的基础上使用mutex, 仅存在少量的用户态和内核态的切换
重温一下什么是用户态, 什么是内核态:
内核态: 处于内核态CPU可以访问任何数据, 包括io设备, 如硬盘等, 处于内核态的CPU可以从一个程序切换到另外一个程序, 并且占用CPU不会发生抢占情况.
用户态: 处于用户态的CPU只能受限的访问内存, 并且不允许访问io设备, 用户台下的CPU不允许独占, 也就是CPU能够被其他程序获取到
总体来说, 内核态运行的时候比用户态安全.用户态的时间成本是比较可控的. 内核态的时间成本是不太可控的. 内核态这时效率是很低的.
所以我们这里的synchronized开始是一个轻量级锁. 如果锁冲突比较严重, 就会变成重量级锁.
读写锁
在我们之前谈到的原子性和可读性问题上就说明过, 多线程之间读取方之间不会昌盛线程安全问题, 但是如果数据写入方和读取方之间需要进行互斥, 我们所说, 非必要不加锁, 于是就设计了读写锁这个操作, 如果在只有读取操作这样的线程安全的代码中, 给读取操作加锁, 这样就会产生极大的性能消耗, 所以为了更加精细化锁的操作, 也就出现了读写锁.
读写锁, 在加锁时需要额外表名读写意图, 例如如果只是单纯的读取方之间, 就不需要互斥, 如果存在写入的操作, 那么必须要求任何人都需要互斥.
下面详细讲讲读取数据和写入数据的线程安全问题:
- 两个或者多个线程, 都只是去读取一个数据, 此时没有线程安全问题, 直接并发读取即可
- 两个线程写一个数据, 会有线程安全问题,
- 多个线程读和写, 存在线程安全问题, 也就是之前所说的load, op, save的原子性问题等
接下来的读写操作就是把读操作和写操作分开来加锁,
Java标准库提供了ReentrantReadWriteLock类, 实现了读写锁, 底下有ReadLock和WriteLock类表示读加锁或者写加锁:
- ReenTrantReadWriteLock.ReadLock类, 提供 lock() / unlock()方法来进行加 / 解 锁
- ReenTrantReadWriteLock.WriteLock类, 提供lock() / unlock() 方法来进行加 / 解 锁
其中会不会引起线程阻塞等待, 取决于:
- 读加锁与读加锁之间不互斥
- 读加锁与写加锁之间互斥
- 写加锁和写加锁之间互斥
适应:
读加锁适应于频繁度, 少量写的场景中, 比如我们日常生活中的点名, 班级学生表之类的(synchronized不是读写锁)
自旋锁
线程在抢锁失败后进入阻塞状态, 放弃CPU, 需要经过很长时间后, 才有可能被系统调度, 但是在实际情况下, 其实抢锁失败, 过不了多久锁就后悔被重新释放, 这个时候就没有必要放弃CPU, 这个时候可以使用自旋锁来处理这样的问题:
自旋锁伪代码:
while (抢锁(lock) == false) {
// 代码
}
解释: 如果获取锁失败, 就会立即尝试再次获取锁, 无限循环, 知道拿到锁为止.第一次获取所失败, 第二次尝试就会在极短的时间内执行, 一旦锁被释放, 第一时间就能拿到锁.
自旋锁是轻量级线程的一种典型实现
挂起等待锁是重量级锁的一种典型实现
优缺点: 自旋锁没有放弃CPU, 不涉及线程阻塞等待和调度, 一点所被释放就能第一时间拿到锁, 但是如果锁被其他线程长时间占用, 那么这个线程就会持续消耗CPU资源(而挂起等待是不消耗CPU的)
互斥锁
例如我们经常使用的synchronized, 就是一种互斥锁, 他就只是单纯的给指定代码块加锁:
synchronized(locker) {
// code
}
他只有两个操作, 一个是进入代码块, 开始加锁, 一个是出了代码块就释放锁. 其他线程想要拿到锁就必须阻塞等待
可重入锁 VS 不可重入锁
可重入锁字面上也就是"可重新进入的锁", 即允许同一个线程多次获取同一把锁.
举个例子: 一个递归函数里面有加锁操作, 但是可以思考一个问题, 如果在递归的过程中这个函数已经拿到锁了, 那么他的递归函数会阻塞自己吗?
如果会. 那么这个锁就是不可重入锁, 如果不会阻塞, 那么叫可重入锁.
对于Java中, 以Reentrant开头命名的锁都是可重入锁, 而且JDK都提供的所有的现有的Lock实现类, 包括synchronized 等都是可重入的.
如何把自己锁死?
例如:
symchronized pubilc static void Function(int n) {
if ( n == 1 || n == 2) {
return 1;
}
return Function(n - 1) + Function(n - 2);
}
调用这个函数, 如果他是不可重入的, 那么就会在第二次调用这个Funciton方法的时候进入阻塞等待. 但是 第一个Function拿到的锁必须要等到后面的递归结束之后才能释放锁, 而后面的递归又在阻塞等待. 这个时候就会死锁
死锁
关于死锁的两个情况, 也就是:
- 一个线程一把锁, 但是一个线程已经占有一个锁, 但是这个线程再次对这个锁进行获取:
synchronized(locker) { synchronized(locker) { // code } }
第一次这个线程获取到了这个锁, 第二次继续尝试索取这个锁, 但是需要等待当前这个锁释放, 当前锁释放了才有可能, 但是当前代码在没有获取到锁并且走出代码块之前是无法释放锁的, 在逻辑上就矛盾了.
我们日常开发中经常遇到这种情况, 例如:class BlockingQueueMy { synchronized void fun() { int tem = this.size(); // .... } synchronized int size() { // code.... } }
我们对方法加锁, 那么这个锁的对象都是调用这个方法的实例, 就形成了上述的死锁的情况.. 但是在实际开发过程中并不会经常死锁, 就比如synchronized这个锁是一个可重入锁, 在重复加锁的过程中使回去判断一个当前获取锁的线程是否已经是锁的拥有者了.
-
两个线程两把锁, 即使是可重入锁, 也会出现死锁的情况
例如有如下代码案例:
当一个线程获取了locker1, 另外一个线程获取了locker2之后, 然后线程1想继续获取lokcer2, 同时线程2想继续获取locker1, 这个时候就产生了冲突 如果线程1想要获取locker2, 就必须等待线程2释放掉lokcer2, 但是locker2释放的条件是线程2执行出代码块, 但是在出代码快之前必须得先获取locker1, 但是要获取locker1就必须等待locker1被线程1释放, 想要线程1被释放就又得等待线程2释放locker2, 这就形成了死锁, 就算是可重入的, 也会被死锁 - N个线程, M把锁.
假设这里有五个线程和五把锁, 每个锁进行随机的获取锁和释放锁, 如果他们想要的锁已经被占有, 那么他们就会进行阻塞等待, 等待的过程中不会释放手中已经拿到的锁. 并且线程需要拿到最近的两个锁才能执行任务,
如图:
假设, 线程1拿到locker1, 线程2拿到locker2, ...一直到线程5拿到locker5, 此时, 每一个线程都拿到了最近的一个锁, 但是还有另外一个锁没有拿到, 例如, 当前线程5去拿锁1想要完成任务, 但是locker1被线程1占有, 此时就会阻塞等待, 那么对于其他的线程同样如此, 就会陷入死锁的情况
解决上述死锁的策略:
- 对于同一个线程重复获取同一把锁, 我们可以采取将锁设为可重入锁, 就可以避免这个问题
- 对锁进行编号, 约定加锁顺序, 例如, 对于例子3来说, 我们可以让线程1先获取locker1和locker2,线程2获取locker3和locker2, 但是locker已经被线程1占用, 此时就进入阻塞等待, 线程3和线程4,5同样如此, 此后等线程1释放锁, 然后线程2就可以拿到locker2, 以此类推, 就可以解决重复等待的条件
- 两个线程两把锁的, 我们可以约定加锁顺序, 例如:
线程都遵守这个顺序来加锁, 就不会进行循环等待了.
公平锁 VS 非公平锁
一个锁, n个线程, 每个线程拿到锁的可能是等概率的, 或者说是随机拿到锁的, 这种情况就称为非公平锁, synchronized就是一个非公平锁.
相反, 如果一个锁, 线程占用这个锁都是按先来后到或者是其他顺序来的, 则称为公平锁.
锁策略相关面试题
CAS
概念
什么是CAS? CAS全程为Compare and swap, 英文翻译也就是"比较并交换", 一个CAS涉及到一下操作:
我们假设主存或者是寄存器中存储着一个值为int a = 0; 我们在使用CAS的时候首先将其读取到工作内存, 然后将其修改然后存储回寄存器或者主存当中:
我们假设内存中的原数据 V ,旧的预期值 A ,需要修改的新值 B 。1. 比较 A 与 V 是否相等。(比较)2. 如果比较相等,将 B 写入 V 。(交换)3. 返回操作是否成功。
其本质上CAS是一条CPU指令
原理
例如加下来的代码案例: 我们创建两个线程, 对同一个线程进行自增操作:
public static void main(String[] args) throws InterruptedException {
final int[] a = {0};
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
for(int i = 0; i < 50000; i++) {
a[0]++;
}
}
});
Thread t2 = new Thread(()-> {
for(int i = 0; i < 50000; i++) {
a[0]++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(a[0]);
}
他的输出值时随机的, 因为线程的随机调度, 导致他们存在脏读或者是脏数据问题, 也就不能正确的得到想要的结果. 也就是存在++操作的原子性问题.
基于CAS, Java标准库提供了AtomInteger等类, 来保证前后置++和--的原子性问题
public static void main(String[] args) throws InterruptedException {
AtomicInteger num = new AtomicInteger(0);
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
// num++
num.getAndIncrement();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
num.getAndIncrement();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
// get 获取到数值
System.out.println(num.get());
}
其中: incrementAndGet是前置++, decrementAndGet是后置--....
也就是, 读取完数据之后, 回去检查工作内存中的数据和寄存器当中的数据是否是一致的, 如果一致,就将swapData和寄存器中的0交换, 完成自增操作.
但是如果是下面这种情况:
数据比较时, 发现 0 != 1, 结果返回false, 也就是操作失败.
其伪码如下:
boolean CAS(address, expectValue, swapValue) {
if (&address == expectedValue) {
&address = swapValue;
return true;
}
return false;
}
利用这种机制, 及时感知到了另外一个线程修改了这个数据, 就相当于提前判断了寄存器当中的值是否被修改. 也就保证了原子性或者说是内存可见性.
实现自旋锁
public class SpinLock {
private Thread owner = null;
public void lock() {
while (!CAS(this.owner,null,Thread.currentThread())) {}
}
public void unlock(){
this.owner = null;
}
}
通过循环查看这个锁是否被占有, 如果占有就自选等待, 如果没有被线程持有之后, 那么就把owner设为当前尝试加锁的线程, 然后判断CAS为false之后, 跳出循环.
CAS的ABA问题
我们说, CAS可以提前检测寄存器内容是否被修改, 但是, 如果遇到像这种修改前和修改后值相等的该如何判断?
解决方案, 我们不用去判断这个值是否相等, 我们去设置一个版本号, 每次的修改都有一个对应的版本号, 比较的时候就去比较版本号, 如果版本号如果不一样, 就返回false.
synchronized关键字
synchronized关键字属于一个具有锁升级策略的锁, 这个过程具有不可逆性.
对于一个拿到synchronized锁的线程, 首先是这个线程针对锁做一个标记,如果在代码执行的过程中, 都没有别的线程去竞争这个锁, 那么就不需要加锁, 这也是非必要不加锁的特点. 此时synchronized被称为偏向锁. 这种标记非常轻量, 既保证了效率, 与保证了线程安全.
当有线程来竞争之后就变成了轻量级锁, 也是一种自旋锁, 遇到锁竞争时会进行自选等待, 如果锁竞争更加激烈之后, 就变成了重量级锁. 假设10个线程竞争一个锁, 那么有9个在阻塞等待, 那么这么多线程自选, CPU的消耗就会非常大, 既然如此就升级为重量级锁, 在内核阻塞等待, 资源消耗少儿, 但是这就意味着放弃CPU, 有内核进行随机调度, 效率也会慢很多. 不能保证释放锁的第一时间有线程拿到锁.