volatile原理

一、内存屏障

内存屏障(Memory Barrier)又称内存栅栏,是一个CPU指令,它的作用有两个:

  • 一是保证特定操作的执行顺序;
  • 二是保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)。

由于编译器和处理器都能执行指令重排优化。如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化

Memory Barrier的另外一个作用是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。总之,volatile变量正是通过内存屏障(lock指令)实现其在内存中的语义,即可见性和禁止重排优化

二、volatile的内存语义实现

2.1 volatile 重排序规则

为了保证内存可见性,java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。内存屏障是一组处理指令,用来实现对内存操作的顺序限制。

为了实现 volatile 的内存语义,JMM 会限制特定类型的编译器和处理器重排序,JMM 会针对编译器制定 volatile 重排序规则表:

image-20230328232703364

说明

  • 举例来说,第三行最后一个单元格的意思是:在程序中,当第一个操作为普通变量的读或写时,如果第二个操作为volatile写,则编译器不能重排序这两个操作。

图表分析

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

2.2 内存屏障指令

硬件层面的内存屏障(了解即可):

  • sfence:即写屏障(Store Barrier),在写指令之后插入写屏障,能让写入缓存的最新数据写回到主内存,以保证写入的数据立刻对其他线程可见。
  • lfence:即读屏障(Load Barrier),在读指令前插入读屏障,可以让高速缓存中的数据失效,重新从主内存加载数据,以保证读取的是最新的数据。
  • mfence:即全能屏障(modify/mix Barrier ),兼具sfence和lfence的功能。
  • lock 前缀:lock不是内存屏障,而是一种锁。执行时会锁住内存子系统来确保执行顺序,甚至跨多个CPU。

JMM层面的内存屏障

  • loadload:读读,该屏障用来禁止处理器把上面的volatile读与下面的普通读重排序;
  • storestore:写写,该屏障可以保证在volatile写之前,其前面的所有普通写操作都已经刷新到主内存中;
  • loadstore:读写,该屏障用来禁止处理器把上面的volatile读与下面的普通写重排序;
  • storeload:写读,该屏障的作用是避免volatile与后面可能有的volatile读/写操作重排序。

2.3 内存屏障插入策略

为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。

对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能。为此,JMM采取保守策略。下面是基于保守策略的JMM内存屏障插入策略。

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

上述内存屏障插入策略非常保守,但它可以保证在任意处理器平台,任意的程序中都能得到正确的volatile内存语义。

示例一

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

image-20230328232956725

分析

  • 上图中StoreStore屏障可以保证在volatile写之前,其前面的所有普通写操作已经对任意处理器可见了。这是因为StoreStore屏障将保障上面所有的普通写在volatile写之前刷新到主内存。(这就是实现了volatile变量的可见性)
  • 而volatile写后面的StoreLoad屏障,作用是避免volatile写与后面可能有的volatile读/写操作重排序。
示例二

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

image-20230328233008162

分析

  • 上图中LoadLoad屏障用来禁止处理器把上面的volatile读与下面的普通读重排序。
  • LoadStore屏障用来禁止处理器把上面的volatile读与下面的普通写重排序。

上述volatile写和volatile读的内存屏障插入策略非常保守。在实际执行时,只要不改变 volatile写-读的内存语义,编译器可以根据具体情况省略不必要的屏障。

三、volatile为什么不保证原子性

volatile变量的复合操作(如i++)是不具有原子性的,原因是 i++ 操作从字节码角度来看,分为三步:

image-20230328233844628

多线程环境下,数据计算和数据赋值操作可能多次出现,即操作非原子。若数据再加载之后,若主内存中 count变量发生修改之后,由于线程工作内存中的值在此之前已经加载,从而不会对变更操作做出相应变化,即私有内存和公共内存中变量不同步,进而导致数据不一致。

对于volatile变量,JVM只是保证从主内存加载到线程工作内存的值是最新的,也就是数据加载时是最新的。

提问:那为啥synchronized可以保证原子性?

synchronized的执行内部代码的过程分为五步,分别是:

  • 1、获得同步锁;
  • 2、清空工作内存;
  • 3、在主内存中拷贝最新变量的副本到工作内存;
  • 4、执行代码(计算或者输出等);
  • 5、将更改后的共享变量的值刷新到主内存中;
  • 6、 释放同步锁。

在主内存中拷贝最新变量的副本到工作内存后,只有同步块中的线程能修改i的值,所以可以保证原子性。

四、示例

  • 使用内存屏障分析下面代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class VolatileTest {

int i = 0;
volatile boolean flag = false;

public void write() {
i = 1;
flag = true;
}

public void read() {
if (flag) {
System.out.println("i=" + i);
}
}

}

写操作

  • 在每一个volatile写操作 前面 插入一个 storestore屏障
  • 在每一个volatile写操作 后面 插入一个 storeload屏障
操作 说明
i = 1 普通写
storestore屏障 禁止上面的普通写与下面的volatile写重排序
flag = true volatile写
storeload屏障 禁止上面的volatile写与下面可能有的volatile读/写重排序

读操作

  • 在每一个volatile读操作 后面 插入一个 loadload屏障
  • 在每一个volatile读操作 后面 插入一个 loadstore屏障
操作 说明
if (flag) volatile读
loadload屏障 禁止上面的volatile读与下面的普通读重排序
loadstore屏障 禁止上面的volatile读与下面的普通写重排序
System.out.println 普通读

volatile原理
http://example.com/2023/03/28/volatile原理/
作者
程序员小魏
发布于
2023年3月28日
许可协议