深入浅出CAS

作者: 美团Java | 来源:发表于2016-07-12 13:42 被阅读26465次

    占小狼 转载请注明原创出处,谢谢!

    前言

    CAS(Compare and Swap),即比较并替换,实现并发算法时常用到的一种技术,Doug lea大神在java同步器中大量使用了CAS技术,鬼斧神工的实现了多线程执行的安全性。

    CAS的思想很简单:三个参数,一个当前内存值V、旧的预期值A、即将更新的值B,当且仅当预期值A和内存值V相同时,将内存值修改为B并返回true,否则什么都不做,并返回false。

    问题

    一个n++的问题。

    public class Case {
    
        public volatile int n;
    
        public void add() {
            n++;
        }
    }
    

    通过javap -verbose Case看看add方法的字节码指令

    public void add();
        flags: ACC_PUBLIC
        Code:
          stack=3, locals=1, args_size=1
             0: aload_0       
             1: dup           
             2: getfield      #2                  // Field n:I
             5: iconst_1      
             6: iadd          
             7: putfield      #2                  // Field n:I
            10: return        
    

    n++被拆分成了几个指令:

    1. 执行getfield拿到原始n;
    2. 执行iadd进行加1操作;
    3. 执行putfield写把累加后的值写回n;

    通过volatile修饰的变量可以保证线程之间的可见性,但并不能保证这3个指令的原子执行,在多线程并发执行下,无法做到线程安全,得到正确的结果,那么应该如何解决呢?

    如何解决

    add方法加上synchronized修饰解决。

    public class Case {
    
        public volatile int n;
    
        public synchronized void add() {
            n++;
        }
    }
    

    这个方案当然可行,但是性能上差了点,还有其它方案么?

    再来看一段代码

    public int a = 1;
    public boolean compareAndSwapInt(int b) {
        if (a == 1) {
            a = b;
            return true;
        }
        return false;
    }
    

    如果这段代码在并发下执行,会发生什么?

    假设线程1和线程2都过了a==1的检测,都准备执行对a进行赋值,结果就是两个线程同时修改了变量a,显然这种结果是无法符合预期的,无法确定a的最终值。

    解决方法也同样暴力,在compareAndSwapInt方法加锁同步,变成一个原子操作,同一时刻只有一个线程才能修改变量a。

    除了低性能的加锁方案,我们还可以使用JDK自带的CAS方案,在CAS中,比较和替换是一组原子操作,不会被外部打断,且在性能上更占有优势。

    下面以AtomicInteger的实现为例,分析一下CAS是如何实现的。

    public class AtomicInteger extends Number implements java.io.Serializable {
        // setup to use Unsafe.compareAndSwapInt for updates
        private static final Unsafe unsafe = Unsafe.getUnsafe();
        private static final long valueOffset;
    
        static {
            try {
                valueOffset = unsafe.objectFieldOffset
                    (AtomicInteger.class.getDeclaredField("value"));
            } catch (Exception ex) { throw new Error(ex); }
        }
    
        private volatile int value;
        public final int get() {return value;}
    }
    
    1. Unsafe,是CAS的核心类,由于Java方法无法直接访问底层系统,需要通过本地(native)方法来访问,Unsafe相当于一个后门,基于该类可以直接操作特定内存的数据。
    2. 变量valueOffset,表示该变量值在内存中的偏移地址,因为Unsafe就是根据内存偏移地址获取数据的。
    3. 变量value用volatile修饰,保证了多线程之间的内存可见性。

    看看AtomicInteger如何实现并发下的累加操作:

    public final int getAndAdd(int delta) {    
        return unsafe.getAndAddInt(this, valueOffset, delta);
    }
    
    //unsafe.getAndAddInt
    public final int getAndAddInt(Object var1, long var2, int var4) {
        int var5;
        do {
            var5 = this.getIntVolatile(var1, var2);
        } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
        return var5;
    }
    

    假设线程A和线程B同时执行getAndAdd操作(分别跑在不同CPU上):

    1. AtomicInteger里面的value原始值为3,即主内存中AtomicInteger的value为3,根据Java内存模型,线程A和线程B各自持有一份value的副本,值为3。
    2. 线程A通过getIntVolatile(var1, var2)拿到value值3,这时线程A被挂起。
    3. 线程B也通过getIntVolatile(var1, var2)方法获取到value值3,运气好,线程B没有被挂起,并执行compareAndSwapInt方法比较内存值也为3,成功修改内存值为2。
    4. 这时线程A恢复,执行compareAndSwapInt方法比较,发现自己手里的值(3)和内存的值(2)不一致,说明该值已经被其它线程提前修改过了,那只能重新来一遍了。
    5. 重新获取value值,因为变量value被volatile修饰,所以其它线程对它的修改,线程A总是能够看到,线程A继续执行compareAndSwapInt进行比较替换,直到成功。

    整个过程中,利用CAS保证了对于value的修改的并发安全,继续深入看看Unsafe类中的compareAndSwapInt方法实现。

    public final native boolean compareAndSwapInt(Object paramObject, long paramLong, int paramInt1, int paramInt2);
    

    Unsafe类中的compareAndSwapInt,是一个本地方法,该方法的实现位于unsafe.cpp

    UNSAFE_ENTRY(jboolean, Unsafe_CompareAndSwapInt(JNIEnv *env, jobject unsafe, jobject obj, jlong offset, jint e, jint x))
      UnsafeWrapper("Unsafe_CompareAndSwapInt");
      oop p = JNIHandles::resolve(obj);
      jint* addr = (jint *) index_oop_from_field_offset_long(p, offset);
      return (jint)(Atomic::cmpxchg(x, addr, e)) == e;
    UNSAFE_END
    
    1. 先想办法拿到变量value在内存中的地址。
    2. 通过Atomic::cmpxchg实现比较替换,其中参数x是即将更新的值,参数e是原内存的值。

    如果是Linux的x86,Atomic::cmpxchg方法的实现如下:

    inline jint Atomic::cmpxchg (jint exchange_value, volatile jint* dest, jint compare_value) {
      int mp = os::is_MP();
      __asm__ volatile (LOCK_IF_MP(%4) "cmpxchgl %1,(%3)"
                        : "=a" (exchange_value)
                        : "r" (exchange_value), "a" (compare_value), "r" (dest), "r" (mp)
                        : "cc", "memory");
      return exchange_value;
    }
    

    看到这汇编,内心崩溃 😖

    __asm__表示汇编的开始
    volatile表示禁止编译器优化
    LOCK_IF_MP是个内联函数

    #define LOCK_IF_MP(mp) "cmp $0, " #mp "; je 1f; lock; 1: "
    

    Window的x86实现如下:

    inline jint Atomic::cmpxchg (jint exchange_value, volatile jint* dest, jint compare_value) {
        int mp = os::isMP(); //判断是否是多处理器
        _asm {
            mov edx, dest
            mov ecx, exchange_value
            mov eax, compare_value
            LOCK_IF_MP(mp)
            cmpxchg dword ptr [edx], ecx
        }
    }
    
    // Adding a lock prefix to an instruction on MP machine
    // VC++ doesn't like the lock prefix to be on a single line
    // so we can't insert a label after the lock prefix.
    // By emitting a lock prefix, we can define a label after it.
    #define LOCK_IF_MP(mp) __asm cmp mp, 0  \
                           __asm je L0      \
                           __asm _emit 0xF0 \
                           __asm L0:
    

    LOCK_IF_MP根据当前系统是否为多核处理器决定是否为cmpxchg指令添加lock前缀。

    1. 如果是多处理器,为cmpxchg指令添加lock前缀。
    2. 反之,就省略lock前缀。(单处理器会不需要lock前缀提供的内存屏障效果)

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

    1. 确保后续指令执行的原子性。
      在Pentium及之前的处理器中,带有lock前缀的指令在执行期间会锁住总线,使得其它处理器暂时无法通过总线访问内存,很显然,这个开销很大。在新的处理器中,Intel使用缓存锁定来保证指令执行的原子性,缓存锁定将大大降低lock前缀指令的执行开销。
    2. 禁止该指令与前面和后面的读写指令重排序。
    3. 把写缓冲区的所有数据刷新到内存中。

    上面的第2点和第3点所具有的内存屏障效果,保证了CAS同时具有volatile读和volatile写的内存语义。

    CAS缺点

    CAS存在一个很明显的问题,即ABA问题。

    问题:如果变量V初次读取的时候是A,并且在准备赋值的时候检查到它仍然是A,那能说明它的值没有被其他线程修改过了吗?

    如果在这段期间曾经被改成B,然后又改回A,那CAS操作就会误认为它从来没有被修改过。针对这种情况,java并发包中提供了一个带有标记的原子引用类AtomicStampedReference,它可以通过控制变量值的版本来保证CAS的正确性。

    相关文章

      网友评论

      • 3724d96fc1c7:迷妹诞生:grin:
      • 一滴水的坚持:只点赞学习
      • 9b0134b242a6:看懂了,但是不知道干什么用的,怎么保证原子性
        xiehongm_信陵:@小风_33ac java提供的cas是保证原子性的,在操作系统层面
        小风_33ac:@谢特0805 本来就不是原子操作 只是通过不用的替换和对比来保证数据一致 实现一个原子操作吧
      • a3f5aa21e07a:大佬 能讲讲DCAS吗 网上一直没搜到 如何解决ABA问题
        a3f5aa21e07a:@占小狼 就是你上面说的带邮戳的那个东西 用来解决CAS的 ABA问题 但是原理不懂诶.... 网上也没搜到资料
        美团Java:DCAS是什么?
        a3f5aa21e07a:主要是从原理上 讲讲....
      • 细雨蒙情:我感觉并没有解释核心问题:在CAS中,比较和替换是一组原子操作。volatile的底层原理也是Lock前缀啊,但是volatile并没有办法保证原子性,为什么CAS能够保证原子性?
      • 8105737cd1f5:第3步:并执行compareAndSwapInt方法比较内存值也为3,成功修改内存值为2,请问这里的2是怎么来的, 不应该是4吗?
      • 511b17a5b597:狼哥。求一定解答我的疑惑。内存值原来为1,现在假如有两个线程都读取到内存值为1,两个线程在绝对同时修改这一块内存,一个改2,一个改3,最后内存值是2还是3啊。你说的有while循环能检测到内存值被修改了吗
        鸡哥cy:@蟹子的钳 cas操作会锁住总线,保证其他CPU访问不了内存
        美团Java:@蟹子的钳 底层保证了不可能绝对的同时
      • 906cdc13c9e5:第五步 假如没有 volatile关键字 线程A从主内存 从新获取 难道就不能获取到改变后的值吗?
      • jack_520:你好,这样能保证不会有脏数据吗,比如我共享的数据是从数据库读取的,我修改成功后不能及时存到数据库,下次我又从数据库读取,得到数据不一致什么办
        美团Java:@jack_520 这个和数据库没用关系啊
      • 激情的狼王:为什么单核处理器不需要lock的内存屏障效果呢,这段汇编代码不会有多线程执行乱入的情况吗?狼神求解答🤔
        激情的狼王:@占小狼 单核多线程,A线程到第几行被B线程抢了cpu,这时候不就坏了吗?还是说cpu的指令某几行肯定是原子性的呢
        美团Java:@激情的狼王丶21 单核就没有并发的情况发生了
      • luban_:特意登录,给博主点赞。能挖到这么底层,真心让人佩服。
        luban_:@占小狼 已经关注了:grin: 希望能拉我进群
        美团Java:@iyifei咖啡 公众号搜索 占小狼的博客
      • 山_3c05:小狼哥,预期值指的是线程副本里面的的copy吧,内存是值得主内存里面的值,比较的是这两个值对吗?
      • cmazxiaoma:牛逼
      • 前世小书童:重新看了下,然后理解了下源码,虽然没有看native 部分,提出几个问题

        1.这个 volatile 修饰的 value变量 是不是和 valueOffset这个内存偏移地址其实是同一样变量,因为做 CAS方法传递的是valueOffset,而并非那个value变量

        2.你举的例子应该是问题CAS 执行时候会有你提到的 cpu lock 住,这时候别的线程进行 CAS 操作时候,会发现其实是 lock 住的,才会进行循环操作,知道 cpu unlock

        不知道我这样的理解,博主怎么看的

        因为博主的这种思路,其实就是和AQS 完全一样的逻辑了,我觉得可能不太合理,因为我上一个评论按照你思路进行下去的,但是那个疑问我还是理解不了
      • 前世小书童:一个问题,一个是 volatile 关键字修饰的变量,一个是 存在 native 中的内存区的值

        volatile 关键字修饰的变量具有内存可见性,也就是一个线程操作结果,其他线程也是可见,

        通过你上述的例子,也就是一个线程 A修改这个volatile 关键字修饰的变量,那么线程 B 同样

        可以获取到这个修改后的变量,这样进行 CAS 判断的时候就会出现volatile 关键字修饰的变量

        与 native 内存区的值不一致的问题

        so,什么时候会去修改 native 内存区的值?如果不修改的话,后续的 CAS 验证都是无法进行

        的吧?
      • DarkCoder:感谢大佬
      • e53e1112fa14:很浅显易懂,那个线程A和线程B同时执行getAndAdd的例子解决了我对CAS的很长久的疑惑,感谢感谢_(:зゝ∠)_
        美团Java:@yzhym1 客气客气
      • 77270532c100:看中间的过程,我还是有点疑惑,多CPU,A B线程同时进行到了与内存值比较的情况下,它怎么确保线程安全呢。A B线程都在运行,没有一个挂起。
        美团Java:@Godson_a837 有个wihile循环,一直在判断
        美团Java:@Godson_a837 可以看下compareAndSwap方法的实现
      • 世友_86e9:AtomicInteger getAndAdd 实现分析中 第4步"发手里的值3和内存值4不一致" 这个部分,是否应该是 “发手里的值3和内存值2不一致”
        美团Java:@世友_86e9 仔细啊
      • 3a1271aceb39:4线程A恢复,利用compareAndSwapInt方法比较,发手里的值3和内存值4不一致
        这个地方没看懂呢。第3部中不是线程B把内存值改为2了吗,4是从哪儿来的值
      • 1Day:《Java并发编程实战》:当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,而其他线程都将失败。然而,失败的线程并不会挂起!!!
      • 林英秋楚:线程A恢复,利用compareAndSwapInt方法比较,发手里的值3和内存值4不一致,此时value正在被另外一个线程修改,线程A不能修改value值
        这个内存值4是那里来的??
        美团Java:@好风凭借力丶 被另一个线程修改成4了
      • 4c664bd542da:多线程中每个线程共享的资源有哪些啊
        美团Java:@厚德载物来了 你这个问题问的方式不对啊
      • 码农皮邱:简单说:线程操作堆内存共享变量的时候,比较线程工作内存副本值与堆内存变量值,如果相等交换,否则不交换。这样理解对吗?
        美团Java:@邱勇Aaron 意思是你在修改在一个变量a,它原来的值是1,所以你预期它是1,如果在你修改的时候,它被别的线程更新为2,那么就不符合你的预期,你的修改也不会生效
        码农皮邱:@占小狼 那这个预期值A指代着什么?
        美团Java:@邱勇Aaron 不是,这里拿的都是主内存的
      • 6744894c37b6:“线程A继续利用compareAndSwapInt进行比较并替换,直到compareAndSwapInt修改成功返回true”,value是volatile的,修改value的值后线程A的值不是会从内存中读取写入到本地内存中吗?就是说它们的值一样了,为啥这时比较会不一样?而且如果一开始比较不一样后面比较为什么会一样?是因为修改有延迟吗还是怎样?求解答。。。。。
        美团Java:@梵蒂冈的昨天 volitile修饰的变量没有比较一说,可以看看volitile那文章
        5c1c22e66ab2:@占小狼 请问可不可以这样理解:A线程中一开始是3,B将他修改成4后由于是volatile类型,A中也变成了4,由于存在其他线程修改,所以A每次修改时都要比较一下自己的值有没有和内存中的值刷新成一样,只有一样了A才修改。。求解答
        美团Java:因为在同一时刻,可能有其它线程会执行相同的操作,导致取到的值和预期的值不相等
      • 闪电是只猫:赞一个
        美团Java:@小笨笨oO :relaxed:
      • 程序员爸爸:花了不少时间研究源码
        美团Java:是啊
      • ae6faf50bf4d:这种c++的代码是怎么查看啊 博主
        ae6faf50bf4d:@占小狼 :disappointed_relieved:
        美团Java:@刘君君 肉眼看啊,还没开始调试
      • Typhoon叔牙:文章写得很细,分析的很透彻,jdk源码有些比较晦涩难懂的可以在这里找到详解,赞赞赞
        美团Java:@ScorpioAeolus 哈哈哈,能帮助你就好
      • 9c07f4f183fd:真是太棒了
      • 轩平:赞👍
        美团Java:@轩平 哈哈,他们太没有底线了
        轩平: @占小狼 importnew公众号被你的文章刷屏了😁
        美团Java:@轩平 :smile:
      • a9f0f91b91fd:赞,我是顺序看你的文章的,之前的文章一直将cas指令什么什么的,都不太懂,直到看到这篇文章~
        美团Java:@蜗牛加速飞 赞
      • 4e34bdcfcb25:请问读的什么资料,英文版说明吧
        美团Java:@discoverX 书籍和源码
        4e34bdcfcb25: @discoverX 我是想问学java看什么资料,呵呵
        美团Java:@discoverX 什么
      • BigfaceMonster:够深入,不错不错。
        美团Java:@melody12ab 多谢支持

      本文标题:深入浅出CAS

      本文链接:https://www.haomeiwen.com/subject/nwejjttx.html