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;
}