+-
并发CAS机制你真的理解了嘛?(深入到操作系统分析)
首页 专栏 java 文章详情
0

并发CAS机制你真的理解了嘛?(深入到操作系统分析)

愚公要移山 发布于 1 月 22 日

学习Java并发编程,CAS机制都是一个不得不掌握的知识点。这篇文章主要是从出现的原因再到原理进行一个解析。希望对你有所帮助。

一、为什么需要CAS机制?

为什么需要CAS机制呢?我们先从一个错误现象谈起。我们经常使用volatile关键字修饰某一个变量,表明这个变量是全局共享的一个变量,同时具有了可见性和有序性。但是却没有原子性。比如说一个常见的操作a++。这个操作其实可以细分成三个步骤:

(1)从内存中读取a

(2)对a进行加1操作

(3)将a的值重新写入内存中

在单线程状态下这个操作没有一点问题,但是在多线程中就会出现各种各样的问题了。因为可能一个线程对a进行了加1操作,还没来得及写入内存,其他的线程就读取了旧值。造成了线程的不安全现象。如何去解决这个问题呢?最常见的方式就是使用AtomicInteger来修饰a。我们可以看一下代码:

 //使用AtomicInteger定义a
 static AtomicInteger a = new AtomicInteger();
 public static void main(String[] args) {
  Test3 test = new Test3();
  Thread[] threads = new Thread[5];
  for (int i = 0; i < 5; i++) {
   threads[i] = new Thread(() -> {
    try {
     for (int j = 0; j < 10; j++) {
      //使用getAndIncrement函数进行自增操作
      System.out.println(a.incrementAndGet());  
      Thread.sleep(500);
     }
    } catch (Exception e) {
     e.printStackTrace();
    }
   });
   threads[i].start();
  }
 }
}

现在我们使用AtomicInteger类并且调用了incrementAndGet方法来对a进行自增操作。这个incrementAndGet是如何实现的呢?我们可以看一下AtomicInteger的源码。

* Atomically increments by one the current value.
* @return the updated value
*/
public final int incrementAndGet() {
    return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}

我们到这一步可以看到其实就是usafe调用了getAndAddInt的方法实现的,但是现在我们还看不出什么,我们再深入到源码中看看getAndAddInt方法又是如何实现的,

 int var5;     
 do {          
  var5 = this.getIntVolatile(var1, var2);   
 } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));    
 return var5;   
}

到了这一步就稍微有点眉目了,原来底层调用的是compareAndSwapInt方法,这个compareAndSwapInt方法其实就是CAS机制。因此如果我们想搞清楚AtomicInteger的原子操作是如何实现的,我们就必须要把CAS机制搞清楚,这也是为什么我们需要掌握CAS机制的原因。

二、分析CAS

1、基本含义

CAS全拼又叫做compareAndSwap,从名字上的意思就知道是比较交换的意思。比较交换什么呢?

过程是这样:它包含 3 个参数 CAS(V,E,N),V表示要更新变量的值,E表示预期值,N表示新值。仅当 V值等于E值时,才会将V的值设为N,如果V值和E值不同,则说明已经有其他线程做两个更新,则当前线程则什么都不做。最后,CAS 返回当前V的真实值。

我们举一个我之前举过的例子来说明这个过程:

比如说给你儿子订婚。你儿子就是内存位置,你原本以为你儿子是和杨贵妃在一起了,结果在订婚的时候发现儿子身边是西施。这时候该怎么办呢?你一气之下不做任何操作。如果儿子身边是你预想的杨贵妃,你一看很开心就给他们订婚了,也叫作执行操作。现在你应该明白了吧。

CAS 操作时抱着乐观的态度进行的,它总是认为自己可以成功完成操作。所以CAS也叫作乐观锁,那什么是悲观锁呢?悲观锁就是我们之前赫赫有名的synchronized。悲观锁的思想你可以这样理解,一个线程想要去获得这个锁但是却获取不到,必须要别人释放了才可以。

2、底层原理

想要弄清楚其底层原理,深入到源码是最好的方式,在上面我们已经通过源码看到了其实就是Usafe的方法来完成的,在这个方法中使用了compareAndSwapInt这个CAS机制。因此,现在我们有必要进一步深入进去看看:

    // compareAndSwapInt 是 native 类型的方法
    public final native boolean compareAndSwapInt(
     Object o, 
     long offset,
        int expected,
        int x
    );
    //剩余还有很多方法
}

我们可以看到这里面主要有四个参数,第一个参数就是我们操作的对象a,第二个参数是对象a的地址偏移量,第三个参数表示我们期待这个a是什么值,第四个参数表示的是a的实际值。

不过这里我们会发现这个compareAndSwapInt是一个native方法,也就是说再往下走就是C语言代码,如果我们保持好奇心,可以继续深入进去看看。

           jobject obj, jlong offset, jint e, jint x))
  UnsafeWrapper("Unsafe_CompareAndSwapInt");
  oop p = JNIHandles::resolve(obj);
  // 根据偏移量valueOffset,计算 value 的地址
  jint* addr = (jint *) index_oop_from_field_offset_long(p, offset);
  // 调用 Atomic 中的函数 cmpxchg来进行比较交换
  return (jint)(Atomic::cmpxchg(x, addr, e)) == e;
UNSAFE_END

上面的代码我们解读一下:首先使用jint计算了value的地址,然后根据这个地址,使用了Atomiccmpxchg方法进行比较交换。现在问题又抛给了这个cmpxchg,真实实现的是这个函数。我们再进一步深入看看,真相已经离我们不远了。

                         volatile unsigned int* dest, 
                         unsigned int compare_value) {
    assert(sizeof(unsigned int) == sizeof(jint), "more work to do");
  /*
   * 根据操作系统类型调用不同平台下的重载函数,
     这个在预编译期间编译器会决定调用哪个平台下的重载函数
  */
    return (unsigned int)Atomic::cmpxchg((jint)exchange_value, 
                     (volatile jint*)dest, (jint)compare_value);
}

皮球又一次被完美的踢走了,现在在不同的操作系统下会调用不同的cmpxchg重载函数,我现在用的是win10系统,所以我们看看这个平台下的实现,别着急再往下走走:

       jint compare_value) {
  int mp = os::is_MP();
  __asm {
    mov edx, dest
    mov ecx, exchange_value
    mov eax, compare_value
    LOCK_IF_MP(mp)
    cmpxchg dword ptr [edx], ecx
  }
}

这块的代码就有点涉及到汇编指令相关的代码了,到这一步就彻底接近真相了,首先三个move指令表示的是将后面的值移动到前面的寄存器上。然后调用了LOCK_IF_MP和下面cmpxchg汇编指令进行了比较交换。现在我们不知道这个LOCK_IF_MPcmpxchg是如何交换的,没关系我们最后再深入一下。

真相来了,他来了,他真的来了。

                             volatile jint* dest, jint compare_value) {
  //1、 判断是否是多核 CPU
  int mp = os::is_MP();
  __asm {
    //2、 将参数值放入寄存器中
    mov edx, dest   
    mov ecx, exchange_value
    mov eax, compare_value 
    //3、LOCK_IF_MP指令
    cmp mp, 0
    //4、 如果 mp = 0,表明线程运行在单核CPU环境下。此时 je 会跳转到 L0 标记处,直接执行 cmpxchg 指令
    je L0
    _emit 0xF0
//5、这里真正实现了比较交换
L0:
    /*
     * 比较并交换。简单解释一下下面这条指令,熟悉汇编的朋友可以略过下面的解释:
     *   cmpxchg: 即“比较并交换”指令
     *   dword: 全称是 double word 表示两个字,一共四个字节
     *   ptr: 全称是 pointer,与前面的 dword 连起来使用,表明访问的内存单元是一个双字单元 
     * 这一条指令的意思就是:
       将 eax 寄存器中的值(compare_value)与 [edx] 双字内存单元中的值进行对比,
       如果相同,则将 ecx 寄存器中的值(exchange_value)存入 [edx] 内存单元中。
     */
    cmpxchg dword ptr [edx], ecx
  }
}

到这一步了,相信你应该理解了这个CAS真正实现的机制了吧,最终是由操作系统的汇编指令完成的。

3、CAS机制的优缺点

(1)优点

一开始在文中我们曾经提到过,cas是一种乐观锁,而且是一种非阻塞的轻量级的乐观锁,什么是非阻塞式的呢?其实就是一个线程想要获得锁,对方会给一个回应表示这个锁能不能获得。在资源竞争不激烈的情况下性能高,相比synchronized重量锁,synchronized会进行比较复杂的加锁,解锁和唤醒操作。

(2)缺点

缺点也是一个非常重要的知识点,因为涉及到了一个非常著名的问题,叫做ABA问题。假设一个变量 A ,修改为 B之后又修改为 A,CAS 的机制是无法察觉的,但实际上已经被修改过了。这就是ABA问题,

ABA问题会带来大量的问题,比如说数据不一致的问题等等。我们可以举一个例子来解释说明。

你有一瓶水放在桌子上,别人把这瓶水喝完了,然后重新倒上去。你再去喝的时候发现水还是跟之前一样,就误以为是刚刚那杯水。如果你知道了真相,那是别人用过了你还会再用嘛?举一个比较黄一点的例子,女朋友被别人睡过之后又回来,还是之前的那个女朋友嘛

ABA可以有很多种方式来解决,在其他的文章已经给出。

java
阅读 72 发布于 1 月 22 日
赞 收藏
分享
本作品系原创, 采用《署名-非商业性使用-禁止演绎 4.0 国际》许可协议
java小白到架构师系列
关注专栏
avatar
愚公要移山
45 声望
2 粉丝
关注作者
0 条评论
得票 时间
提交评论
avatar
愚公要移山
45 声望
2 粉丝
关注作者
宣传栏

学习Java并发编程,CAS机制都是一个不得不掌握的知识点。这篇文章主要是从出现的原因再到原理进行一个解析。希望对你有所帮助。

一、为什么需要CAS机制?

为什么需要CAS机制呢?我们先从一个错误现象谈起。我们经常使用volatile关键字修饰某一个变量,表明这个变量是全局共享的一个变量,同时具有了可见性和有序性。但是却没有原子性。比如说一个常见的操作a++。这个操作其实可以细分成三个步骤:

(1)从内存中读取a

(2)对a进行加1操作

(3)将a的值重新写入内存中

在单线程状态下这个操作没有一点问题,但是在多线程中就会出现各种各样的问题了。因为可能一个线程对a进行了加1操作,还没来得及写入内存,其他的线程就读取了旧值。造成了线程的不安全现象。如何去解决这个问题呢?最常见的方式就是使用AtomicInteger来修饰a。我们可以看一下代码:

 //使用AtomicInteger定义a
 static AtomicInteger a = new AtomicInteger();
 public static void main(String[] args) {
  Test3 test = new Test3();
  Thread[] threads = new Thread[5];
  for (int i = 0; i < 5; i++) {
   threads[i] = new Thread(() -> {
    try {
     for (int j = 0; j < 10; j++) {
      //使用getAndIncrement函数进行自增操作
      System.out.println(a.incrementAndGet());  
      Thread.sleep(500);
     }
    } catch (Exception e) {
     e.printStackTrace();
    }
   });
   threads[i].start();
  }
 }
}

现在我们使用AtomicInteger类并且调用了incrementAndGet方法来对a进行自增操作。这个incrementAndGet是如何实现的呢?我们可以看一下AtomicInteger的源码。

* Atomically increments by one the current value.
* @return the updated value
*/
public final int incrementAndGet() {
    return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}

我们到这一步可以看到其实就是usafe调用了getAndAddInt的方法实现的,但是现在我们还看不出什么,我们再深入到源码中看看getAndAddInt方法又是如何实现的,

 int var5;     
 do {          
  var5 = this.getIntVolatile(var1, var2);   
 } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));    
 return var5;   
}

到了这一步就稍微有点眉目了,原来底层调用的是compareAndSwapInt方法,这个compareAndSwapInt方法其实就是CAS机制。因此如果我们想搞清楚AtomicInteger的原子操作是如何实现的,我们就必须要把CAS机制搞清楚,这也是为什么我们需要掌握CAS机制的原因。

二、分析CAS

1、基本含义

CAS全拼又叫做compareAndSwap,从名字上的意思就知道是比较交换的意思。比较交换什么呢?

过程是这样:它包含 3 个参数 CAS(V,E,N),V表示要更新变量的值,E表示预期值,N表示新值。仅当 V值等于E值时,才会将V的值设为N,如果V值和E值不同,则说明已经有其他线程做两个更新,则当前线程则什么都不做。最后,CAS 返回当前V的真实值。

我们举一个我之前举过的例子来说明这个过程:

比如说给你儿子订婚。你儿子就是内存位置,你原本以为你儿子是和杨贵妃在一起了,结果在订婚的时候发现儿子身边是西施。这时候该怎么办呢?你一气之下不做任何操作。如果儿子身边是你预想的杨贵妃,你一看很开心就给他们订婚了,也叫作执行操作。现在你应该明白了吧。

CAS 操作时抱着乐观的态度进行的,它总是认为自己可以成功完成操作。所以CAS也叫作乐观锁,那什么是悲观锁呢?悲观锁就是我们之前赫赫有名的synchronized。悲观锁的思想你可以这样理解,一个线程想要去获得这个锁但是却获取不到,必须要别人释放了才可以。

2、底层原理

想要弄清楚其底层原理,深入到源码是最好的方式,在上面我们已经通过源码看到了其实就是Usafe的方法来完成的,在这个方法中使用了compareAndSwapInt这个CAS机制。因此,现在我们有必要进一步深入进去看看:

    // compareAndSwapInt 是 native 类型的方法
    public final native boolean compareAndSwapInt(
     Object o, 
     long offset,
        int expected,
        int x
    );
    //剩余还有很多方法
}

我们可以看到这里面主要有四个参数,第一个参数就是我们操作的对象a,第二个参数是对象a的地址偏移量,第三个参数表示我们期待这个a是什么值,第四个参数表示的是a的实际值。

不过这里我们会发现这个compareAndSwapInt是一个native方法,也就是说再往下走就是C语言代码,如果我们保持好奇心,可以继续深入进去看看。

           jobject obj, jlong offset, jint e, jint x))
  UnsafeWrapper("Unsafe_CompareAndSwapInt");
  oop p = JNIHandles::resolve(obj);
  // 根据偏移量valueOffset,计算 value 的地址
  jint* addr = (jint *) index_oop_from_field_offset_long(p, offset);
  // 调用 Atomic 中的函数 cmpxchg来进行比较交换
  return (jint)(Atomic::cmpxchg(x, addr, e)) == e;
UNSAFE_END

上面的代码我们解读一下:首先使用jint计算了value的地址,然后根据这个地址,使用了Atomiccmpxchg方法进行比较交换。现在问题又抛给了这个cmpxchg,真实实现的是这个函数。我们再进一步深入看看,真相已经离我们不远了。

                         volatile unsigned int* dest, 
                         unsigned int compare_value) {
    assert(sizeof(unsigned int) == sizeof(jint), "more work to do");
  /*
   * 根据操作系统类型调用不同平台下的重载函数,
     这个在预编译期间编译器会决定调用哪个平台下的重载函数
  */
    return (unsigned int)Atomic::cmpxchg((jint)exchange_value, 
                     (volatile jint*)dest, (jint)compare_value);
}

皮球又一次被完美的踢走了,现在在不同的操作系统下会调用不同的cmpxchg重载函数,我现在用的是win10系统,所以我们看看这个平台下的实现,别着急再往下走走:

       jint compare_value) {
  int mp = os::is_MP();
  __asm {
    mov edx, dest
    mov ecx, exchange_value
    mov eax, compare_value
    LOCK_IF_MP(mp)
    cmpxchg dword ptr [edx], ecx
  }
}

这块的代码就有点涉及到汇编指令相关的代码了,到这一步就彻底接近真相了,首先三个move指令表示的是将后面的值移动到前面的寄存器上。然后调用了LOCK_IF_MP和下面cmpxchg汇编指令进行了比较交换。现在我们不知道这个LOCK_IF_MPcmpxchg是如何交换的,没关系我们最后再深入一下。

真相来了,他来了,他真的来了。

                             volatile jint* dest, jint compare_value) {
  //1、 判断是否是多核 CPU
  int mp = os::is_MP();
  __asm {
    //2、 将参数值放入寄存器中
    mov edx, dest   
    mov ecx, exchange_value
    mov eax, compare_value 
    //3、LOCK_IF_MP指令
    cmp mp, 0
    //4、 如果 mp = 0,表明线程运行在单核CPU环境下。此时 je 会跳转到 L0 标记处,直接执行 cmpxchg 指令
    je L0
    _emit 0xF0
//5、这里真正实现了比较交换
L0:
    /*
     * 比较并交换。简单解释一下下面这条指令,熟悉汇编的朋友可以略过下面的解释:
     *   cmpxchg: 即“比较并交换”指令
     *   dword: 全称是 double word 表示两个字,一共四个字节
     *   ptr: 全称是 pointer,与前面的 dword 连起来使用,表明访问的内存单元是一个双字单元 
     * 这一条指令的意思就是:
       将 eax 寄存器中的值(compare_value)与 [edx] 双字内存单元中的值进行对比,
       如果相同,则将 ecx 寄存器中的值(exchange_value)存入 [edx] 内存单元中。
     */
    cmpxchg dword ptr [edx], ecx
  }
}

到这一步了,相信你应该理解了这个CAS真正实现的机制了吧,最终是由操作系统的汇编指令完成的。

3、CAS机制的优缺点

(1)优点

一开始在文中我们曾经提到过,cas是一种乐观锁,而且是一种非阻塞的轻量级的乐观锁,什么是非阻塞式的呢?其实就是一个线程想要获得锁,对方会给一个回应表示这个锁能不能获得。在资源竞争不激烈的情况下性能高,相比synchronized重量锁,synchronized会进行比较复杂的加锁,解锁和唤醒操作。

(2)缺点

缺点也是一个非常重要的知识点,因为涉及到了一个非常著名的问题,叫做ABA问题。假设一个变量 A ,修改为 B之后又修改为 A,CAS 的机制是无法察觉的,但实际上已经被修改过了。这就是ABA问题,

ABA问题会带来大量的问题,比如说数据不一致的问题等等。我们可以举一个例子来解释说明。

你有一瓶水放在桌子上,别人把这瓶水喝完了,然后重新倒上去。你再去喝的时候发现水还是跟之前一样,就误以为是刚刚那杯水。如果你知道了真相,那是别人用过了你还会再用嘛?举一个比较黄一点的例子,女朋友被别人睡过之后又回来,还是之前的那个女朋友嘛

ABA可以有很多种方式来解决,在其他的文章已经给出。