理解CAS与锁

文章目录

  • 理解锁
  • CAS
    • 乐观锁和悲观锁
    • 内存语义
      • MESI缓存一致性协议
      • lock前缀
    • 特点
    • ABA
  • 写一个自旋锁

理解锁

锁是什么?一个变量。线程A看见这个变量已经有主人了,它要么等待、要么去sleep、要么放弃,线程B释放锁就是将这个锁变量的主人重新置空。那么无论是获取锁的操作还是释放锁的操作,本身都是应该是原子的,应该是一个事务!我们平时更关心的是加锁和解锁之间的代码,那么上锁和解锁本身如何保证原子性?我只能说方式有很多,不过主流的实现方案是基于CAS指令。

应用层次的锁,解决的是多个进/线程并发访问同一块内存的问题,而CPU层面的锁解决了多个核心并发访问同一块内存的问题。由于应用层面的锁是对底层的封装与抽象,因此一旦锁获取失败,操作系统都可以通过系统调用挂起一个线程,让出CPU。

起初程序是原子的,为了程序实现并发来提升效率,引入了“执行到一半”的第三种状态,而上锁的操作,本质上是使用一个原子指令来将锁变量置位,即保证程序原子性的原理就是使用一个原子性指令来保证另外一堆非原子性指令的原子性

总结,锁就是一个变量,访问一个变量前先抢占锁,这种访问策略也称为悲观锁策略。阻塞和非阻塞主要指的是“抢占锁失败后”的处理策略。阻塞锁底层依赖系统调用(mutex互斥量),非阻塞锁一般都会继续尝试,这种锁也称为自旋锁。(既然都“上锁”了,那么无法上锁肯定是不退出的,尝试失败则退出一般称为tryLock,上锁的中途能够被外界中断则称为lockInterruptibly,这些都是Lock接口提供的行为,synchronized是没有的)

CAS

CAS是什么?比较与交换,它是一个指令,不管从低级的cpu指令还是高级的代码都能看见它的身影。
jdk层面的 CAS API 可以由unsafe类提供或者使用JUC的Atomic原子类提供的CAS相关方法。
CAS底层实现依赖处理器的指令集(cmpxchg),jdk的CAS方法无疑都采用本地实现,处理器的CAS是一条原子指令,也就是说比较和交换整个动作可以一次性完成。

CAS指令集需要三个操作数:需要修改资源的内存地址,预期值,目标值。
CPU访问内存地址,当资源的实际值等于预期值时,CPU将内存地址上的资源修改为目标值。由于CAS是处理器的单条指令,不会被打断,因此可以保证原子性。

CAS是无阻塞同步的一种解决方案,它可以实现乐观锁。我的理解:CAS可以完成上锁操作本身

CAS是实现“上锁”的一种方法,也可以使用关中断、testAndSet等

乐观锁和悲观锁

什么是悲观锁?
访问一个资源之前,一定要加锁,否则可能出现读写冲突或者写写冲突等线程安全问题。
这个锁可以是自旋锁——定义一个锁变量,CAS自旋去修改这个变量,如果修改失败就一直自旋着,其中上锁成功的线程就相当于进入了同步代码块。
这个锁也可以是互斥锁/阻塞锁——仍然是有一个锁变量,此时不再是无限CAS自旋,而是自旋若干次,如果修改失败就调用阻塞函数/系统调用,将线程阻塞起来,主动放弃CPU——这便是synchronized的基本原理

什么是乐观锁?
访问一个资源之前,认为没有竞争发生,不使用锁变量,而是直接CAS修改资源本身(读操作不需要加锁)。如果失败了如何进行后序处理看具体业务场景。

内存语义

CAS不仅可以用于线程同步,而且可以用于线程通信。
CAS操作同时具有volatile读和volatile写的内存语义,编译器不能对CAS前面和后面的任意指令进行重排序。如果程序运行在多处理器计算机,那么CAS操作被翻译为汇编指令时会加上lock前缀
CAS在x86处理器的大致写法是lock cmpxchg a,b,c 。
单核处理器是没必要写lock前缀的,因为cmpxchg本身是一个原子指令,这意味着执行这条指令时,能够一次性完成一次内存读和内存写,中间不会打断。
但是多核处理器下,加上lock前缀,意味着:
【1】将当前处理器缓存行的数据回写内存(写入内存前,通过锁总线,或者锁缓存行的方式保证同步)
【2】其他核心存储该变量的相应缓存行标记为invalidate(MESI协议)

只要某个核心使用了cmpxchg,其他的核心都会停下来(类似自旋),因此多个CPU 核心同时执行这条指令(同一块内存),只有一个核心会成功,其他的将会排队失败。

volatile和CAS可以说是JUC实现的基石
(注意:cmpxchg不是一个特权指令,不需要切换内核态)

MESI缓存一致性协议

(MESI只是缓存一致性协议的一种)
Modified已修改 - exclusive独占 - shared 共享 - invalidated 已失效
四个状态用于标志缓存行,协议对各个状态的转移做了详细的规定。

修改表示cache中的block已经被更新,但是没有更新到内存。
失效表示block中的数据已经失效,不可以读取。(必须从内存读取)
独占、共享表示block中的数据是一致的(独占:数据只存储在一个CPU核心的cache中,其他核心的cache没有该数据,写独占cache时不需要通知其他的核心。)独占状态下,如果有其他核心从内存中读取相同数据到cache中,则独占状态的数据变为共享状态(共享:当需要更新cache时,必须先进行一个广播,要求其他cache将block中的相同数据标记为无效,然后再更新当前cache的数据)

多个线程(核心)同时读写同一个缓存行的不同变量,导致缓存行频繁失效的现象称为伪共享。其中一个方案是空间换时间,通过空行填充,让某些变量独占一个缓存行,浪费一部分cache,换来性能的提升

写缓冲区
CPU缓冲区为了保证数据一致性,遵循MESI缓存一致性协议,某个CPU更新数据后,需要向其他CPU发出invalidate信号,并且得到其他CPU确认信号后才会进行写缓冲区操作,但是等待确认的这段时间CPU核心是无效的,因此引入写缓存区——将数据先写入写缓冲区,等待确认完毕时,再将数据取出并写入本地缓存
(引入写缓冲区后,一个写指令发出后,放入缓冲区后就直接往下执行了,也就是说写操作不是立即生效的)

写缓冲区不是无穷大的,而且处理器有时还是需要等待失效确认的返回(写缓冲区失效的情况),引入失效队列
【1】对于收到的所有invalidate请求,必须立即返回确认
【2】invalidate并不会真正执行,而是放入失效队列,在方便的时候才回去执行
【3】处理器不会发生任何消息给所处理的缓存条目,直到处理invalidate请求
(相当于使用一个队列,临时存储invalidate请求,收到请求后立刻回复,之后CPU再异步处理这些请求,失效队列的引入导致线程读到**“本应该失效却还没有失效”的脏数据**)

写缓存的引入使得指令看起来是乱序执行的——写缓冲区和本地缓存行的数据是不一致的
store forwarding(存储转发):当CPU执行读操作时,会从写缓冲区和缓存行中读。
另一方面,机器指令本身也会被处理器重排序,因为CPU无法确定多线程环境下哪些变量具有相关性(只能保证单线程情况下,重排序不会影响最终结果),但是CPU设计者提供了内存屏障供程序员规范CPU行为。

内存屏障是CPU设计者提供给程序员的一组指令,让程序员去约束CPU的行为,读类型的内存屏障会使得失效队列的invalidate请求立即更新,而写类型的内存屏障会使写缓冲区的数据立即写入。

内存屏障是CPU设计者为程序员提供的一组方法,可以约束CPU的行为,不同的CPU具有不同的内存屏障,相当于为程序员提供相应工具,将保证线程安全的责任交给程序员。
程序员通过使用内存屏障,告诉CPU哪些部分不应该被(重排序)优化,底层就是通过临时禁用失效队列、写缓冲区等实现的。

lock前缀

volatile、CAS(synchronized底层也是基于CAS上锁的)被编译为汇编指令后(即时编译器),都会在相应指令前增加一个lock前缀,lock前缀正是这些关键字实现有序性和可见性的基础。
LOCK前缀在多核处理器中引发两件事
【1】让当前处理器缓存行的数据回写入主存
【2】其他核心维护该变量相应的缓存行过期/无效,下次取需要从主存中获取

CPU为了提升效率,通过增加高速缓存来缓解读写内存造成的(CPU计算和访存之间的)速度差,而告诉缓存的最小单位是缓存行,因此CPU读数据都是一块块读的,多核处理机中,每个核心都有自己独立的缓存(L1/L2),各个核心通过环总线连接在一起,并且嗅探总线上信号,为了保证各个核心缓存行中的数据都是一致的,有两种解决思路(这也是lock前缀执行上的,两种实现方式)。
【1】锁总线
执行指令期间,核心发出Lock信号,总线仲裁机构该核心独占总线,而其他核心必须等待,代价很大,非主流方案。
【2】锁缓存,而且缓存之间需要遵守一个缓存一致性协议
各个CPU核心都是通过环总线连接在一起的,每个核心都维护自己缓存的状态,一旦某个核心修改了自己缓存的内容,就会通过环总线向其他核心发出信号,其他核心根据MESI协议修改相应的缓存行状态。

Lock前缀的汇编指令会强制写入主存,也可以避免前后指令的CPU重排序,并且及时让其他核心中的相应缓存行失效(从而利用MESI达到符合预期的效果)
非lock前缀的汇编指令执行写操作时,可能不会立刻生效,因为存在写缓存区,lock前缀的指令在功能上可以等价内存屏障,让写操作立刻生效(或者说jvm插入内存屏障,平台通过CPU指令实现对应效果)。

总结:为什么volatile、synchronized、CAS等能保证可见性、有序性,因为它们共同的底层实现lock前缀,满足了MESI缓存一致性协议的触发条件,才使得变量具有缓存一致性。而普通的读写涉及各种优化,如写缓存、失效延迟处理等导致MESI条件无法触发,进而产生一系列数据不一致的问题。

特点

这里我们讨论API层面的CAS。因为CAS可以分为CAS修改锁变量和CAS乐观锁,我们这里讨论CAS乐观锁,而这里乐观锁指代CAS自旋乐观锁。
在竞争不激烈的情况下,CAS可以提高系统的吞吐量——说白了,就是在一段时间内,让CPU多执行用户代码,少执行操作系统代码如(系统调用、切换上下文等)。如果竞争特别激烈,或者同步代码执行时间特别长,那么就使用自旋CAS乐观锁就是白白浪费CPU资源——虽然CPU一直在执行用户代码,但是执行循环啥也不干,还不如把CPU让给别人呢——这种情况不如主动申请阻塞、转让CPU给其他线程。

当多个核心针对同一内存地址指向CAS指令时,其实他们是在试图修改每个核心自己维护的缓存行,假如两个核心同时同时对同一内存地址执行CAS指令,则他们都会尝试向其他核心发出invalidate,仲裁获胜的核心将先一步发出invalid,失败者则需要对自己的缓存行invalidate,读取胜利者修改后的内存值,CAS指令执行失败。

因此锁并没有消失,只是转嫁到了环总线上的总线仲裁协议上,而多核同时针对一个地址CAS会导致对应的缓存行频繁失效,降低性能,因此CAS不能滥用

另外,CAS指令提供的总是一个变量的内存地址,也就是说乐观锁只能CAS修改某一个变量的值——不如独占锁变量,想怎么修改就怎么修改来的爽快啊。

也可以将多个变量包装为一个对象(结构体),通过JUC的atomicReference来实现。当然了,肯定还是加锁更方便

ABA

这个我想单独聊一聊。
ABA问题:
首先,CAS指令的三个参数实际上都是内存地址,比较两个内存地址的值,然后考虑要不要把第一个内存地址的值修改为第三个内存地址的值,既然涉及到寻址,那么两次寻址之间必然具有时间间隔,我们只能保证CAS指令执行是原子的,在CPU寻址过程中(三个地址的寻址过程),源地址上的值从A变成B,再变成A是可能的。

造成以上问题的主要原因,是因为我们使用CAS时的逻辑就是:值相同,就交换。如果我们的业务禁止ABA问题,我们完全可以将CAS的逻辑更改为:值+时间戳或版本号 相同,则交换

严格意义上,ABA不能归于CAS,而是我们“错误”的编码。CAS没有错,因为无论我们传入值还是值+版本号,它看来就是“变量的实际值”,ABA问题更应该被归于业务逻辑范畴。

ABA解决思路就是:CAS输入不但考虑值本身,还附带具有标识意义的字段。例如JUC的atomicStampedReference(额外维护了一个时间戳)、mysql可以维护一个version字段(mybatis plus 提供了乐观锁功能,本质上就是维护额外版本号)

总结:
造成ABA问题的不是CAS指令本身,因为它只是一个原子指令,出现ABA问题不是执行CAS的时候,而是CPU为CAS指令加载值的过程中。

写一个自旋锁

不要把自旋锁和乐观锁搞混,一般说乐观锁普遍指的是CAS自旋修改目标变量(不加锁直接修改资源),这里采用循环的方式更改锁变量

自旋锁使用场景:并发度不高临界代码执行时间不长的场景

这里使用计数器count实现可重入效果,如果直接使用布尔值表示状态那么就是不可重入锁,不可重入锁一旦连着调用两次lock()就会死锁,因此推荐使用可重入锁——避免死锁

    private AtomicReference<Thread> owner = new AtomicReference<>();//保证内存可见性
    private int count=0;//重入次数

上锁

    public void lock(){
        Thread cur = Thread.currentThread();
        if(owner.get()==cur){
            //重入
            count++;
            return;
        }
        //自旋获取锁:如果当前owner等于期望值null,则CAS设置为cur
        while (!owner.compareAndSet(null,cur)){
            System.out.println("自旋");
        }
    }

解锁

    public void unlock(){
        Thread cur =Thread.currentThread();
        //持有该锁的线程才可以解锁
        if(owner.get()==cur){
            if(count>0){
                count--;
            }else {
                owner.set(null);
            }
        }

    }

Volatile修饰数组或者集合只能保证指针(地址)的内存可见性,如果某一个线程将指针修改指向,其他线程可以立即知道。
而元素修改的可见性应该使用atomic变量来保证——atomicArray和atomicReference
atomic类封装了unsafe类提供的CAS、putObjectVolatile等方法,便于非框架开发用户(普通程序员)的使用。

保证内存可见性的arr[i]=newValue

public final void set(int i, E newValue) { 
    unsafe.putObjectVolatile(array, checkedByteOffset(i), newValue);
}

CAS:如果arr[i]等于期望值except,则更新为update

private boolean compareAndSetRaw(long offset, E expect, E update) {
    return unsafe.compareAndSwapObject(array, offset, expect, update);
}

JUC的atomic就是基于unsafe类实现的,而且封装了unsafe类的各种方法,其中原子操作就是基于CAS自旋实现的(乐观锁)
Atomic调用unsafe方法,unsafe方法调用C语言,C语言再调用汇编语言,最终生成一条CPU指令cmpxchg,因此CAS是具有原子性,不会被打断。

atomicLong在高并发下,大量线程同时竞争更新同一个原子变量(因为long是64位的,底层会被拆分为两个32位,分别为高位和低位),CAS成功率小,失败的线程尝试自旋,会浪费很多CPU资源。(atomicDouble也有这样的问题)
LongAdder是jdk8引入的,是对atomicLong的改进,在高并发场景更加高效。

LongAdder可以概括成这样:内部核心数据value分离成一个数组(Cell),每个线程访问时,通过哈希等算法映射到其中一个数字进行计数,而最终的计数结果,则为这个数组的求和累加。

简单来说就是将一个值分散成多个部分,每个线程操作这个值的一部分,最后值相加,在并发的时候就可以分散压力(只有在线程哈希冲突时才会产生竞争),性能有所提高。

你可能感兴趣的:(java基础,java,多线程,并发编程,锁,同步)