Volatile?怎么保证可见性?如何禁止重排序?从底层分析

114 阅读6分钟
  • volatile关键字的作用是什么?
  • volatile能保证原子性吗?
  • 之前32位机器上共享的long和double变量的为什么要用volatile? 现在64位机器上是否也要设置呢?
  • i++为什么不能保证原子性?
  • volatile是如何实现可见性的? lock前缀。
  • volatile是如何实现有序性的? happens-before等
  • 说下volatile的应用场景?

Volatile的读写语义

当一个线程修改了Volatile 变量 并且写回主内存,其他线程本地内存的这个变量的值会全部失效,重新从主内存读取。

Volatile的作用

禁止指令重排

用DCL(双重检锁)说明,Volatile的禁止指令重排。
   private static volatile User user;


    public static User DCL() {
        if (user == null) {
            synchronized (Main.class) {
                if (user == null) {
                    user = new User();
                }
            }
        }
        return user;
    }

为什么要对user 对象使用了volatile关键字。

正常对象创建的过程:

  1. 分配内存空间
  2. 初始化对象
  3. 返回引用

如果发生指令重排,那么顺序可能变为 1.分配内存空间, 2. 返回引用, 3. 初始化对象。

所以为了防止返回一个未初始化对象,造成难以预料的问题,使用Volatile 关键字 禁止指令重排。

保证内存可见

```java public static boolean flag = true; public static void testMemoryVisible(){
    Thread t1 = new Thread(new Runnable() {
        @Override
        public void run() {
            while (flag) {

            }
            System.out.println("我退出了");
        }
    });
    Thread t2 = new Thread(new Runnable() {
        @Override
        public void run() {
            flag = false;
            System.out.println("改变flag 为" + flag);
        }
    });
    t1.start();
    try{
        //延迟2s 后执行flag = false
        Thread.sleep(2000);
    }catch(Exception e){
        e.printStackTrace();
    }
    t2.start();
}

![](https://cdn.nlark.com/yuque/0/2024/png/26033386/1704622019932-ae5258de-2184-469b-9cd8-8a9c21d78a97.png)

程序仍在运行。因为t1将 flag 读取到本地内存以后,while使用的flag 仍然是本地内存的值。没有从住内存中重新读取flag的新值。

![](https://cdn.nlark.com/yuque/0/2024/png/26033386/1704622237911-c65998fe-7b97-450a-9f2f-8e08a0641ff0.png)

使用Volatile关键字后 保证内存可见,flag值更新后,while需要从主内存重新读取flag的值为false,退出循环。

<h2 id="N1cLD">Volatile的原理</h2>
<h3 id="L8Jjm">可见性原理</h3>
:::info
Volatile的可见性是通过lock前缀执行实现的。

:::

```java
public class Test {
    private volatile int a;
    public void update() {
        a = 1;
    }
    public static void main(String[] args) {
        Test test = new Test();
        test.update();
    }
}

以上代码转换成汇编指令。


  0x0000000002951563: and    $0xffffffffffffff87,%rdi
  0x0000000002951567: je     0x00000000029515f8
  0x000000000295156d: test   $0x7,%rdi
  0x0000000002951574: jne    0x00000000029515bd
  0x0000000002951576: test   $0x300,%rdi
  0x000000000295157d: jne    0x000000000295159c
  0x000000000295157f: and    $0x37f,%rax
  0x0000000002951586: mov    %rax,%rdi
  0x0000000002951589: or     %r15,%rdi
  0x000000000295158c: lock cmpxchg %rdi,(%rdx)  //在 volatile 修饰的共享变量进行写操作的时候会多出 lock 前缀的指令
  0x0000000002951591: jne    0x0000000002951a15
  0x0000000002951597: jmpq   0x00000000029515f8
  0x000000000295159c: mov    0x8(%rdx),%edi
  0x000000000295159f: shl    $0x3,%rdi
  0x00000000029515a3: mov    0xa8(%rdi),%rdi
  0x00000000029515aa: or     %r15,%rdi

可以看见在11行 有个lock前缀。

在多核处理器中lock前缀有3个作用:

  1. 将当前缓存行数据写入主内存
  2. 数据写入主内存后,使其他线程共享变量副本所在缓存行失效。
  3. 有类似于内存屏障的作用,禁止该指令与前面和后面的指令进行重排序。

线程共享副本失效后 会从主内存重新读取,达到变量修改后就可见的效果。

volatile底层实现就是通过lock前缀执行,使用总线嗅探和MESI协议来实现内存可见性的。

总线锁定和缓存锁定

总线锁定 :处理器提供`LOCK#`信号,锁住整个总线,其他CPU核心请求会被阻塞,让当前核心独享整个内存。

缓存锁定:基于缓存一致性协议,进行缓存的更新或者失效。(如果数据所在内存跨缓存行,那么会降级为总线锁定)

总线窥探

是一种保持缓存一致性的一种方案(基于缓存锁定),当数据被多个缓存共享时,处理器修改了共享数据的值后,必须传播到其他具有该数据副本的缓存中。

事件传播可以通过总线窥探来达到,每一个缓存行都是一个窥探者,当总线传播出有数据更改的行为,每一个窥探者会检测是否持有该数据副本,如果持有该数据副本,会根据 **缓存一致性协议**做出不同的抉择(缓存失效或者缓存更新)。

缓存一致性协议。

如MSI、MESI(又名Illinois)、MOSI、MOESI、MERSI、MESIF、write-once、Synapse、Berkeley、Firefly和Dragon协议等等。
MESI协议
MESI协议是一个基于写失效(监测到数据更改后,缓存行失效)的缓存一致性协议。允许缓存到缓存之间的数据副本复制。

禁止重排序原理

禁止重排序是通过**内存屏障实现的。**

volatile 修饰的变量户通过增加内存屏障实现静止重排序(内存屏障:静止屏障两边指令进行重排序),在JVM中提供了四种内存屏障:

1. LoadLoad屏障:举例语句是Load1; LoadLoad; Load2 (这句里面的LoadLoad里面的第一个Load对应Load1加载代码,然后LoadLoad里面的第二个Load对应Load2加载代码),此时的意思就是,在Load2及后续读取操作从内存读取数据到CPU前,保证Load1从主内存里要读取的数据读取完毕。

2. StoreStore屏障:举例语句是 Store1; StoreStore; Store2 (这句里面的StoreStore里面的第一个Store对应Store1存储代码,然后StoreStore里面的第二个Store对应Store2存储代码)。此时的意思就是在Store2及后续写入操作执行前,保证Store1的写入操作已经把数据写入到主内存里面,确认Store1的写入操作对其它处理器可见。

3. LoadStore屏障:举例语句是 Load1; LoadStore; Store2 (这句里面的LoadStore里面的Load对应Load1加载代码,然后LoadStore里面的Store对应Store2存储代码),此时的意思就是在Store2及后续代码写入操作执行前,保证Load1从主内存里要读取的数据读取完毕。

4. StoreLoad屏障:举例语句是 Store1; StoreLoad; Load2 (这句里面的StoreLoad里面的Store对应Store1存储代码,然后StoreLoad里面的Load对应Load2加载代码),在Load2及后续读取操作从内存读取数据到CPU前,保证Store1的写入操作已经把数据写入到主内存里,确认Store1的写入操作对其它处理器可见。