JAVA并发:深入理解volatile的实现原理

一、JAVA内存模型简介(JMM)

1.1、JMM定义

Java内存模型(Java Memory Model) 就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能保证效果一致的机制及规范。

为了保证共享内存的正确性(可见性、有序性、原子性),内存模型定义了共享内存系统中多线程程序读写操作行为的规范。解决了CPU多级缓存、处理器优化、指令重排等导致的内存访问问题,保证了并发场景下的一致性、原子性和有序性。

1.2、JMM需要解决的问题

由于计算的存储设备与处理器的运算速度有几个数量级的差距,所以现代计算机系统每个CPU都有多级缓存(L1、L2、L3)来作为内存与处理器之间的缓冲,处理器将运算所需要的数据复制到缓存中,让运算能快速进行,

当运算结束之后再从缓存同步回内存之中,这样提供了数据的访问性能,也减轻了数据总线上数据传输的压力,同时也带来了很多新的问题:缓存一致性(Cache Coherence)问题。

JAVA并发:深入理解volatile的实现原理_第1张图片

强的内存模型(strong memory model) : 能够让所有的处理器在任何时候任何指定的内存地址上都可以看到完全相同的值。

弱的内存模型(weaker memory model):必须使用内存屏障(一种特殊的指令)来刷新本地处理器缓存并使本地处理器缓存无效,目的是为了让当前处理器能够看到其他处理器的写操作或者让其他处理器能看到当前处理器的写操作。
近来处理器设计的趋势更倾向于弱的内存模型,因为弱内存模型削弱了缓存一致性,所以在多处理器平台和更大容量的内存下可以实现更好的可伸缩性。

1、JMM屏蔽了不同处理器内存模型的差异,它在不同的处理器平台之上为Java程序员呈现了一个一致的内存模型。

2、JMM在实现上,在不改变(正确同步的)程序执行结果的前提下,尽可能地位编译器和处理器的优化打开方便之门。

1.3、JMM的抽象结构

Java的并发采用的是共享内存模型,Java线程之间的通信总是 隐式进行,整个通信过程对程序员是完全透明,在Java中,所有共享变量(实例域、静态域、数组元素)都存储于堆内存中。

JMM决定一个线程对共享变量的写入何时对另外一个线程可见。

从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系: 线程之间的共享变量存储于主内存,每个线程都有一个私有的本地内存(Local Memory), 本地内存中存储了该线程以读/写共享变量的副本

JAVA并发:深入理解volatile的实现原理_第2张图片

Java内存模型抽象示意图,如下:


JAVA并发:深入理解volatile的实现原理_第3张图片

图3-1可以看出,线程之间通信必须经过下面两个步骤:

  1. 线程A将本地内存A中更新过的共享变量刷新的主内存中
  2. 线程B到主内存中读取A线程更新过的共享变量

二、指令重排序

在执行程序时,编译器和处理器常常会对指令进行重排序,重排序是指编译器和处理器为了优化程序性能而对指令序列重新排序的一种手段。

2.1、重排序种类

  • 编译器优化重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  • 指令级并行重排序:如果不存在数据依赖性,处理器可以改变语句对应的机器指令的执行顺序。
  • 内存系统的冲排序:由于处理器使用缓存和读/写缓冲区,那么加载操作、存储操作可能也会被重排序。

从Java源代码到最终实际执行的指令序列,会经历下面3种重排序,如下图所示。

2.2、数据依赖性

数据依赖性:如果两个操作访问同一个变量,且这两个操作中有一个为写操作,那么这两个操作之间就存在数据依赖性。


JAVA并发:深入理解volatile的实现原理_第4张图片

编译器和处理器在重排序时,不会改变存在数据依赖关系的两个操作的执行顺序。

2.3、重排序-单线程影响

从2.2中我们知道,编译器和处理器不会对存在数据依赖关系的操作进行重排序,但是如果两个操作之间不存在数据依赖关系,那么这些操作可能会被重排序。


5.png

从上图可以看出, A和C 存在数据依赖, B和C 存在数据依赖 ,A和B之间没有数据依赖关系,所以程序可能执行的顺序如下:


JAVA并发:深入理解volatile的实现原理_第5张图片

结论:重排序不会对影响单线程程序的执行语义,也无需担心内存可见性问题

2.4、重排序-多线程影响

class ReorderExample {
      int a = 0;
      boolean flag = false;
      public void writer() {
        a = 1;                // 1  
        flag = true;          // 2
      }
      public void reader() {
        if (flag) {          // 3
            int i = a*a;       // 4
        }
      }
}

线程A 执行 writer方法, 线程B执行 reader方法。

  • case1: 线程A 1和2 进行重排序,则程序执行结果:


    JAVA并发:深入理解volatile的实现原理_第6张图片
  • case2:线程B 3和4 进行重排序,3和4之间存在控制依赖关系 则程序执行结果:


    JAVA并发:深入理解volatile的实现原理_第7张图片

    对存在控制依赖关系的两个操作进行重排序,会破坏多线程执行的语义。

当代码中存在控制依赖性时, 会影响指令序列执行的并行度。编译器和处理器会采用猜测(Speculation)执行来克服控制依赖相关性对指令并行度的影响,执行线程B的处理器会提前读取并计算temp=a*a, 并将计算结果临时保存到重排序缓冲(Reorder Buffer,ROB)的硬件缓存中,当执行到操作3 flag=true的时候,就会把计算结果写入到变量i=temp

三、Java内存模型的Happens-before原则

happens-before是JMM最核心的概念,JMM为程序中的所有操作定义了一个happens-before原则。
happens-before原则包括:

  • 程序顺序规则,如果程序中操作A在操作B之前,那么线程A操作将在B操作之前执行
  • 监视器锁规则,对一个锁的解锁,happens-before于随后对这个锁的加锁
  • volatile变量规则,对一个volatile变量的写,happens-before于任意后续对这个volatile域的读
  • 线程启动规则,在线程对Thread.start的调用必须在该线程中执行任何操作之前执行。
  • 线程结束规则,线程中的任何操作都必须在其他线程检测到该线程已经结束之前执行,或者从Thread.join中成功返回,或者在调用Thread.isAlive时返回false
  • join规则, 如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回
  • 中断规则,当一个线程在另一个线程调用interrupt时,必须在被中断线程检测到interrupt调用之前执行(通过抛出 InterruptedException,h或者调用isInterrupted和interrupted)
  • 终结器规则,对象的构造函数必须在启动该对象的终结器之前执行完成。
  • 传递性,如果操作A在操作B之前执行,并且操作B在操作C之前执行,那么操作A必须在操作C之前执行

四、volatile关键字

4.1、volatile的定义

volatile关键字是JVM提供的最轻量级的同步机制,它在多处理器开发中保证了共享变量的"可见性",可见性是指:当一个线程修改一个共享变量时,另外一个线程能读到这个修改后的值。如果一个字段被声明成volatile,Java内存模型确保所有线程看到这个变量的值都是一致的,如果volatile变量修饰符使用恰当,它比synchronized的使用和执行成本更低,因为它不会引起线程上下文的切换和调度。

4.2、volatile的特性

volatile字段是用于线程间通讯的特殊字段。每次读volatile字段都会看到其它线程写入该字段的最新值。
理解volatile特性,可以将volatile变量的单个读/写 -> 使用同一个锁对这些单个读/写操作做了同步。

class VolatileFeaturesExample {
        volatile long v1 = 0L;            // 使用volatile声明long型变量
    public void set(long l) {         
       v1 =1;                         // 单个volatile变量的写
    }
    public void getAndIncrement () {
        v1++;                          // 复合(多个)volatile变量的读/写
    }
    public void get() {
        return v1;                    // 单个volatile变量的读
    }
}
// 在多线程环境执行上面程序的三个方法,其实等价于下面程序的内存语义
class VolatileFeaturesExample {
        long v1 = 0L;                      // 64位long型普通变量
    public synchronized void set(long l) {         
       v1 =1;                         // 使用同步锁对单个变量的写操作
    }
    public void getAndIncrement () {
      long temp = get();           
      temp += 1L;     
      set(temp);
    }
    public synchronized void get() {
        return v1;                    // 使用同步锁对单个变量的读操作
    }
}

锁的释放、获取保证两个线程之间的可见性, volatile的写-读 也能保证两个线程之间的可见性。
1、对一个volatile的读,总能看到任意线程对这个volatile变量的最后写入
2、单个volatile变量的读/写具有原子性,但是volatile++这种复合操作不具有原子性

4.3、volatile可见性

对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。

instance = new Singleton(); // instance是一个 volatile变量

通过工具获取JIT编译器生成的汇编指令,来查看对volatile进行写操作的时候,CPU会做什么事情,汇编代码如下:



通过上面的汇编代码看出,volatile变量在写操作的时候会多出一个lock前缀的汇编指令。
Lock前缀指令在多核处理器下会引发两件事情:

4.3.1、Lock前缀指令会引起处理器缓存回写到主内存

在多处理器环境下,Lock#信号确保在声言该信号期间,处理器可以独占任何共享内存。
老的处理器,例如:Intel486、Pentium处理器,在锁操作时,总是在总线上声言Lock#信号【总线锁
最近处理器,如果访问的内存区域已经缓存到处理器内部,则不会在总线上加锁,而是锁定这块内存区域的缓存并回写到主内存,使用缓存一致性机制来确保修改的原子性。缓存一致性机制:阻止同事修改由两个以上处理器缓存的内存区域数据【缓存锁

4.3.2、一个处理器的缓存回写到内存会导致其他处理器的缓存无效

IA-32、Intel64处理器使用MESI(修改、独占、共享、无效)控制协议维护内部缓存和其他处理器缓存一致性。
IA-32、Intel64处理器能嗅探其他处理器访问系统内存和它们的内部缓存。处理器使用嗅探技术保证它的内部缓存、系统内存和其他处理器的缓存的数据在总线上保持一致。
如果通过嗅探一个处理器来检测其他处理器打算写内存地址,而这个内存地址当前处于共享状态,那么正在嗅探的处理器将使它的缓存行置为无效,下次访问相同内存地址数据时,需要强制执行缓存行填充。

4.4、volatile原子性

4.4.1、原子性定义

原子(atomic): 不能被进一步分割的最小粒子
原子操作(atomic operation): 不可被中断的一个或一系列操作

4.4.2、处理器如何实现原子操作

最新的处理器能保证 单个处理器对同一个缓存行里进行16/32/64位的操作是原子性,但是复杂的内存操作是不能自动保证其原子性,比如操作跨越多个缓存行。所以处理器提供下面两个方式保证复杂内存操作的原子性。

  • 使用总线锁保证原子性
    例如: i++操作(读改写操作)
    JAVA并发:深入理解volatile的实现原理_第8张图片

    针对上述可能出现的错误结果,处理器使用总线锁解决这个问题,处理器提供了一个LOCK#信号,当一个处理器在总线上输出此信号时(当前处理器独占共享内存),其他处理器的请求将会被阻塞。
  • 使用缓存锁保证原子性
    由于总线锁将CPU和主内存之间的通信锁住了,使得其他处理器不能操作其他内存地址数据,总线锁定开销比较大,所以处理器提供了一种新的机制:使用缓存锁保证原子性
    缓存锁定:指内存区域如果被缓存在处理器的缓存行中,并且在Lock操作期间被锁定,那么当它执行锁操作回写到内存时,处理器不在总线上声明LOCK#信号,而是使用缓存一致性机制来保存操作的原子性。
    例如上面 i++操作,当CPU1修改缓存行中i时使用了缓存锁定,那么CPU2就不能同时缓存i的缓存行。

4.4.3、java如何实现原子操作

  • 使用循环CAS实现原子操作
    CAS(Compare and Swap): CAS操作需要输入两个数值,一个旧值(期望操作前的值)和一个新值,在操作期间先比较旧值有没有发生变化,如果没有发生变化,才交换成新值,发生了变化则不交换。
  • 使用锁机制实现原子操作

4.4.4、对volatile变量的操作是否具有原子性?

对单个volatile变量的写-读操作具有原子性,但是多个volatile变量的读写操作或者 volatile变量复合操作(volatile++)不具有原子性保证。

4.5、volatile有序性

从内存语义的角度,volatile的写 等价于锁的释放; volatile的读等价于锁的获取。为了实现volatile的内存语义,JMM会限制编译器和处理器重排序,下面是volatile重排序的规则表


JAVA并发:深入理解volatile的实现原理_第9张图片
f.png

五、Volatile关键字的JAVA应用场景

5.1、双重检查锁定

我们知道 双重检查锁定 是实现延迟初始化 单例的一种实现方式。
早期 双重检查锁定(Double-Checked Locking),见下面代码

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

上面这段代码看似很完美, 但是其实当代码执行到第4行的时候,检测到 instance不为空,第7行的初始化还没有完成,导致对象被非安全发布(逸出)。

产生问题的原因分析:
memory = allocate(); // 1、分配对象的内存空间
ctorInstance(memeory); // 2、初始化对象
instance = memory; // 3、设置instance指向刚分配的内存地址

因为 2和3操作可能会被指令重排序,重排序之后的执行时序如下:
memory = allocate(); // 1、分配对象的内存空间
instance = memory; // 3、设置instance指向刚分配的内存地址
ctorInstance(memeory); // 2、初始化对象

上面这段代码,在多线程执行的时序表:


JAVA并发:深入理解volatile的实现原理_第10张图片

解决方案: 禁止2和3重排序

public class DoubleCheckedLocking {
   // 将instance 声明为一个volatile变量
   private volatile static Instance instance;  
   public static Instance getInstance() {
     if (instance == null) {
         synchronized (DoubleCheckedLocking.class) {
              if(instance == null) {
                 // volatile变量 的写-读内存语义(本质上是禁止2和3的重排序)
                 instance = new Instance(); 
              }
         }
     }
     return instance;
   }
}

5.2 ConcurrentHashMap 的get方法

JAVA并发:深入理解volatile的实现原理_第11张图片

5.3 Java.util.concurrent包下的 AQS 队列同步器(AbstractQueuedSynchronizer)实现

AQS是用来构建锁或者其他同步组件的基础框架,它使用了一个int 成员变量标识同步状态 【private volatile int state】,通过内置的FIFO队列来完成资源获取线程的排队工作,底层使用CAS来实现原子操作。

你可能感兴趣的:(JAVA并发:深入理解volatile的实现原理)