JVM技术专题360度无死角认识volatile机制

Posted 浩宇の天尚

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JVM技术专题360度无死角认识volatile机制相关的知识,希望对你有一定的参考价值。

前提概要

我们都知道synchronized关键字的特性:原子性、可见性、有序性、可重入性,虽然,JDK在不断的尝试优化这个内置锁,一文中有提到:无锁 -> 偏向锁 -> 轻量锁 -> 重量锁 一共四种状态,但是,在高并发的情况下且大量冲突出现的时候,最终都还是会膨胀到重量锁

本篇文章主要讲解volatile关键字,它与synchronized 的区别是:volatile 不具备原子性! 注:不具备原子性不代表它没有原生性!

为何这么说?

那是因为,synchronized是同步代码块通过monitor监视器,对整个代码块(方法是通过判断 ACC_SYNCHRONZED 标志位对整个方法)进行了整体原子性操作。而 volatile 对单一操作是原子性的,非单一操作则是非原子性的

基本用法

Java语言里的volatile关键字是用来修饰变量的,方式如下入所示。表示:该变量需要直接存储到主内存中

public class SharedClass 
    public volatile int counter = 0;

被volatile关键字修饰的 int counter 变量会直接存储到主内存中并且所有关于该变量的读操作,都会直接从主内存中读取,而不是直接从CPU缓存。(关于主内存和CPU缓存的区别,如果不理解也不用担心,下面会详细介绍

这么做解决什么问题呢?主要是两个问题:

  • 多线程见可见性的问题
  • CPU指令重排序的问题

注:为了描述方便,我们接下来会把 volatile 修饰的变量简称为“volatile 变量”,把没有用 volatile 修饰的变量建成为“non-volatile”变量。

理解 volatile 关键字

变量可见性问题(Variable Visibility Problem) : volatile可以保证变量变化在多线程间的可见性

一个多线程应用中,出于计算性能的考虑,每个线程默认是从主内存将该变量拷贝到线程所在CPU的缓存中,然后进行读写操作的。现在电脑基本都是多核CPU,不同的线程可能运行的不同的核上,而每个核都会有自己的缓存空间。如下图所示(图中的 CPU 1,CPU 2 大家可以直接理解成两个核)

这里存在一个问题,JVM既不会保证什么时候把 CPU 缓存里的数据写到主内存,也不会保证什么时候从主内存读数据到 CPU 缓存。也就是说,不同 CPU 上的线程,对同一个变量可能读取到的值是不一致的,这也就是我们通常说的:线程间的不可见问题

比如下图,Thread 1 修改的 counter = 7 只在 CPU 1 的缓存内可见,Thread 2 在自己所在的 CPU 2 缓存上读取 counter 变量时,得到的变量 counter 的值依然是 0。

而volatile出现的用意之一,就是要解决线程间不可见性,通过 volatile 修饰的变量,都会变得线程间可见

其解决方式就是文章开头提到的:

  • 通过 volatile 修饰的变量,所有关于该变量的读操作,都会直接从主内存中读取,而不是 CPU 自己的缓存。而所有该变量的写操都会写到主内存上。

  • 因为主内存是所有 CPU 共享的,理所当然即使是不同 CPU 上的线程也能看到其他线程对该变量的修改了。volatile不仅仅只保证 volatile变量的可见性,volatile 在可见性上所做的工作,实际上比保证 volatile 变量的可见性更多

当 Thread A 修改了某个被 volatile 变量 V,另一个 Thread B 立马去读该变量 V。一旦 Thread B 读取了变量 V 后,不仅仅是变量 V 对 Thread B 可见, 所有在 Thread A
修改变量 V 之前 Thread A 可见的变量,都将对 Thread B 可见。

当 Thread A 读取一个 volatile 变量 V 时,所有对于 Thread A 可见的其他变量也都会从主内存中被读取。

特性及原理

可见性

任意一个线程修改了 volatile 修饰的变量,其他线程可以马上识别到最新值。实现可见性的原理如下。

  • 步骤 1:修改本地内存,强制刷回主内存。

  • 步骤 2:强制让其他线程的工作内存失效过期。(此部分更多的属于MESI协议)

单个读/写具有原子性

单个volatile变量的读/写(比如 vl=l)具有原子性,复合操作(比如 i++)不具有原子性,Demo 代码如下:

public class VolatileFeaturesA 
   
   private volatile long vol = 0L;

    /**
     * 单个读具有原子性
     * @date:2020 年 7 月 14 日 下午 5:02:38
     */
    public long get() 
        return vol;
    

    /**
     * 单个写具有原子性
     * @date:2020 年 7 月 14 日 下午 5:01:49
     */
    public void set(long l) 
        vol = l;
    

    /**
     * 复合(多个)读和写不具有原子性
     * @date:2020 年 7 月 14 日 下午 5:02:24
     */
    public void getAndAdd() 
        vol++;
    



互斥性

同一时刻只允许一个线程操作 volatile 变量,volatile 修饰的变量在不加锁的场景下也能实现有锁的效果,类似于互斥锁。上面的 VolatileFeaturesA.java 和下面的 VolatileFeaturesB.java 两个类实现的功能是一样的(除了 getAndAdd 方法)。

public class VolatileFeaturesB 
    
	private volatile  long vol = 0L;

    /**
     * 普通写操作
     * @date:2020 年 7 月 14 日 下午 8:18:34
     * @param l
     */
    public synchronized void set(long l)   
        vol = l;
    

    /**
     * 加 1 操作
     * @author songjinzhou
     * @date:2020 年 7 月 14 日 下午 8:28:25
     */
    public void getAndAdd() 
        long temp = get();
        temp += 1L;
        set(temp);
    

    /**
     * 普通读操作
     * @date:2020 年 7 月 14 日 下午 8:33:00
     * @return
     */
    public synchronized long get() 
        return vol;
    


部分有序性

JVM 是使用内存屏障来禁止指令重排,从而达到部分有序性效果,看看下面的 Demo 代码分析自然明白为什么只是部分有序

//a、b 是普通变量,flag 是 volatile 变量
int a = 1;            //代码 1
int b = 2;            //代码 2
volatile boolean flag = true;  //代码 3
int a = 3;            //代码 4
int b = 4;            //代码 5

因为 flag 变量是使用 volatile 修饰,则在进行指令重排序时,不会把代码 3 放到代码 1 和代码 2 前面,也不会把代码 3 放到代码 4 或者代码 5 后面。 但是指令重排时代码 1 和代码 2 顺序、代码 4 和代码 5 的顺序不在禁止重排范围内,比如:代码 2 可能会被移到代码 1 之前。

内存屏障类型分为四类。

    1. LoadLoadBarriers

指令示例:LoadA —> Loadload —> LoadB

此屏障可以保证 LoadB 和后续读指令都可以读到 LoadA 指令加载的数据,即读操作 LoadA 肯定比 LoadB 先执行

    1. StoreStoreBarriers

指令示例:StoreA —> StoreStore —> StoreB

此屏障可以保证 StoreB 和后续写指令可以操作 StoreA 指令执行后的数据,即写操作 StoreA 肯定比 StoreB 先执行

    1. LoadStoreBarriers

指令示例: LoadA —> LoadStore —> StoreB

此屏障可以保证 StoreB 和后续写指令可以读到 LoadA 指令加载的数据,即读操作 LoadA 肯定比写操作 StoreB 先执行

    1. StoreLoadBarriers

指令示例:StoreA —> StoreLoad —> LoadB

此屏障可以保证 LoadB 和后续读指令都可以读到 StoreA 指令执行后的数据,即写操作 StoreA 肯定比读操作 LoadB 先执行。

实现有序性的原理:

如果属性使用了 volatile 修饰,在编译的时候会在该属性的前或后插入上面介绍的 4 类内存屏障来禁止指令重排,比如

  • volatile 写操作的前面插入 StoreStoreBarriers 保证volatile写操作之前的普通读写操作执行完毕后再执行 volatile 写操作。
  • volatile 写操作的后面插入 StoreLoadBarriers 保证 volatile 写操作后的数据刷新到主内存,保证之后的 volatile 读写操作能使用最新数据(主内存)。
  • volatile 读操作的后面插入 LoadLoadBarriersLoadStoreBarriers 保证 volatile 读写操作之后的普通读写操作先把线程本地的变量置为无效,再把主内存的共享变量更新到本地内存,之后都使用本地内存变量

volatile 读操作内存屏障:

volatile 写操作内存屏障:

状态标志,比如布尔类型状态标志,作为完成某个重要事件的标识,此标识不能依赖其他任何变量,Demo 代码如下:

public class Flag 
    //任务是否完成标志,true:已完成,false:未完成
    volatile boolean finishFlag;

    public void finish() 
        finishFlag = true;
    

    public void doTask()  
        while (!finishFlag)  
            //keep do task
        
    

一次性安全发布,比如:著名的 double-checked-locking,demo 代码上面已贴出。
开销较低的读,比如:计算器,Demo 代码如下。


/**
 * 计数器
 */
public class Counter 
    private volatile int value;
    //读操作无需加锁,减少同步开销提交性能,使用 volatile 修饰保证读操作的可见性,每次都可以读到最新值 
    public int getValue() 
        return value; 
    
    //写操作使用 synchronized 加锁,保证原子性
    public synchronized int increment() 
        return value++;
    

以上是关于JVM技术专题360度无死角认识volatile机制的主要内容,如果未能解决你的问题,请参考以下文章

如何对三维立体图形进行360度无死角观察呢?

2023最新版360度无死角go学习路线

阿昌教你在数据库的压测过程中,如何360度无死角观察机器性能

MySQL: 7 对生产环境中的数据库进行360度无死角压测

JVM技术专题重塑你对类加载机制的认识「分析篇」

一步步学OpenGL 25-《Skybox天空盒子》