并发笔记之volatile实现原理

volatile是轻量级的synchronized,理解volatile特性的一个好办法就是把对volatile变量的读/写,看成是使用同一把锁对这些单个读/写操作做同步。

Java语言规范对volatile的定义如下:

Java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致的更新,线程应该确保通过排他锁单独获得这个变量。

通俗点讲就是一个变量如果被volatile修饰了,则Java可以确保所有线程看到这个变量是一致的,如果某个线程对volatile修饰的变量做修改,那么其他线程就可以
立马看到这个更新,也就是所谓的线程可见性

操作系统语义

计算机运行程序时,每条指令都在CPU中执行,执行过程中势必也会读写数据。程序的数据存储在主存中,现代计算机发展到目前 主存的读写速度和CPU的计算速度仍然存在巨大的差距,
所以就有了高速缓存(L1/L2/L3)。CPU高速缓存为某个CPU独有,只与该CPU运行的线程有关。
CPU的高速缓存解决了效率问题,但带来的新的问题:数据一致性问题
在程序运行时会将数据复制一份到高速缓存中,CPU计算时直接从高速缓存中读写,只有运行结束后才会将数据刷新到主存中。
解决缓存一致性方案有两种:

  • 在地址总线加LOCK#锁
  • 缓存一致性协议(MESI协议)

方案一采用一种独占的方式来实现,即总线加LOCK#锁,则同时只有一个CPU能够操作,效率较为低下
方案二缓存一致性协议(MESI协议)它确保每个缓存中使用的共享变量的副本是一致的。其核心思想:当某个CPU在写数据时,如果发现操作的变量是共享变量,则会通知其他CPU告知改变量的缓存行是无效的,
因此其他CPU在读取该变量时发现其无效会重新从主存中加载数据。

地址总线加LOCK#锁

如果多个处理器同时对同一共享变量进行 decl指令操作,那这个操作一定不是原子的,也就是执行的结果和预期结果不一致。如下图所示,我们期望的结果是3,但是有可能结果是2
如果要解决这个问题,就需要是的CPU0在更新共享变量时,CPU1就不能操作缓存了该共享变量内存地址的缓存,所以处理器提供了总线锁来解决问题,处理器会提供一个LOCK#信号,当一个处理器在总线上输出这个信号时,其他处理器的请求会被阻塞,那么该处理器就可以独占共享内存
总线锁有一个弊端,总线锁相当于使得多个CPU由并行执行变成了串行,使得CPU的性能严重下降,所以在P6系列以后的处理器中,引入了缓存锁。

缓存一致性协议(MESI协议)

我们只需要保证 多个线程操作同一个被缓存的共享数据的原子性就行,所以只需要锁定被缓存的共享对象即可。所谓缓存锁是指被缓存在处理器中的共享数据,在Lock操作期间被锁定,那么当被修改的共享内存的数据回写到内存时,处理器不在总线上声明LOCK#信号,而是修改内部的内存地址,并通过 缓存一致性机制来保证操作的原子性。
缓存一致性
所谓缓存一致性,就是多个CPU核心中缓存的同一共享数据的数据一致性,而(MESI)使用比较广泛的缓存一致性协议。MESI协议实际上是表示缓存的四种状态
M(Modify) 表示共享数据只缓存在当前CPU缓存中,并且是被修改状态,也就是缓存的数据和主内存中的数据不一致
E(Exclusive) 表示缓存的独占状态,数据只缓存在当前CPU缓存中,并且没有被修改
S(Shared) 表示数据可能被多个CPU缓存,并且各个缓存中的数据和主内存数据一致
I(Invalid) 表示缓存已经失效
每个CPU核心不仅仅知道自己的读写操作,也会监听其他Cache的读写操作 CPU的读取会遵循几个原则
如果缓存的状态是I,那么就从内存中读取,否则直接从缓存读取
如果缓存处于M或者E的CPU 嗅探到其他CPU有读的操作,就把自己的缓存写入到内存,并把自己的状态设置为S
只有缓存状态是M或E的时候,CPU才可以修改缓存中的数据,修改后,缓存状态变为M

Java内存模型

原子性

即一个操作或者多个操作 要么全部成功要么全部失败
单线程环境下,我们可以认为整个步骤都是原子操作,但在多线程环境下则不同,Java只保证了基本数据类型的变量和赋值操作才是原子性的 多线程下原子性则需要锁、synchronized来保证。
volatile无法保证复合操作的原子性

可见性

即多个线程访问同一变量时,一个线程修改了这个变量的值 其他线程能够立刻看到修改的值。
volatile写操作语义:

  1. 修改volatile变量时会强制将修改后的值刷新到主内存中
  2. 修改volatile变量后会导致其他线程工作内存中对应的变量值失效,故再读取该变量值时就需要重新从主存中读取

有序性

即程序执行的顺序按照代码的先后顺序执行。
在Java内存模型中,为了效率是允许编译器和处理器对指令进行重排序,当然重排序不会影响单线程的运行结果,但是对多线程会有影响。
Java提供volatile来保证一定的有序性。

####从JMM层面解决线程并发问题
JMM属于语言级别的抽象内存模型,可以简单理解为对硬件模型的抽象,它定义了共享内存中多线程程序读写操作的行为规范,也就是在虚拟机中将共享变量存储到内存以及从内存中取出共享变量的底层细节。 通过这些规则来规范对内存的读写操作从而保证指令的正确性,它解决了CPU多级缓存、处理器优化、指令重排序导致的内存访问问题,保证了并发场景下的可见性。 需要注意的是,JMM并没有限制执行引擎使用处理器的寄存器或者高速缓存来提升指令执行速度,也没有限制编译器对指令进行重排序,也就是说在JMM中,也会存在缓存一致性问题和指令重排序问题。只是JMM把底层的问题抽象到JVM层面,再基于CPU层面提供的内存屏障指令,以及限制编译器的重排序来解决并发问题
Java内存模型定义了线程和内存的交互方式,在JMM抽象模型中,分为主内存、工作内存;主内存是所有线程共享的,一般是实例对象、静态字段、数组对象等存储在堆内存中的变量。工作内存是每个线程独占的,线程对变量的所有操作都必须在工作内存中进行,不能直接读写主内存中的变量,线程之间的共享变量值的传递都是基于主内存来完成。 在JMM中,定义了8个原子操作来实现一个共享变量如何从主内存拷贝到工作内存,以及如何从工作内存同步到主内存

8个原子操作指令
lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。
unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。
write(写入):作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中。

volatile原理

volatile可以保证线程可见性且提供了一定的有序性,但无法保证原子性。

观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码会发现,加入volatile关键字时,会多出一个lock前缀指令

lock前缀指令实际上相当于一个内存屏障(也称内存栅栏,是一组处理器指令,用于实现对内存操作的顺序限制),内存屏障会提供3个功能:

  1. 它确保指令重排序时不会把其后面的指令重排序到内存屏障之前,也不会把前面的指令排到内存屏障的后面;即执行到内存屏障指令时其前面的操作都已经全部完成
  2. 它会强制将对缓存的修改操作立即写入主存
  3. 如果是写操作 他会导致其他CPU的对应缓存行无效

在JVM底层volatile是采用内存屏障来实现,即:

  • 保证可见性 但不保证原子性
  • 禁止指令重排序

指令重排序

在执行程序时为了提供性能,编译器和处理器通常会对指令做重排序:

  1. 编译器重排序 编译器在不改变单线程语义的前提下,可以重新安排语句的执行顺序
  2. 处理器重排序 如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序

指令重排序对单线程没有影响,但是会影响多线程的正确性。故对多线程我们需要禁止指令重排序

happens-before原则

Java内存模型中具备一些先天的“有序性”,即不需要通过任何手段就能保证有序性,这个通常被称为happens-before(先行发生原则)。如果两个操作的执行顺序无法通过happens-before原则
推导出来,那么它们就不能保证他们的有序性,虚拟机就可以随意地对他们进行重排序。
下面介绍下happens-before原则:

  • 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
  • 锁定规则:一个unlock操作先行发生于后面对同一个锁的lock操作
  • volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作
  • 传递规则: 如果操作A先行发生于B,而操作B又先行发生于C,则可得出操作A先行发生于C
  • 线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作
  • 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程检测到中断事件的发生
  • 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
  • 对象终结规则:一个对象的初始化先行发生于他的finalize()方法的开始

volatile写-读建立的happens-before关系

从内存语义的角度来说,volatile的写-读与锁的释放-获取具有相同的内存效果:

  • volatile的写和锁的释放有相同的内存语义
  • volatile的读和锁的获取有相同的内存语义
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class VolatileExample{
int a = 0;
volatile boolean flag = false;

public void writer(){
a = 1; //1
flag = true;//2
}

public void reader(){
if(flag){ //3
int i = a;//4
... ...
}
}
}

假设线程A执行writer()方法之后,线程B执行reader()方法。根据happens-before原则,这个过程建立的happens before关系可以分成两类:

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

volatile 写-读的内存语义

  • volatile写的内存语义:
    当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存

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

总结一下volatile写和volatile读的内存语义:

  • 线程A写一个volatile变量,实质上是线程A向接下来将要读取这个volatile变量的某个线程发出了(其对共享变量所在修改的)消息
  • 线程B读一个volatile变量,实质上是线程B接收到之前某个线程发出的(在写这个volatile变量之前对共享变量所在修改的)消息
  • 线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质上是线程A通过主内存向线程B发送消息

volatile内存语义的实现

前面提到过重排序分为编译器重排序和处理器重排序。为了实现volatile内存语义,JMM会分别限制这两种类型的重排序类型。下面是针对编译器制定的volatile重排序规则表:

是否能重排序 第二个操作
第一个操作 普通读/写 volatile读 volatile写
普通读/写 NO
volatile读 NO NO NO
volatile写 NO NO

从上表可以看出:

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

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

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

保守策略下 volatile写插入内存屏障后生成的指令序列示意图:

保守策略下 volatile读插入内存屏障后生成的指令序列示意图:

JVM层面的内存屏障

屏障类型 指令示例 备注
LoadLoad Barriers load1;LoadLoad;load2 确保load1数据的装载优先于load2及后续装载指令的装载
StoreStore Barriers store1;StoreStore;store2 确保store1数据对其他处理器可见优先于store2及所有后续存储指令的存储
LoadStore Barriers load1;LoadStore;store2 确保load1数据装载优先于store2以及后续的存储指令刷新到内存
StoreLoad Barriers store1;StoreLoad;load2 确保store1数据对其他处理器变得可见优先于load2及所有后续装载指令的装载,这条内存屏障指令是一个全能型的屏障

总结

volatile是并发编程中的一种优化,某些场景下可以代替synchronized,但volatile并不能完全取代synchronized,使用volatile总的来说,必须同时满足下面两个条件
才能保证在并发环境的线程安全:

  1. 对变量的写操作不依赖于当前值
  2. 该变量没有包含在具有其他变量的不变式中

Volatile的作用及原理

坚持原创技术分享,您的支持将鼓励我继续创作!