面试官:谈谈你对 Volatile 的理解吧

132 阅读8分钟

前言

在之前的文章 深入分析 Synchronized 原理 介绍了 Synchronized是一种锁的机制,存在阻塞和性能的问题,而 volatile 是 java 虚拟机提供的最轻量级的同步机制,volatile 主要提供修饰共享变量赋予 可见性有序性。从简单的 Demo 引出我们今天的主题 -- volatile。

Demo -- 多线程共享对象 控制执行开关

public class Demo {

    private static boolean switchStatus = false;

    public static void main(String[] args) {
        new Thread(() -> {
            System.out.println("开始工作");
            while (!switchStatus) ;
            System.out.println("结束工作");
        }).start();

        try {
            Thread.sleep(500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        switchStatus = true;
        System.out.println("命令停止工作");
    }
}

本意是想通过 switchStatus 作为控制工作线程的开关,但是实际执行后,会发现结果并没有按照预期 输出"结束工作",而是失联了一样停不下来了,在死循环中出不来了。

但是如果在上面的 Demo 进行稍微的修改即可满足预期: private static volatile boolean switchStatus = false; 此时符合预期关闭开关时,工作线程也随之关闭了。接下我会针对这2个现象原理进行解答,为了读者更好的理解,得先引入几个知识点(计算机内存模型、JMM-Java 内存模型)。

计算机内存模型

为了更好的理解后续 JMM 和 volatile,我们先了解下计算机内存模型,简单地介绍下:

程序执行时,CPU接收到指令 需要进行计算时,读取所需要的数据,会先尝试从 CPU Cache 中获取,若没有再从主内存中获取,计算完成后,将结果写入 CPU Cache ,若没有特殊指令情况下,会根据操作系统自身定义的时间 一段时间会将 CPU Cache 刷新到主内存中(未被volatile 修饰的普通变量);当然遇到特殊的指令会将 CPU Cache 刷新到主内存中(被volatile 修饰的变量 就是依赖这个特性实现可见性)

image-20210704214703373
  • CPU:处理程序中各种指令,需要和CPU Cache 和 内存打交道
  • CPU Cache:由于 CPU 和内存的速度差 几个数量级,CPU 直接和内存打交道很浪费 CPU 性能,因此引入了 CPU Cache 降低 CPU 的性能损耗
  • 缓存一致性协议/总线锁机制:引入 CPU Cache降低了 CPU性能损耗的问题,同时引入了缓存不一致的问题,为了解决这个这个问题通过缓存一致性协议/总线锁机制 进行解决

总线锁机制

CPU和其他功能部件是通过总线通信的,如果在总线加LOCK#锁,那么在锁住总线期间,其他CPU是无法访问内存,这样一来,效率就比较低了。因此需要进行优化,细化控制锁的粒度,我们只需要保证,对于被多个CPU缓存的同一份数据是一致的就行,所以引入了缓存锁,他的核心机制就是缓存一致性协议

缓存一致性协议

为了达成数据访问的一致性,需要各个处理器在访问内存时,遵循一些协议,在读写时根据协议来操作,常见的协议有,MSI,MESI,MOSI等等,最常见的就是MESI协议;MESI表示缓存行的四种状态(modifyExclusive SharedInvalid)

嗅探技术

如何保证当前处理器的内部缓存、主内存和其他处理器的缓存数据在总线上保持一致的?多处理器总线嗅探

在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己的缓存值是不是过期了,如果处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据库读到处理器缓存中。

Java内存模型

image-20210704222102827
  • Java虚拟机规范试图定义一种Java内存模型,来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台上都能达到一致的内存访问效果。
  • 为了更好的执行性能,java内存模型并没有限制执行引擎使用处理器的特定寄存器或缓存来和主内存打交道,也没有限制编译器进行调整代码顺序优化。所以Java内存模型会存在缓存一致性问题和指令重排序问题的
  • Java内存模型规定所有的变量都是存在主内存当中(类似于计算机模型中的物理内存),每个线程都有自己的工作内存(类似于计算机模型的高速缓存)。这里的变量包括实例变量和静态变量,但是不包括局部变量,因为局部变量是线程私有的。
  • 线程的工作内存保存了被该线程使用的变量的主内存副本,线程对变量的所有操作都必须在工作内存中进行,而不能直接操作操作主内存。并且每个线程不能访问其他线程的工作内存。

举个例子:

# 初始值 
i = 0; 

# 线程A 和 线程B同时进行操作
i = i + 1;

首先,执行线程A从主内存中读取到 i=0,到工作内存。然后在工作内存中,赋值 i+1,工作内存就得到 i=1,最后把结果写回主内存。如果是单线程的话,该语句执行是没问题的。但是在多线程的情况下,线程B的本地工作内存和线程A的工作内存读取的时间相同都是 i=0,但是线程A将 i=1写入主内存中,线程B不知情的情况下,也做了 i+1 的操作,此时就出现可见性带来问题了:连续2次的 i=i+1 最终的结果是1。

volatiole 可见性、有序性

在之前的文章 深入分析 Synchronized 原理 已经介绍过 原子性可见性有序性定义,这里也就不展开说了~

先说结论:依赖于CPU的缓存一致性协议内存屏障 解决了可见性的问题。

正常来说,volatile 基于缓存一致性协议就应该可以实现可见性(在上面已经介绍过 缓存一致性协议和嗅探技术),但是由于 Java 为了提高性能允许重排序(编译器重排序 和 处理器重排序),因此需要通过内存屏障来防止重排序,来保证每个线程执行的每个指令有一定的顺序性

java 内存屏障

java的内存屏障通常所谓的四种即 LoadLoadStoreStoreLoadStoreStoreLoad 实际上也是上述两种的组合,完成一系列的屏障和数据同步功能。

  • LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕
  • StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见
  • LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕
  • StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能

volatile 语义的内存屏障

  • 在每个 volatile 写操作前插入 StoreStore 屏障,在写操作后插入 StoreLoad 屏障
  • 在每个 volatile 读操作前插入 LoadLoad 屏障,在读操作后插入 LoadStore 屏障
  • 由于内存屏障的作用,避免了 volatile 变量和其它指令重排序、线程之间实现了通信,使得 volatile 表现出了锁的特性

举一个 volatile 防止指令重排的场景

java 中 DLC单例模式 大家应该很熟悉了,只不过大家是否有注意到 uniqueInstancevolatile 修饰的作用吗? 就是为了防止指令重排

public class Singleton {

    private volatile static Singleton uniqueInstance;

    private Singleton() {
    }

    public static Singleton getInstance() {
        if (uniqueInstance != null) {
            synchronized (Singleton.class) {
                if (uniqueInstance != null) {
                    uniqueInstance = new Singleton();
                }
            }
        }
        return uniqueInstance;
    }
    
}

初始化一个类,会产生多条汇编指令,总结下来主要执行下面三点:

  1. uniqueInstance 的实例分配内存
  2. 初始化 Singleton 的构造器
  3. uniqueInstance 对象指向分配的内存空间(按顺序到这步 uniqueInstance 初始化完成)

理想的状态下:1 -> 2 -> 3,但是 Java 为了提高性能允许重排序,可能会将初始化一个类的顺序进行变化,比如:1 -> 3 -> 2,这种情况下就可能会出现NPE,修饰了volatile 防止重排序,避免获取到 uniqueInstance 未初始化完成,导致NPE

最后简单总结下volatile 在指令之间插入内存屏障 + 缓存一致性协议,保证按照特定顺序执行和某些变量的可见性volatile 通过内存屏障通知 CPU 和编译器防止指令重排优化来维持有序性


我的微信公众号:Java架构师进阶编程
专注分享Java技术干货,期待你的关注!