Volatile与CAS的底层原理

Volatile与CAS的底层原理

引言:VOLATILE是JAVA中一个极其重要关键字,它保证的内存的可见性,但是并不能够保证原子性。而CAS是采用一种无锁的方式,解决VOLATILE所不能带来的原子性等这类问题。接下来,就讲讲VOLATILE与CAS吧!


一、volatile

在讲解 Volatile关键字之前,先上个小demo!

 private static volatile long longValue = 0;
    private void testVolatile() {
        Thread t1 = new Thread(() -> {
            long val = 0;
            while (val < 10000000L) {
                longValue++;
                val++;
            }
        });
        t1.start();
        Thread t2 = new Thread(() -> {
            long val = 0;
            while (val < 10000000L) {
                longValue++;
                val++;
            }
        });
    /*    try {
            //休眠5S,等待第一个线程跑完
            Thread.sleep(5000L);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }*/
        t2.start();
        try {
            //休眠5S,等待两个线程跑完
            Thread.sleep(5000L);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("final val is: " +longValue);
    }
    public static void main(String[] args) {
        new TestVolatile().testVolatile();
    }

上述代码总结:

  • longValue变量使用了volatile定义,然后有2种测试方案:
    • 第一个线程跑完,再跑第二个线程,结果为 20000000
    • 两个线程同时跑,结果为1XXXXXXX
  • 因此,可以看出,volatile并不能够保证原子性

1、内存可见性

Volatile的内存可见性的解释如下:

  • 如果一个变量用了volatile修饰,那么这个变量是对所有线程共享的、可见的,每次jvm都会读取最新写入的值并使其最新值在所有CPU可见。

这个时候疑问来了!!!既然volatile能够保证内存的可见性,即使得所有的线程可以见到最新写入的值,那么上面的demo代码种,两个线程同时进行修改,为什么会出现数据不一致的问题呢?让我们一起看看 volatile的指令实现:

//例如volatile定义的一个变量的自增,分为如下3步:
mov    0xc(%r10),%r8d ; Load
inc    %r8d           ; Increment
mov    %r8d,0xc(%r10) ; Store
lock addl $0x0,(%rsp) ;  Barrier //这一步不要忽略!

具体步骤如下:

1、读取volatile变量到本地内存区;

2、在本地内存区实现volatile变量的自增;

3、将本地内存区的变量回写到主内存;

最后,插入内存屏障(memory barrier)。

在这里,我们对刚才这个保证内存可见性的过程进行分析,得出一个结论:

  • 之所以出现demo中的数据不一致或者说是不保证原子性的原因就在于,1~3步这个过程中,假设现在有两个线程,变量的值为1,同时进行 Load , 读取到本地的都是1,都对1执行 increment自增操作,即都变为2,再写回共享内存区,此时的变量值为2,但是,按道理来说,执行两次自增,应该为3才是对的!
  • 总结,在1~3步骤的执行过程,出现了load同一个value的情况,导致变量自增没有原子性。

刚才上述是四个步骤,还有最后一个步骤没有进行讲解,这个步骤是添加内存屏障!


2、内存屏障

首先,内存屏障是一个CPU指令,该指令的作用就是:

  • 确保一些特定操作执行的顺序(禁止指令重排序)
  • 影响一些数据的可见性(保证可见性)

可能对上面两点作用的概述不是很理解,没有关系,继续看!通常,编译器与CPU会对代码在保证输出结果一样的情况下进行指令重排序,使得性能得到优化。而插入一个内存屏障,就相当于告诉CPU与编译器,先于这个指令的代码必须优先执行,后于这个指令的代码必须后执行;内存屏障另一个作用是强制更新一次不同CPU的缓存。例如,一个写屏障会把这个屏障前写入的数据刷新到缓存,这样任何试图读取该数据的线程将得到最新值,而不用考虑到底是被哪个cpu核心或者哪颗CPU执行的。

那么,volatile与内存屏障的关系是什么?

——————如果一个变量为volatile定义,Java内存模型将在写操作后插入一个写屏障指令在读操作前插入一个读屏障指令。这意味着如果你对一个volatile字段进行写操作,必须知道的是:

1、一旦你完成写入,任何访问这个字段的线程将会得到最新的值。

2、在你写入前,会保证所有之前发生的事已经发生,并且任何更新过的数据值也是可见的,因为内存屏障会把之前的写入值都刷新到缓存。

在理解了内存屏障与volatile的关系之后,想必明白 Volatile是如何保证内存可见性的。

以下是四种内存屏障在JVM的优化应用原理图:

在这里插入图片描述

虽然上图的内存屏障看似好像是把对volatile的操作原子性化,但是,上面指的是读/写操作(这里的读写是一步完成的操作),即gettter、setter,并不保证自增之类的复合操作(多步完成)的原子性。


3、部分原子性

在某些文档资料中,我们听说过,volatile具有原子性!但是,在本篇首部的demo中,我们可以很清楚地知道, volatile在进行自增的时候并没有原子性的存在,那么为何有些地方又说具有原子性呢?其实,就我个人理解,这个 volatile关键字定义的变量,是具有部分原子性的。

什么是部分原子性:

  • 通过 volatile定义的变量,对这个变量进行的某些操作是具备原子性的,一些操作不具备原子性。
  • 部分原子性操作:对该变量进行读/写的操作具有原子性,例如getter、setter,因为,这些的操作实现只有一步,就是读取值/修改值,同时拥有内存屏障的情况下,保证了原子性,这也是在一些地方能够替代锁的原因
  • 不具备原子性操作:例如自增、自减操作,它的指令实现其实是分为多步执行的,即复合操作,且newValue依赖于oldValue,那么,就会失去原子性!

4、happens-before

在这里插入图片描述

一共可以分为3个:

  • 根据程序次序规则,1 happens before 2; 3 happens before 4
  • 根据volatile规则,2 happens before 3
  • 根据happens before 的传递性规则,1 happens before 4

其中橙色代表volatile规则,蓝色箭头表示组合这些规则后提供的happens before保证。


二、CAS

CAS是基于乐观锁的设计,进行一个比较并交换的操作,可以理解为当寄存器的旧值不等于期待的旧值的时候,说明有其他线程在修改,那么继续进行尝试,直到寄存器的旧值等于期待的旧值的时候,进行替换,从而保证操作的原子性。配合volatile进行使用,从而又保证了内存的可见性,从而保证线程安全。

上面这句话怎么解释呢?我们以开篇的demo为例来讲解:

mov    0xc(%r10),%r8d ; Load
inc    %r8d           ; Increment
mov    %r8d,0xc(%r10) ; Store
lock addl $0x0,(%rsp) ; StoreLoad Barrier 
//把demo中自增的几步指令操作内容封装成伪代码
no_lock_increment(value){
	load value;
	inc value;
	store value;
	add Barrier ;
}
//如果采用CAS设计,那么,封装成的伪代码如下:
no_lock_increment_by_cas(reg,expect,target){
	lock; //使用lock指令,CPU发出LOCK#信号,这个LOCK#是BUS-LOCK
	if(reg==expect)
	reg=target;
	else
	do none;
}

解析:

  • no_lock_increment(value)该方法,在上面讲到这几个步骤造成了没有原子性, 仔细分析,问题 出现在load value阶段,即从主存加载volatile变量到本地缓存。(在多线程访问的时候出现了load同一个值的情况)
  • no_lock_increment_by_cas(reg,expect,target)这个方法,则是CAS的伪代码实现,但其实JVM也只是调用了底层的汇编,因为CAS的实现是依赖于硬件平台的。后面我给出对应的内联汇编代码!

好了,经过以上的分析,得出的重要结论有:

  • volatile变量之所以出现问题的地方,就是在: 从主存中读取到到(核)本地的变量的值不是最新的
  • CAS是基于原子汇编的,而volatile变量在主存中的最新值又是由BUS(总线)与缓存一致性原则保证

那么接下来,就先介绍BUS总线与缓存一致性原则。

1、BUS(总线)

在这里插入图片描述

首先,Bus是所有CPU的Core之间联系的一条通道,CPU依赖该通道连接主存,而每个核都有自己的缓存线,也称高速缓冲。就我目前的个人电脑,拥有三级缓存,分别为L1、L2、L3。上图只是列出了一个L1与整体架构。

2、缓存一致性

直接与CAS相关的,应该就是缓存一致性机制了,下面,我将给大家介绍一下缓存一致性机制。

从多线程角度上看,假如是上图结构,那么该CPU可以同时启动6个线程进行工作,我们只取其中的core1和core2两个核进行讲解。当core1从通过BUS总线,从主存中load一个volatile变量的值,缓存到cacheLine;core2与core1是并行的,因此,也缓存了相同的变量值,当core1修改了cache的变量值,并且写回主存,这个时候,会通过BUS总线,使得core2的cache对应值无效。而当core2发现自己的缓存无效(缓存命中缺失)就会重新通过BUS总线请求主存,获取最新的值。这就是缓存一致性的原理。


3、CAS结合BUS与缓存一致性

CAS就是结合了BUS与缓存一致性,从而解决无锁化保证原子性操作!具体如下:

  • 执行CAS的时候,判断是不是多核系统,如果是,则给BUS总线加锁,此时其它core无法通过BUS访问主存。
  • 如果eax寄存器的值(compare_value)与exchange_value(来自主存)相等,则会令exchange_value=dest(dest为我们想要set的值),否则,将来自主存的值exchange_value赋给eax寄存器中。

这个过程的伪代码如下:

if(compare_value == exchange_value)
  exchange_value = dest;
else
  compare_value = exchange_value;
//根据这个代码可以做一个推导,看看与CAS是否一致以及是否解决了原子性问题。
//①expect = 1; 主存的值(当前值等于1);要设置为2(自增)
//执行:1、锁总线 2、判断compare_value(加载进来的这个值,这里其实就是原子操作的load加载步骤)判断与当前总线exchange_value是否相等;相等则表明是load进来的值是最新的,那么,执行:3、把exchange_value=dest(想要设置的值)并且写回到主存;同时,执行BUS缓存一致性原则,使得其它CORE的compare_value无效。如果不相等,则说明其它CORE更改了exchange_value,那么执行compare_value=exchange_value,尝试着找回compare_value与exchange相同的值。
//执行完一轮之后,接下来谁想要执行CAS,就需要进行锁总线,并且load加载期待值到CORE本地。继续执行上述操作。

上述的伪代码主要是理解注释部分,接下来贴出JNI的内联汇编实现:

//	exchange_value来自主存的即将更新的值
//	compare_value则是原内存的值,或者说是缓存的值
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)//=a表示写到eax寄存器
                    : "r" (exchange_value), "a" (compare_value), "r" (dest), "r" (mp)//%4=mp
                    : "cc", "memory");
  //cc表示编译器cmpxchgl的执行将影响到标志寄存器, memory告诉编译器要重新从内存中读取变量的最新值
  return exchange_value;
  //%1=exchange_value   %3=dest
}

解析:

  • cmpxchgl %1,(%3)这个就是执行cas的汇编指令;
  • %1代表exchange_value;%3代表dest,则CAS表达式其实就是 cmpxchgl exchange_value ,dest
  • 可能会发现没有%2,即compare_value,其实,通过查询cmpxchgl指令的操作语义,知道了cmpxchgl指令会默认比较eax寄存器的值(compare_value)与exchange_value,如果相等,就把dest的值赋值给exchange_value,否则,将exchange_value赋值给eax。

**三、**总结

  • volatile的内存可见性、内存屏障、部分原子性、happens-before规则
  • 为何会导致volatile的自增自减等复合操作没有原子性
    • load到旧的值
  • BUS总线以及缓存一致性原则
  • CAS如何结合BUS总线与缓存一致性原则

最后,附上一些内联汇编的代码,有兴趣的可以朋友可以自己玩一下:

#include <iostream>

using namespace std;

void cmpxchg(){
    int cpp_eax = 0;
    int cpp_ebx = 0;
    int expect = 8888;
    int target = 8888;
    __asm__ __volatile__("movl    $2222, %%eax \n"  /*将字面量2222存入到eax寄存器中*/
                         "movl    %3   , %%ebx \n"  /*将8888存入到ebx寄存器中*/
                         "lock;" "cmpxchg %%ebx, %2    \n"  /*如果变量expect的值与寄存器eax的值不相等(失败), 那么expect的值就赋给eax*///相等则(8888)-eax
                         "movl    %%eax, %0    \n"  /*将寄存器eax的值赋值给变量cpp_eax*/
                         "movl    %%ebx, %1    \n"  /*将寄存器ebx的值赋值给变量cpp_ebx*/
    :"=r"(cpp_eax), "=r"(cpp_ebx), "=m"(expect)     /*等于号表示可写, 加号表示可写可读*/
    :"r"(target)
    /*cpp_eax是%0, cpp_ebx是%1, expect是%2, target是%3*/
    //如果加lock前缀的话, 指令的第2操作数必须是存储在内存中的, 语句格式是这样`
    // lock;cmpxchg 操作数1(寄存器), 操作数2(内存) ` .
    // 所以, expect作为output的时候是这样声明的: "=m"(expect) 
    :"%eax", "%ebx", "memory", "cc");
    cout << "eax := " << cpp_eax << endl;
    cout << "ebx := " << cpp_ebx << endl;
    cout << "expect := " << expect << endl;
    cout << "target := " << target << endl;
}
int main(){
  cmpxchg();
  return 0;
}