一篇文章让你从底层搞懂volatile

319 阅读16分钟

volatile这个关键字相比大家都不陌生,在并发编程的实践中,volatile的使用十分广泛,可以说,不了解volatile的原理,就很难说你了解java并发编程。

本文将从以下几个方面讲述volatile的底层逻辑和实现原理,帮助你更好的了解他真正的逻辑!

  • cpu的底层实现
  • JMM内存模型
  • 并发编程的三个基本问题
  • volatile原理与内存语义
  • 关于指令重排和内存屏障
  • 小结

一、 CPU的底层实现

多CPU

现代的计算机通常有两个或者多个cpu同时进行计算,如果要运行多个程序,或者说进程,假如只有一个CPU的话,那么进行进程的上下文切换的代价是十分高的

CPU多核
一个现代CPU除了处理器核心之外,还包括寄存器、L1L2L3三级缓存、浮点运算单元、整数运算单元和一些辅助运算设备,内部总线等。一个多核的CPU也就是一个CPU上有多个处理器核心,这样有什么好处呢?如果我们在一个计算机上跑一个多线程的程序,如果这台计算机都是单核的CPU的话,就意味着这个程序的不同线程需要经常在不同的CPU之间的外部总线上通信,同时还需要处理不同CPU之间不同的缓存导致的数据不一致的问题,所以在这种场景下,多核单CPU的加购就能发挥很大的有事,通信在内部总线,共用同一个缓存。

IMG_5AC50F0C63C2-1.jpeg

CPU读取存储器数据的过程

1、读取寄存器的值
直接读取,CPU直接和寄存器打交道
2、L1缓存
锁cache行,拿数据
3、L2缓存
先从L1中取,如不在,到L2中取,如在,L2加锁,先复制到L1,再执行读L1的过程。
4、L3缓存
同上
5、内存
通知内存控制器占用总线的贷款,通知内存加锁,发起内存读去就去,等待回应。回应保存到L3-->L2-->L1,最后解除总线锁定。

1.1缓存一致性问题

在多处理器的系统中,每个cpu都有自己的高速缓存,二他们有共享同一个主存的数据。基于高速存储的存储交互能够很好的解决CPU到主存之间的速度的矛盾,但是也引入了新的问题————缓存一致性。
当多个处理器的运算任务都涉及同一块主存区域的时候,可能导致各自的缓存数据不一致的情况,如果真的发生这种情况,那么应该以谁的数据为准呢?为了解决这个,需要在各个处理器访问缓存的时候都遵循一些协议,在读写的时候根据协议来进行操作。本文主要以MESI协议来进行说明。

image.png

1.11缓存行的四个状态:

image.png

修改数据的流程

  • 1、CPU-A从主存的读取数据,此时状态为E
  • 2、有其他CPU读取了数据,此时大家的状态都变为S
  • 3、CPU-A此时需要修改数据,他先想总线发一个消息,将其他的cpu缓存的数据都置为I(无效)
  • 4、通过总线在回写回主存

这里我们可以发现,如果程序中大量使用volatile关键字,在大量并发修改的情况下,某个线程的回写回导致大量其他cpu中的缓存失效,这些cpu重新从主存中读取数据的时候,会占用大量的总线带宽,我们吧这种现象称之为总线风暴!

MESI不生效的情况
如果修改的数据超过一个缓存行的大小,那么mesi就无法生效,这个时候会对总线加锁(效率低) CPU不支持缓存一致性协议(少)

image.png

二、 JMM内存模型

Java内存模型是根据英文Java Memory Model(JMM)翻译过来的。其实JMM并不像JVM内存结构一样是真实存在的。他只是一个抽象的概念。

Java内存模型(Java Memory Model ,JMM)就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能得到一致效果的机制及规范。目的是解决由于多线程通过共享内存进行通信时,存在的原子性、可见性(缓存一致性)以及有序性问题。

JVM运行程序的实体是线程,二每个线程创建时JVM都会围棋创建一个工作内存(或者成为线程的栈空间),用于存储线程私有的数据,而java内存模型中规定所有的变量都存储在主内存,主内存是共享内存区域,所有的线程都可以访问,但线程对变量的操作(读取,赋值等)必须在工作内存中进行,优先要把变量从主内存拷贝到自己工作内存空间,然后对变量进行操作,操作完成之后在写回主内存,而不能直接操作主内存里面的变量。(具体原因在分析CPU的底层实现的时候已经说明,CPU实际上只和寄存器L1缓存打交道)

工作内存中存储着主内存中的数据的副本,而工作内存是每个线程的私有数据区域,因此不同线程之间无法访问对方的工作内存,线程之间的通信必须通过主内存来实现。

image.png

2.1工作内存

主要存储当前方法的所有本地变量信息,每个线程只能访问自己的工作内存,记线程中的本地变量对其他线程是不可见的,就算两个线程执行的是同一段代码,他们也会在各自的工作内存中创建属于当前线程的本地变量。这里面也会包括字节码行号指示器、相关的native方法的信息。

因为工作线程是每个线程的私有数据,所以不存在线程安全性这种问题。

根据JVM虚拟机规范,主存和工作内存的数据存储类型以及操作方式,对于一个实例对象的成员方法而言,如果方法中包含本地变量是基本数据类型(boolean,byte,short,char,int,long,float,double),将直接存储在工作内存的帧栈结构中。但倘若本地变量是引用类型,那么该变量的引用会储存在功能内存的帧栈中,二对象实例存储在主内存中(共享数据区域,堆)中。但对于实例对象的成员变量,不管它是基本数据类型还是包装类型还是引用类型,都会被存储到堆区。至于static变量以及类本身的相关信息会存储在主内存中。

需要注意的是,在主内存的实例变量可以被多线程共享,如果两个线程调用了同一个对象的同一个方法,那么两条线程会将将要操作的数据拷贝一份到自己的工作内存中,执行完成操作后才刷新到主内存。

image.png

看一下JMM与具体硬件之间的关系

image.png

实际上硬件上并没有工作内存的区分,对于硬件来说只有寄存器和各级缓存的概念,所以JMM并不是真实存在,只是一种抽象的概念

2.2 JMM同步八中操作介绍

贾栓主内存中存在一个共享变量X,现在有A线程想要修改X的值为2,B线程想要读取X的值,那么B线程读取到的值是A线程更新后的值2还是更新前的值1呢?答案是——都有可能。 这是因为工作内存是每个线程私有的数据区域,而线程A修改变量A是,首先要把A拷贝到A线程的工作内存中区,然后对变量进行操作,操作完再写回主存中。而B在那个瞬间读取主存中的值是不确定的。

以上关于主存和工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何中工作内存同步到主内存的细节,java内存模型定义八中操作来完成。

(1)lock(锁定):作用于 主内存的变量,把一个变量标记为一条线程独占状态

(2)unlock(解锁):作用于 主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定

(3)read(读取):作用于 主内存的变量,把一个变量值从主内存传输到线程的 工作内存中,以便随后的load动作使用

(4)load(载入):作用于 工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中

(5)use(使用):作用于 工作内存的变量,把工作内存中的一个变量值传递给执行引擎

(6)assign(赋值):作用于 工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量 (7)store(存储):作用于 工作内存的变量,把工作内存中的一个变量的值传送到 主内存中,以便随后的write的操作

(8)write(写入):作用于 工作内存的变量,它把store操作从工作内存中的一个变量的值传送到 主内存的变量中

如果要把一个变量从主内存中复制到工作内存中,就需要按顺序地执行read和load操作,如果把变量从工作内存中同步到主内存中,就需要按顺序地执行store和write操作。 但Java内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。

image.png

同步规则分析:

1)不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步会主内存中

2)一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或者assign)的变量。即就是对一个变量实施use和store操作之前,必须先自行assign和load操作。

3)一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。lock和unlock必须成对出现。

4)如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量之前需要重新执行load或assign操作初始化变量的值。

5)如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。

6)对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)

三、并发编程的三个基本问题

四、volatile原理与内存语义

4.1 volatile的内存语义

  • 保证被volatile修饰的共享变量对所有的线程都可见,也就是相当于一个线程修改了一个被volatile修饰的变量的时候,能够立刻被其他的线程所感知。具体的实现是通过缓存一致性协议来实现的,对程序员来说是透明的,思考模型可以参照MESI协议去理解。
  • 禁止指令重排

4.2 volatile的可见性

volatile只能够保证变量在线程之间的可见性,而不能保证各个线程对变量的操作的原子性。举一个简单的例子,两个线程同时对一个变量i进行i++。i++的操作本身不具备原子性,我们可以看做是一个store的操作和一个write操作。虽然线程A对i的write能立刻被线程B所感知到,但是B线程的store在A的write操作之前还是操作之后是不确定的,如果B的store在线程A的write之前,并且也已经assign回工作内存中了,那么A的write仍然会被B的write覆盖。

思考?
如果这个时候B刚刚store了i的值,但是没有use,这个时候B线程能覆盖A线程的write吗?
笔者认为是不可以的。根据一致性协议,B线程总store的时候同时会侦听总线上线程A对i的修改,i向总线发消息,表明自己需要修改i这个值的时候,根据一致性协议,就会吧线程B中的i的值置为无效,B这个时候还想要对i进行操作,就需要中内存中去读取最新的i的值。

4.3volatile禁止重排优化

首先,我们需要了解什么是指令重排和指令重排发生在什么时候。 想java这样的高级语言,在运行的时候,首先会编译成字节码,JVM读入.class文件解释后,将其发给JIT编译器。JIT编译器将字节码编译成本机机器代码。

通常javac将程序源码编译,转换成java字节码,JVM通过解释字节码将其翻译成相应的机器指令,逐条读入,逐条解释翻译。非常显然,经过解释运行,其运行速度必定会比可运行的二进制字节码程序慢。为了提高运行速度,引入了JIT技术。

在执行时JIT会把翻译过的机器码保存起来,已备下次使用,因此从理论上来说,採用该JIT技术能够,能够接近曾经纯编译技术。

image.png

CPU在运行一条指令的时候,会分为不同的指令周期,我们可以简单地抽象为取值、译码、执行三个周期(实际上为了提高流水线的效率,cpu会分为更多的周期)。由于现代的CPU都采取流水线的作业方式,即第一条指令取值完成,进入译码的时候,另外一条指令可以开始进入取指阶段。这个时候如果第二条指令依赖第一条指令的计算结果,那么这个时候我们说发生了指令相关,这个时候我们必须将第二条指令停等两个机器周期(当然cpu可能会有一些硬件层面的优化,比如数据旁路技术,但是无论如何,影响了流水线的吞吐量)。为了提高cpu的效率,如果第三条指令并不依赖第一条指令的执行结果,并且如果提前到第二条指令之前做并不影响最终的结果,那么编译器就会只能的帮我们吧指令进行重新排序。这个过程,就是我们说的指令重排。本质上是为了保证CPU的流水线的畅通。

volatile是如何实现禁止指令重排的呢? 首先我们需要了解一个概念——内存屏障 内存屏障是一个CPU的指令,他的作用有两个,一是保证特定操作的执行顺序,二是保证某些变量的内存可见性,由于编译器和处理器都能执行指令重排的优化。如果在指令之间插入一条Memory Barrier,则会告诉编译器和处理器,无论什么指令都不能喝这条Memory Barrier指令重排序,也就是说,插入内存屏障禁止在内存屏障的前后执行重排序优化。Memory Barrier的另外一鞥作用是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。总之,volatile正式通过内存屏障,来实现他在内存中的语义。

image.png

这是一个经典的单例双重检测的代码,这段代码在单线程下没有任何问题,但是如果在多线程环境下就可能会出现线程安全的问题。原因在于,读取到instance不为null的情况下,instance的引用对象可能没有完成初始化。
因为instance = new DoubleCheckLock()可以分为三个步骤

memory = allocate() //1、分配对象内存空间
instance(memory) //2、 初始化对象
instance = memory //3、设置instance指向刚刚分配的内存地址,此时instance != null

由于步骤2和步骤3之间可能发生重排序,如下

memory = allocate() //1、分配对象内存空间
instance = memory //3、设置instance指向刚刚分配的内存地址,此时instance != null
instance(memory) //2、 初始化对象

由于步骤2和步骤3不存在数据依赖关系,所以重排过后在单线程的情况下,执行结果不会发生改变,所以这种指令重排是允许的。二在多线程的情况下,当一个线程访问instance不为null的时候,由于instance的实例未必已经初始化完成,也就造成了线程安全的问题。所以,如果要解决这个问题,就必须禁止指令重排优化。

private volatile static DoubleCheckLock instance

4.4volatile内存语义的实现。

为了实现volatile内存语义,JMM会分别限制编译器重排序和处理器重排序。 下图是JMM针对编译器指定的volatile重排序的规则表。

image.png

从上图我们可以发现:

  • 1、第二个操作是volatile写的时候,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。
  • 2、第一个操作是volatile读是,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile之前。

为了实现volatile的内存语义,编译器在生成字节码时,都会在指令中插入内存屏障来禁止特定类型的处理器重排序。对于便器来说,发现一个最优布置来最小化插入屏障的总数几乎是不可能的。为此,JMM采取了十分保守的方式。

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