本节要点
了解常见锁策略
了解synchronized使用的锁策略
理解CAS实现逻辑
了解CAS出现的ABA问题,并解决
synchronized锁的原理
常见锁策略
我们已经知道锁在我们的并发编程十分重要.那我们就需要了解,这些锁实现的策略!都有那些策略,便于我们更加深刻的理解锁!
下面介绍的几组锁策略,每一组里面都是相异的,每组策略之间又有相互关联的!
乐观锁 vs 悲观锁
这是程序员处理锁冲突的态度(原因),通过自己的预期而实现咋样的锁
就好比疫情:
乐观的人觉得过段时间就好了,就不会囤太多物资
悲观的人觉得紧张,就屯好多物资,做了好多工作
乐观锁
程序员在设计锁的时候,预期锁冲突概率很低
做的工作更少,付出的成本低,更高效
悲观锁
预期锁冲突概率很高
做的工作多,付出的成本高,更低效
互斥锁vs读写锁
互斥锁
只有多个线程对同一个对象加锁才会导致互斥
互斥锁就是普通的锁,只有加锁和解锁
读写锁
可以对读操作加锁(不存在互斥关系,可以多线程读)
对写操作加锁(只能进行写操作)
读写操作加锁(读时不能写,写时不能读)
轻量级锁vs重量级锁
轻量级锁
做的事情更少,开销比较小
重量级锁
做的事情更多,开销比较大
这里的轻量级锁和重量级锁和上面的悲观锁和乐观锁有所重叠
一个是设计锁的态度(原因),一个是处理锁冲突的结果!
通常情况下一般可以认为乐观锁一般都是轻量级锁,悲观锁都是重量级锁!但是不绝对!!!
是如何实现轻量和重量呢?
其实我们基于纯用户态实现的锁就是认为是轻量级锁,开销小,程序员可控!
如果是基于内核的一些功能实现(比如调用了操作系统内核的mutex接口)的锁就认为是重量级锁(操作系统的锁会在内核做好多事情,比如让线程等待…)
自旋锁vs挂起等待锁
这是上述轻量级锁和重量级锁的典型实现
自旋锁
往往通过纯用户态代码实现,较轻
挂起等待锁
通过内核的一些机制实现,往往较重
公平锁vs非公平锁
公平锁
多个线程等待一把锁时,遵循先来后到原则
非公平锁
多个线程等待一把锁时,每个线程拿到锁的机会均等!
这里就有人有疑惑了,咋的,机会均等还不公平了?
但是你换个场景想想,如果你在等待办理业务,先来不就应该先办业务嘛,就好比排队,你先来排在前面!所以这才公平嘛!!!
可重入锁vs不可重入锁
可重入锁
可重入锁就是对一个对象多次加锁时不会造成死锁
不可重入锁
一个对象多次加锁时会造成死锁!
synchronized使用的锁策略
我们了解了上述的多组锁策略,我们来分析一下,synchronized用了那些锁策略!
自适应锁,即是乐观锁又是悲观锁
不是读写锁只是普通的互斥锁
既是一个轻量级锁又是重量级锁(根据锁竞争程度自适应)
轻量级锁的部分基于自旋锁实现,重量级锁基于挂起等待宿实现
非公平锁(锁拿到的机会均等)
可重入锁(加锁多次,不会导致死锁)
CAS
什么是cas?
CAS 全称是 compare and swap,是一种用于在多线程环境下实现同步功能的机制。CAS 操作包含三个操作数 – 内存位置、预期数值和新值。CAS 的实现逻辑是将内存位置处的数值与预期数值想比较,若相等,则将内存位置处的值替换为新值。若不相等,则不做任何操作。
可能有点抽象,我们看下面案例!
我们假设内存中的原数据V,旧的预期值A,需要修改的新值B。
比较 A 与 V 是否相等。(比较)
如果比较相等,将 B 写入 V。(交换)
返回操作是否成功。
可能看到这里你还是很懵!
boolean CAS(address, expectValue, swapValue) { if (&address == expectedValue) { &address = swapValue; return true; } return false; }
我们看这个伪代码!
通俗点讲,就是CAS解决了多线程中多条指令进行的赋值问题!
我们之前已经了解过当我们需要对一个值进行++在cpu中其实要执行3条指令!
先拿到值放在寄存器,将寄存器中的值更改,然后在放回内存!
而我们知道在多线程执行写操作时,就会导致线程不安全问题!
因为++操作并不是原子性的!
而这里的CAS做的就是将多条指令封装成一条指令,达到原子性的效果!避免线程不安全问题!
我们的cpu提供了一个单独的指令cas来执行上诉代码!!!
CAS使用
CAS可以做什么呢?
基于CAS能够实现"原子类"
java标准库中给我们提供了一组原子类,就是将常用类(int long array …)进行了封装,可以基于CAS进行修改,并且线程安全!
//基于CAS多线程对一个数实现自加 import java.util.concurrent.atomic.AtomicInteger; public class Demo1 { public static void main(String[] args) throws InterruptedException { //原子类 AtomicInteger atomicInteger = new AtomicInteger(0); Thread t1 = new Thread(()->{ for (int i = 0; i < 5000 ; i++) { //这个方法相当于 ++num atomicInteger.incrementAndGet(); } }); Thread t2 = new Thread(()->{ for (int i = 0; i < 5000 ; i++) { //这个方法相当于 ++num atomicInteger.incrementAndGet(); } }); t1.start();//启动线程 t2.start(); t1.join(); t2.join();//等待2个线程执行结束 System.out.println("多线程自加结果:"+atomicInteger.get()); } }
可以看到我们基于CAS多线程进行一个数的更改并不用加锁也能保证线程安全!!!
我们来学习一下java原子类中的一些方法!
原子类在java.util.concurrent.atomic包下!
构造方法,可以给初值!
实现+=操作
自加自减!
我们来看一下这里自加的实现逻辑!
//伪代码 class AtomicInteger { private int value; public int getAndIncrement() { int oldValue = value; while ( CAS(value, oldValue, oldValue+1) != true) { oldValue = value; } return oldValue; } }
我们分析上述伪代码!
基于CAS能够实现"自旋锁"
//伪代码 public class SpinLock { private Thread owner = null; public void lock(){ // 通过 CAS 看当前锁是否被某个线程持有. // 如果这个锁已经被别的线程持有, 那么就自旋等待. // 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程. while(!CAS(this.owner, null, Thread.currentThread())){ } } public void unlock (){ this.owner = null; } }
可以看到自旋锁的实现和原子类的实现类似!
我们来分析一下!
如果已经有线程持有了该锁对象,那么while循环就会一直自旋,直到该锁被释放,该线程才可以拿到该锁!
if(this.owner==null) 为真,Thread.currentThread当前线程就可以拿到该锁!否者自旋等待锁!
CAS的ABA面试问题
我们知道CAS的实现是通过对比当前CPU中的值和内存中的值是否相等,如果相等就采取计然后进行交换!
我们是否想过另外一种情况,就是该内存中的值改变了多次,又改回了原来那个值,显然这时内存中的值虽然和cpu中值相等,但是该值已经进行了多次改变,并没有保证此次CAS的原子性!
举个例子:
当有两个线程t1 和t2 这两个对象对同一块内存空间采取CAS修改操作!
我们已经知道CAS的原子性,t1和t2都能执行完成!
而如果这时有第3个线程在t1的load和t2的CAS之间将该值又更改回去,那么就出现bug了
我们假设一种现实场景:
某一天你去ATM取款
你的余额为1000元
然后你取款500元
你不小心多点了一次取款,但是你没有察觉到!
然后第一次你取款成功了,正常情况你第二次肯定无法取款成功!
因为我们知道CAS会比较寄存器和内存中的值!而此时的余额已经不是1000了,该CAS指令就无法成功执行!
但是如果在你执行第二个取款操作之前,你的朋友刚好给你转账500元!这样CAS在比较时发现相等就会再次执行取款操作,你取500居然取出了1000,余额还有500,这就是一个BUG!
我们用图来描述一下上述情况!
我们如何解决这个问题呢?
我们可以引入一个版本号
记录每次更改内存的次数,如果更改一次,版本号就加1,且版本号只能递增!!!
进行比较时只需要比较版本号即可,如果版本号相等就可以进行交换!
我们再进行上述的CAS就不会产生bug了!
当我们引入版本号时,每次只要比较版本号的值是否相等就可以判断内存中的值是否已经修改过,很好的解决了CAS中的ABA问题!
我们也可以用时间戳代替版本号,达到的效果一样,也可解决ABA问题!