Volatile的底层原理

JaxYoun / 2024-08-23 / 原文

Volatile的底层原理

volatile的特点

被volatile修饰的变量具有如下特点:

  • 1.保证此变量对所有的线程的可见性,不能保证它具有原子性(可见性,是指线程之间的可见性,一个线程修改的状态对另一个线程是可见的)

  • 2.禁止指令重排序优化

  • 3.volatile 的读性能消耗与普通变量几乎相同,但是写操作稍慢,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行

通过在编译期,给目标变量添加ACC_VOLATILE标识,最终在cpu指令前形成lock前缀。

  • 线程对变量赋值后,都会立即刷新到主存,并且通知其他线程其工作内存中的缓存失效,再次访问必须从主存加载。

  • 编译期、执行期形成内存屏障,变量的读、写前的逻辑,不能重排到屏障后。

JMM内存模型

Java内存模式是一种虚拟机规范,它用于屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果。

JMM规范了Java虚拟机与计算机内存是如何协同工作的:规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在需要时如何同步的访问共享变量。

JMM内存模型把虚拟机内存分为主内存和工作内存

主内存

主内存是处理器共享的存储空间。主内存用于存储共享变量,是多个线程之间共享的数据存储区域。多个线程可以同时访问主内存中的共享变量,但是线程对共享变量的修改不一定会立即同步到主内存中。

Java内存模型规定:线程对共享变量的修改操作必须先在线程自己的工作内存中进行,然后可能会被延迟到主内存中去

工作内存

每个线程都有一个私有的工作内存。工作内存是 JMM 的一个抽象概念,并不真实存在。工作内存是寄存器和高速缓存的抽象。我们可以约等于理解为工作内存即为cpu的寄存器或者高速缓存。线程执行的时候,首先从主内存读值,再保存为工作内存中的副本,然后交给cpu执行,执行完毕后再给副本赋值,随后工作内存再把值传回给主存。

主内存和工作内存间的交互

JMM中定义了如下8种操作,来完成主内存和工作内存的数据交互:

  • Lock(锁定):作用于主内存中的变量,表示变量被一个线程独占的状态。

  • Unlock(解锁):作用于主内存中的变量,将变量从锁定状态(Lock)释放,释放后的变量可被其他线程锁定(Lock)

  • Read(读取):作用于主内存中的变量,将变量从主内存传输到工作内存中的过程

  • Load(加载):作用于工作内存中的变量,把read操作从主内存中得到的变量的值放入工作内存的变量副本中。

  • Use(使用):作用于工作内存中的变量,把工作内存中变量传递给执行引擎用于计算等等。(可理解为使用这个变量)

  • Assign(赋值):作用于工作内存中的变量,把一个从执行引擎接收到的值赋值给工作内存中的变量。(可理解为给变量赋值。)

  • Store(存储):作用于工作内存中的变量,把工作内存中的一个变量传送到主内存中,以便随后write 操作使用。

  • Write(写入):作用于主内存中的变量,把store操作从工作内存中得到的变量的值放入主内存的变量中。

需要注意一下几点:

  1. read和load ;store和write 是成对出现的,才能实现数据完整的拷贝
  2. 执行了assign(赋值),就必须执行store和write 把更改同步到主内存
  3. use前必须执行load,load前必须执行read
  4. lock只允许一个线程同时执行
  5. lock一个变量的时候,工作内存中的此变量的值将会清空,所以use(使用)前必须重新read(读取)和load(加载)初始化变量的值。
  6. unlock前必须把变量刷会主内存(即store和write)

一、可见性问题

volatile修饰的变量如何能刷回主内存

通过对OpenJDK中的unsafe.cpp源码的分析,会发现被volatile关键字修饰的变量会存在一个“lock:”的前缀。

Lock前缀,Lock不是一种内存屏障,但是它能完成类似内存屏障的功能。Lock会对CPU总线和高速缓存加锁,可以理解为CPU指令级的一种锁。当CPU发现这个指令时,立即会做两件事情

1.会将当前处理器缓存行的数据直接写回到系统内存中

2.这个写回内存的操作会使在其他CPU里缓存了该地址的数据无效。这是通过cpu总线缓存一致协议来保证的

缓存一致性协议

每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操作的时候,会强制重新从系统内存里把数据读到处理器缓存里。

所以,如果一个变量被volatile所修饰的话,在每次数据变化之后,其值都会被强制刷入主存。而其他处理器的缓存由于遵守了缓存一致性协议,也会把这个变量的值从主存加载到自己的缓存中。这就保证了一个volatile在并发编程中,其值在多个缓存中是可见的。

二、重排序问题

在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。从java代码一直到最后指令被执行,有多个步骤下会重排指令。

编译器重排序

编译器在编译的时候对没有数据依赖性的操作可以重排序,重排序后不会影响程序语意

由于编译器重的原因,在并发编程的时候就会出现意想不到的问题。

指令集重排序

处理器改变对应机器语言的执行顺序,重排后不会影响程序语意。

这是一个典型的获取单例的例子,但是这样写是有问题的。如果线程A和线程B同时调用getInstance来获取单例,可能其中一个线程调用tools的时候会报空指针异常。为什么呢?

可能会有读者提问,不是判断了tools == null了吗?为什么还会报空指针异常。

如果了解字节码指令的读者会知道对应一个new关键字的时候,处理字节码的常规顺序是

1.内存分配
2.初始化内存实例
3.引用指向内存实例
这里的2和3 的顺序可能被重排,所以当引用指向这块内存的时候内存其实可能没有初始化,如果使用这个引用可能报空指针异常。

内存系统重排序

处理器高速缓存的数据在刷会主内存的时候可能会乱序

volatile是怎么处理重排序问题的

volatile通过 “内存屏障” 的方式来防止指令被重排,为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。大多数的处理器都支持内存屏障的指令。

下面是基于保守策略的JMM内存屏障插入策略:

在每个volatile写操作的前面插入一个StoreStore屏障,禁止上面的普通写和他重排在每个volatile写操作的后面插入一个StoreLoad屏障,禁止跟下面的volatile读/写重排在每个volatile读操作的后面插入一个LoadLoad屏障,禁止下面的普通读和voaltile读重排在每个volatile读操作的后面插入一个LoadStore屏障,禁止下面的普通写和volatile读重排