大厂面试必问的CAS,你了解吗

我们在【大厂面试必问:volatile关键字详解】这篇文章中已经知道:

volatile关键字可以保证有序性、可见性、防止指令重排,但是不能保证原子性。

多线程并发修改volatile变量时,如果线程1读取volatile变量后,这时线程2读取并修改了这个变量,并写入了主内存,设置线程1工作内存中的缓存行失效。由于线程1已经读取过volatile变量了,此时不会再去缓存行读取了(此时缓存行已失效),也不会去主内存读取了,而是直接修改变量,写入主内存。这样就导致了并发修改的错误!

正是由于这种错误,导致了CAS理论的诞生。CAS理论解决了volatile变量自增的原子性问题。上篇文章中用AtomicInteger这种方法解决了自增原子性问题,就是基于CAS理论。那么今天我们来了解下CAS。

注:本篇测试环境为JDK1.8,eclipse

1、先看个例子

package com.testatomic;

public class Test {

	public static volatile int inc = 0;
	 
    public static void increase() {
    	inc++;
    }
 
    public static void main(String[] args) throws InterruptedException {
        long st = System.currentTimeMillis();
        for (int i = 0; i < 10; i++) {
        	new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i = 0; i < 1000; i++) {
                        increase();
                    }
                }
            }).start();
        }
 
        while (Thread.activeCount() > 1) {
            Thread.yield();
        }
        System.out.println("变量inc值:"+inc+", 耗时:"+(System.currentTimeMillis()-st));
    }
    
}

运行结果如下,不是我们期望的10000,并且每次运行程序输出的结果都不一样,都是一个小于10000的数字。

变量inc值:9554, 耗时:4

我们在cmd中运行命令javap -v Test.class,反编译下字节码文件,我们可以看到自增操作并不是一个原子操作,在多线程并发的情况下,putstatic指令可能把较小的inc值同步回主内存之中,导致我们每次都无法获得想要的结果。那么,应该怎么解决这个问题了?
在这里插入图片描述
1)采用synchronized:

public synchronized static void increase() {
   	inc++; 
}

运行结果:

变量inc值:10000, 耗时:11

使用synchronized修饰后,increase方法变成了一个原子操作,因此可以得到正确的结果。由于进行了加锁处理,所以性能会差一些,那么有更好点的方案吗?

2)采用AtomicInteger

public static AtomicInteger inc = new AtomicInteger();
	 
public static void increase() {
 	inc.getAndIncrement();
}

运行结果:

变量inc值:10000, 耗时:4

每次都可以获得正确的结果,并且在性能上比synchronized有不错的提升。

2、什么是CAS

CAS(Compare-and-Swap),即比较并替换,是一种实现并发算法时常用到的技术,Java并发包中的很多类都使用了CAS技术。

CAS需要有3个操作数:内存地址V,旧的预期值A,即将要更新的目标值B。
CAS指令执行时,当且仅当内存地址V的值与预期值A相等时,将内存地址V的值修改为B,否则就什么都不做。这个和数据库中的乐观锁有些类似。

CAS整个比较并替换的操作是一个原子操作

3、源码分析

接下来我们看下inc.getAndIncrement();的源码实现。
在这里插入图片描述
getAndIncrement方法调用getAndAddInt方法:getIntVolatile方法是获取内存中变量的最新值;compareAndSwapInt方法CAS操作成功后返回true终止循环。
在这里插入图片描述
compareAndSwapInt方法是一个native方法,底层实现依赖操作系统。通过JNI(Java Native Interface JAVA本地调用)调用操作系统的实现方法,具体是借助C来调用CPU底层指令来实现的。
在这里插入图片描述
下载OpenJdk源码包,unsafe.cpp中调用了compareAndSwapInt方法,然后调用了“Atomic::cmpxchg”方法,“Atomic::cmpxchg”方法在linux_x86和windows_x86的实现如下。
在这里插入图片描述
linux_x86的实现:
在这里插入图片描述
windows_x86的实现:
在这里插入图片描述
Atomic::cmpxchg方法说明:

mp是“os::is_MP()”的返回结果,“os::is_MP()”是一个内联函数,用来判断当前系统是否为多处理器。

如果当前系统是多处理器,该函数返回1;否则,返回0。

LOCK_IF_MP(mp)会根据mp的值来决定是否为cmpxchg指令添加lock前缀。

如果通过mp判断当前系统是多处理器(即mp值为1),则为cmpxchg指令添加lock前缀。否则,不加lock前缀。

这是一种优化手段,认为单处理器的环境没有必要添加lock前缀,只有在多核情况下才会添加lock前缀,因为lock会导致性能下降。cmpxchg是汇编指令,作用是比较并交换操作数。

intel手册对lock前缀的说明如下:

1)确保对内存的读-改-写操作原子执行。在Pentium及Pentium之前的处理器中,带有lock前缀的指令在执行期间会锁住总线,使得其他处理器暂时无法通过总线访问内存。很显然,这会带来昂贵的开销。从Pentium 4,Intel Xeon及P6处理器开始,intel在原有总线锁的基础上做了一个很有意义的优化:如果要访问的内存区域(area of memory)在lock前缀指令执行期间已经在处理器内部的缓存中被锁定(即包含该内存区域的缓存行当前处于独占或以修改状态),并且该内存区域被完全包含在单个缓存行(cache line)中,那么处理器将直接执行该指令。由于在指令执行期间该缓存行会一直被锁定,其它处理器无法读/写该指令要访问的内存区域,因此能保证指令执行的原子性。这个操作过程叫做缓存锁定(cache locking),缓存锁定将大大降低lock前缀指令的执行开销,但是当多处理器之间的竞争程度很高或者指令访问的内存地址未对齐时,仍然会锁住总线。
2)禁止该指令与之前和之后的读和写指令重排序。
3)把写缓冲区中的所有数据刷新到内存中。

上面的第1点保证了CAS操作是一个原子操作,第2点和第3点所具有的内存屏障效果,保证了CAS同时具有volatile读和volatile写的内存语义。

4、CAS的缺点

CAS虽然很高效的解决了原子操作问题,但是CAS仍然存在三大问题。

1)循环时间长开销很大

CAS 通常是配合无限循环一起使用的,我们可以看到 getAndAddInt 方法执行时,如果 CAS 失败,会一直进行尝试。如果 CAS 长时间一直不成功,可能会给 CPU 带来很大的开销。

如果JVM能支持处理器提供的pause指令那么效率会有一定的提升,pause指令有两个作用,第一它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。第二它可以避免在退出循环的时候因内存顺序冲突(memory order violation)而引起CPU流水线被清空(CPU pipeline flush),从而提高CPU的执行效率。

2)只能保证一个变量的原子操作

当对一个变量执行操作时,我们可以使用循环 CAS 的方式来保证原子操作,但是对多个变量操作时,CAS 目前无法直接保证操作的原子性,或者有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如有两个共享变量i=2,j=a,合并一下ij=2a,然后用CAS来操作ij。

我们可以通过以下两种办法来解决:
(1)使用互斥锁来保证原子性;
(2)将多个变量封装成对象,通过 AtomicReference 来保证原子性。

3)ABA问题

因为CAS需要在操作值的时候检查下值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加一,那么A-B-A 就会变成1A - 2B-3A。

从Java1.5开始JDK的atomic包里提供了一个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法作用是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。

因此,在使用CAS前要考虑清楚“ABA”问题是否会影响程序并发的正确性,如果需要解决ABA问题,改用传统的互斥锁可能会比原子类更高效。

欢迎小伙伴们留言交流~~
浏览更多文章可关注微信公众号:diggkr

©️2020 CSDN 皮肤主题: 大白 设计师:CSDN官方博客 返回首页