此篇文章与大家分享分治算法关于多线程进阶的章节——关于常见的锁策略以及CAS机制
如果有不足的或者错误的请您指出!
多线程进阶
1.常见的锁策略
我们需要了解的是,我们使用是锁,在加锁 / 解锁 / 遇到锁冲突的时候,都会怎么做
1.1乐观锁和悲观锁
指的是,在加锁的时候,遇到锁冲突的概率是大 还是 小,如果
如果预测当前出现锁冲突的概率大,那么说明后续要做的工作就会更多,加锁开销的时间就会更大,那么此时就是悲观锁
相反,如果预测当前出现锁冲突的概率小,那么说明后续要做的工作就会少一些,加锁开销的时间就会少一些,那么此时就是乐观锁
我们之前讲到的synchronized,本质上既是乐观锁又是悲观锁
指的是,对于synchronized来说,它是只是"自适应"的,在加锁的过程中,会自动统计出当前出现锁冲突的概率是大还是小
如果大,就会按照悲观锁的方式来执行(做的工作更多),此时往往是要通过内核来完成一些事情的
如果大,就会按照乐观锁的方式来执行(做的工作更少),此时往往是纯用户态的一些操作
1.2重量级锁 和 轻量级锁
一个锁是重量级锁还是轻量级锁,本质上取决于要做的工作的多与少
这样看来,实际上重量级锁和轻量级锁 与 悲观锁和乐观锁 的重叠度是很高的
这是因为,如果加锁过程中要做的事情少,那就是轻量级锁,加锁过程中要做的事情多,那就是重量级锁
而一般锁冲突概率高的时候,需要做的工作就多,低的时候就少
因此,按照我们上面所说,synchronized即使重量级锁又是轻量级锁
1.3自旋锁和挂起等待锁
自旋锁,是轻量级锁的一种典型实现方式
用伪代码来实现自旋锁就是
void lock () {
while (true) {
if(锁是否被占用) {
continue;
}
获取到锁
break;
}
}
自旋锁的功能就是,当一个线程尝试获取锁的时候,如果获取锁失败了,那么说明此时的锁正在被占用,出现了锁冲突,就会让当前这个线程进行一段自旋操作,即在循环中不断地尝试获取锁,而不会立即进入阻塞状态。
但是这种方式消耗了大量的系统资源,大部分处于忙等状态,但一旦锁被释放,就能立即获取到锁(拿到锁的速度更快了,但是消耗cpu)
而挂起等待锁是重量级锁的一种典型实现方式,实际上是借助系统的线程调度机制,当一个线程尝试获取锁,并且锁被占用了,出现了锁冲突,就会让当前这个线程挂起等待(阻塞状态),就不去参与调度了
直到锁被释放了,然后系统才去唤醒这个线程,去尝试重新获取锁
此时消耗的时间更长,一旦线程被阻塞了,即使锁释放了,什么时候唤醒,都是不可控的,可能会消耗很长的时间(拿到锁的速度慢了,节省cpu)
在java中,synchronized的轻量级锁部分是基于自旋锁实现的,而重量级锁是基于挂起等待锁实现的
1.4可重入锁和不可重入锁
这一点我们在介绍死锁的文章有提过
在java中,synchronized就是可重入锁
1.5公平锁和非公平锁
公平锁本质上就是严格按照线程的先来后到顺序来获取锁,哪个线程等待的时间长,哪个线程就先拿到锁
而在非公平锁中,谁先获取到锁,是随机的,和线程调度时间就无关了
而java里面的synchronized就是非公平锁,即多个线程尝试获取同一把锁,此时是按照概率均等的方式来获取的
这是由于系统本事线程调度就是随机的,如果要实现公平锁,那么就要引入额外的队列,按照加锁的顺序把这些获取锁的线程入队列,再一个一个的获取
1.6互斥锁和读写锁
synchronized本身就是一个互斥锁,而读写锁则是更加特殊的一种锁
对于互斥锁,本质上就两步操作,加锁和解锁
而读写锁要区分加读锁和加写锁
就是要实现,不同线程之间,读和读不会产生互斥
在日常开发中,有很多场景都是属于"读多 写少",如果使用普通的互斥锁,此时,每次读操作之间,即使不会产生线程安全问题,也会互斥,此时就会比较影响效率
1.7synchronized的自适应过程
按照我们上面所说的几个锁策略,synchronized是"乐观锁和悲观锁",“轻量级锁和重量级锁”,“轻量级锁部分是基于自旋锁实现的,重量级锁部分是基于挂起等待锁实现的”,“不可重入锁”,“非公平锁”,“互斥锁”
synchronized的自适应过程就是
+未加锁的状态 -----(开始执行synchronized) —> 偏向锁 -----遇到锁冲突----> 轻量级锁 ------锁冲突概率进一步提升 ----> 重量级锁
关于偏向锁
指的是,实际上我们使用synchronized进行加锁的时候,一开始不是真正加锁了,而只是做了一个标记,非常轻量,几乎没有开销
此时,如果没有别的线程针对同一个锁对象进行加锁,那么就会一直保持这个状态,直到解锁
但是如果在偏向锁的情况下,发现有别的线程针对同一个锁对象进行加锁,就立马把偏向锁升级为轻量级锁,此时就是真正加锁了,就会产生互斥了
本质上,偏向锁的思想就是"懒"的体现,能不加锁就不加锁,能晚加锁就晚加锁,就能省下很多锁的开销
注意:上述锁的升级过程是不可逆的
1.8锁消除
指的是,如果你的代码里面出现加锁操作,此时编译器就会自动帮你判断,当前这里是不是真的要进行加锁,如果不是,就会自动帮你把锁给优化掉
最典型的就是,当你在单线程里进行加锁操作
1.9锁粗化
本质上与锁的"粒度"有关
在加锁的范围内,包含的代码越多,就认为锁的粒度越粗,反之越细
锁粗化实际上也是一种优化策略,有的时候,需要频繁加锁解锁,此时编译器就会自动把多次细粒度的锁,合并成一次粗粒度的锁
2.CAS机制
所谓CAS,就是compare and swap,即比较和交换
指的是通过 一条cpu指令,就能完成 比较和交换 这样一套操作,即"原子的"
2.1CAS的流程
我们可以将他想象成一个方法
boolean CAS(address,reg1,reg2){
if(*address == *reg1){
将address里面的值和reg2寄存器里面的值进行交换
return true;
}
return flase;
}
而我们上述模拟的这套操作,是通过一条cpu指令来完成的
实际上上述说的交换,我们通常是用来表示"赋值",因为实际上我们并不关心寄存器里面存的是什么值,而更加关心交换到内存里面的值是多少
因此上述操作也可以近似认为是将寄存器里面的值赋值到内存中
2.2CAS的使用
由于cpu提供了这样的指令,那么操作系统就会对应提供执行这样的指令的API,而JVM又对这样的API进行了封装,那么在java代码中也就直接使用CAS操作了
但是在java里面,CAS相关的类是被封装在unsafe包里面的,使用这里面的东西容易出错,就不鼓励直接使用CAS
在java中,又有一些类是对CAS进行了进一步的封装,典型的就是"原子类"
举其中的一个例子,我们上面圈出来的AtomicInteger
就相当于针对int进行了封装,就可以保证此处的++,–等操作操作是原子的了
我们在之前写过一个存在线程安全问题的代码
而当时我们的解决策略就是进行加锁,进行加锁,就会触发阻塞等待,只要代码有了加锁,就基本是与高性能无缘了
实际上这里用CAS会更好
可以发现,此时就不能简单的对count进行++/–操作了,而正确的操作是:
count.getAndIncrement();//后置++
count.getAndDecrement();//后置--
count.decrementAndGet();//前置--
count.incrementAndGet();//前置++
count.getAndAdd(10);//count += 10
那么由于此时是原子操作,线程就是安全的了
此处我们的代码中就不涉及任何的加锁操作了
使此时的代码以更高的效率来执行程序
这一套基于CAS,不加锁来实现线程安全代码的方式,也称为"无锁编程"
虽然CAS机制挺好的,但是实际上使用范围没有锁广泛,只能是针对一些特殊场景,使用CAS是更高效的,但是有些场景是不适合使用CAS的
2.3基于CAS实现自旋锁
在java中,synchronized的自旋锁,就是基于CAS实现的
我们通过伪代码来理解自旋锁的实现
public class SpinLock {
private Thread owner = null;//表示当前持有锁的线程是谁,如果此时处于未加锁状态,那owner就是null
public void lock() {
//通过CAS查看当前的锁对象是否已经被获取,如果是,那就要自旋等待
//如果不是,那就把owner设置为当前尝试进行加锁的线程,此时cas就返回true,再取反,就是false,循环结束
while(!CAS(this.owner,null,Thread.currentThread())) {
}
}
public void unLock() {
this.owner = null;
}
}
而此处的比较和赋值就是原子的,就可以借助这样的逻辑来进行加锁的实现
ABA问题是属于CAS的一个重要注意事项
2.4CAS的ABA问题
我们前面说过,CAS机制的核心就是"比较 -> 发现相等 -> 交换",即检查当前内存里的值是否被其他线程修改,如果被修改了,就要稍后重试,如果没被修改,接下来就可以修改(不会有安全问题)
但是发现相等的潜台词就是 中间数据没有变化过
但是事实上,在中间可能会有其他线程将这个数从 A - B - A
那么此时就会出现 看起来好像没人修改,实际上已经被改过了的情况
而CAS机制是无法区分当前这个数据是确实没被修改,还是被修改了又改回来
虽然大部分情况下是没有影响的
但是在极端情况下会出现bug
举个例子就是取款场景
假设我们的取款操作是这样类似这样的
但是有可能发生就是某个人在一次取款操作中多按了一次,导致产生了两个线程来执行上述的扣款操作,那么假设我们的执行流程是:
那么此时t1线程什么都不执行,什么都不执行是对的!
但是如果此时恰好有第3个人线程给这个人的账户里面转了500,那么就会出问题了
那么此时t1就又扣款一次,导致取的是500.实际上扣的是1000
上述场景就是典型的ABA问题,实际上是很极端的情况
用户要点击两次 && 恰好在这个时候有人给你转了500 && 恰好转账金额是500
诸多的巧合,哪一环扣不上都不会出现上述的问题
如何解决ABA问题??
核心思路就是,使用账户余额作为判定条件本身就不太合理,因为账户余额本身就属于"能加也能减",就容易出现ABA问题
合理的是,我们可以引入"版本号".约定版本号只能加,不能减,每次操作一遍余额,版本号就+1,通过CAS判定版本号是否与当前版本号相同,如果是就可以进行下一步操作,如果版本号没变过,那么数据就一定没变过