深入解析 volatile 、CAS 的实现原理

转载自:https://www.jianshu.com/p/bd68ddf91240

文章目录

      • 一、预备知识
        • 1. 缓存
        • 2. 缓存一致性协议
          • 2.1 窥探技术
          • 2.2 MESI协议
      • 二、volatile的内存语义
      • 三、CAS(CompareAndSet)
        • 1. 处理器如何实现原子操作
        • 2. lock前缀
      • 四、synchronized与volatile

一、预备知识

1. 缓存

  现代处理器为了提高访问数据的效率,在每个CPU核心上都会有多级 容量小、速度快 的缓存(分别称之为L1 cache,L2 cache,多核心共享L3 cache等),用于缓存数据。
  缓存系统中是以缓存行(cache line)为单位存储的。缓存行是 2 的整数幂个连续字节,一般为 32-256 个字节。最常见的缓存行大小是 64个字节。
  因此当CPU在执行一条读内存指令时,它是会将内存地址所在的内容加载进缓存中的,加载的大小取决于是缓存行大小的。也就是说,一次加载一整个缓存行。

2. 缓存一致性协议

  在多核处理器系统中,每个处理器核心都有它们自己的一级缓存、二级缓存等。这样一来当多个处理器核心在对共享的数据进行写操作时,就需要保证该共享数据在所有处理器核心中的可见性/一致性。
  【窥探技术 + MESI协议 】的出现,就是为了解决多核处理器时代,缓存不一致的问题的。

2.1 窥探技术

  “窥探”背后的基本思想是,所有内存传输都发生在一条共享的总线上,而所有的处理器都能看到这条总线:缓存本身是独立的,但是内存是共享资源,所有的内存访问都要经过仲裁:同一个指令周期中,只有一个缓存可以读写内存。
  窥探技术的思想是,缓存不仅仅在做内存传输的时候才和总线打交道,而是不停地在窥探总线上发生的数据交换,跟踪其他缓存在做什么。所以当一个缓存代表它所属的处理器去读写内存时,其他处理器都会得到通知,它们以此来使自己的缓存保持同步。只要某个处理器一写内存,其他处理器马上就知道这块内存在它们自己的缓存中对应的缓存行已经失效。

2.2 MESI协议

  缓存系统 操作的最小单位就是缓存行,而MESI是缓存行四种状态的首字母缩写,任何多核系统中的缓存行都处于这四种状态之一。
  ① 失效(Invalid)缓存行:该处理器缓存中无该缓存行,或缓存中的缓存行已经失效了。
  ② 共享(Shared)缓存行:缓存行的内容是同主内存内容保持一致的一份拷贝,在这种状态下的缓存行只能被读取,不能被写入。多组缓存可以同时拥有针对同一内存地址的共享缓存行。
  ③ 独占(Exclusive)缓存行:和S状态一样,也是和主内存内容保持一致的一份拷贝。区别在于,如果一个处理器持有了E状态的缓存行,那其他处理器就不能同时持该内容的缓存行,所以叫“独占”。这意味着,如果其他处理器原本也持有同一缓存行,那么它会马上变成“失效”状态(I状态)。
  ④ 已修改(Modified)缓存行:该缓存行已经被所属的处理器修改了。如果一个缓存行处于已修改状态,那么它在其他处理器缓存中的拷贝马上会变成失效状态,这个规律和E状态一样。此外,已修改缓存行如果被丢弃或标记为失效(即从M状态 ——> I状态),那么先要把它的内容回写到内存中。

  只有当缓存行处于E或M状态时,处理器才能去写它,也就是说只有这两种状态下,处理器是独占这个缓存行的。当处理器想写某个缓存段时,如果它没有独占权,它必须先发送一条“我要独占权”的请求给总线,这会通知其他处理器,把它们拥有的同一缓存行的拷贝失效(I状态),如果它们有的话。只有在获得独占权后,处理器才能开始修改数据。并且此时,这个处理器知道,这个缓存行只有一份拷贝,在我自己的缓存里,所以不会有任何冲突。反之,如果有其他处理器想读取这个缓存行(能知道,因为一直在窥探总线),独占或已修改的缓存行必须先回到“共享”状态。如果是已修改的缓存段,那么还要先把内容回写到内存中。

  

二、volatile的内存语义

  volatile变量自身具有下列特性:
  ① 可见性:当一个变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。
  ② 禁止指令重排序:通过增加内存屏障的方式禁止指令重排。happens-before规则中有这么一条,volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。

  happens-before的这个规则会保证volatile写-读具有如下的内存语义:

volatile写的内存语义:当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值刷新到主内存。
volatile读的内存语义:当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。

  volatile内存语义的实现原理:为了实现 volatile 的内存语义,编译器在生成字节码时,会在指令序列中插入 内存屏障 来禁止特定类型的处理器重排序。因为内存屏障是一组处理器指令,它并不由JVM直接暴露,因此JVM会根据不同的操作系统插入不同的指令以达成所要内存屏障效果。
  为了保证内存可见性,java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。JMM把内存屏障指令分为下列四类:

在每个volatile写操作的前面插入一个StoreStore屏障
在每个volatile写操作的后面插入一个StoreLoad屏障
在每个volatile读操作的后面插入一个LoadLoad屏障
在每个volatile读操作的后面插入一个LoadStore屏障

  内存屏障是一组处理器指令,而上面的四种内存屏障(StoreStore、StoreLoad、LoadLoad、LoadStore)是JMM内存屏障的分类,在不同的处理器中,会转换成相应处理器对应的该内存屏障类型的指令。在X86处理器上执行volatile写操作时会插入一个带有lock前缀的指令(汇编指令)来实现volatile的内存语义的。如0x01a3de24: lock addl $0x0,(%esp);
  至于lock前缀是如何保证volatile的内存语义,将在下面介绍CAS时对lock前缀进行一个详细的说明。

  

三、CAS(CompareAndSet)

  以原子的方式更新这个更新器所管理的对象(obj)的成员变量,如果当前值等于期望值(expect)时,将这个成员变量更新为给定的值(update)。该操作具有volatile 读和写的内存语义。
  前面已经介绍了原子操作的概念,所以这里CAS涉及的两步:只有field的值为expect时;将field的值修改为update的值;这视为一个原子操作。

1. 处理器如何实现原子操作

  首先处理器会自动保证基本的内存操作的原子性。同时,处理器提供 总线锁定缓存锁定 两个机制来保证复杂内存操作的原子性。
  ① 使用 总线锁 保证原子性
  所谓总线锁就是使用处理器提供的一个LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占使用共享内存。
  ② 使用 缓存锁 保证原子性
  在同一时刻只需保证对某个内存地址的操作是原子性即可,但总线锁定把CPU和内存之间通信锁住了,这使得锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁定的开销比较大,处理器在某些场合下使用缓存锁定代替总线锁定来进行优化。

  所谓“缓存锁定”就是 如果缓存在处理器缓存行中内存区域在LOCK操作期间被锁定,当它执行锁操作回写内存时,处理器不在总线上声言LOCK#信号,而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性,因为缓存一致性机制会阻止同时修改被两个以上处理器缓存的内存区域数据,当其他处理器回写已被锁定的缓存行的数据时会起缓存行无效。
  在JDK 8,Linux操作系统,X86处理器环境下,CAS的源码如下:

// Adding a lock prefix to an instruction on MP machine
#define LOCK_IF_MP(mp) "cmp $0, " #mp "; je 1f; lock; 1: "

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

  程序会根据当前处理器的类型来决定是否为cmpxchg指令添加lock前缀。如果程序是在多处理器上运行,就为cmpxchg指令加上lock前缀(lock cmpxchg)。反之,如果程序是在单处理器上运行,就省略lock前缀(单处理器自身会维护单处理器内的顺序一致性,不需要lock前缀提供的内存屏障效果)。CAS在底层是使用cmpxchg指令完成的,还是compareAndSet的过程。
  
  应用层面其实体现的很明显, volatile是通过线程屏障, 也就是编译器插入指令,然后在真正运行时会自动转成lock前缀的指令, 而cas则在java中是native方法,那么必然有其对应的c++代码,这样很明显volatile的性能要好点.

  cas调用了汇编指令cmpxchg,lock_if_mp在CPU指令上也会加锁,但是这个锁和java的锁不太一样,不用切换到内核态所以更轻量级。

2. lock前缀

  lock前缀的指令的说明:
  ① 保证指令的执行的原子性
  带有lock前缀的指令在执行期间会锁住总线,使得其他处理器暂时无法通过总线访问内存。很显然,这会带来昂贵的开销。从Pentium 4,Intel Xeon及P6处理器开始,intel在原有总线锁的基础上做了一个很有意义的优化:如果要访问的内存区域(area of memory)在lock前缀指令执行期间已经在处理器内部的缓存中被锁定(即包含该内存区域的缓存行当前处于独占或已修改状态),并且该内存区域被完全包含在单个缓存行(cache line)中,那么处理器将直接执行该指令。由于在指令执行期间该缓存行会一直被锁定,其它处理器无法读/写该指令要访问的内存区域,因此能保证指令执行的原子性。

  ② 禁止该指令与之前和之后的读和写指令重排序
  LOCK前缀会禁止指令与之前和之后的读和写指令重排序。这相当于JMM中定义的StoreLoad内存屏障的效果。也正是因为这个内存屏障的效果,会使得线程把其写缓冲区中的所有数据刷新到内存中。注意,这里不是单单被修改的数据会被回写到主内存,而是写缓存中所有的数据都回写到主内存。
  而将写缓冲区的数据回写到内存时,就会通过缓存一致性协议(如,MESI协议)和窥探技术来保证写入的数据被其他处理器的缓存可见。
  而这就相当于实现了volatile的内存语义。
  所以,CAS的指令的原子性,以及内存语义就是通过lock前缀指令来完成的。

四、synchronized与volatile

  synchronized关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率,而volatile关键字在某些情况下性能要优于synchronized,但是要注意volatile关键字是无法替代synchronized关键字的,因为volatile关键字无法保证操作的原子性。通常来说,使用volatile必须具备以下2个条件:
  1)对变量的写操作不依赖于当前值
  2)该变量没有包含在具有其他变量的不变式中

  实际上,这些条件表明,可以被写入 volatile 变量的这些有效值独立于任何程序的状态,包括变量的当前状态。
  下面列举几个Java中使用volatile的几个场景。
  1. 状态标记量

volatile boolean flag = false;
while(!flag){
     doSomething();
}
public void setFlag() {
    flag = true;
}

volatile boolean inited = false;
//线程1:
context = loadContext();  
inited = true;            
//线程2:
while(!inited ){
    sleep()
}
doSomethingwithconfig(context);

  2.double check

class Singleton{
    private volatile static Singleton instance = null;
      
    private Singleton() {
    }
      
    public static Singleton getInstance() {
        if(instance==null) {
            synchronized (Singleton.class) {
                 if(instance==null)
                     instance = new Singleton();
            }
        }
        return instance;
    }
}

  volatile让变量每次在使用的时候,都从主存中取。而不是从各个线程的“工作内存”。volatile具有synchronized关键字的“可见性”,但是没有synchronized关键字的“并发正确性”,也就是说不保证线程执行的有序性。也就是说,volatile变量对于每次使用,线程都能得到当前volatile变量的最新值。但是volatile变量并不保证并发的正确性。

你可能感兴趣的:(#,【同步/锁/volatile】)