5

Java技术专题-JVM研究系列(19)360度无死角认识volatile机制

 3 years ago
source link: https://my.oschina.net/liboware/blog/5037903
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client
Java技术专题-JVM研究系列(19)360度无死角认识volatile机制 - 浩宇の技术之旅 - OSCHINA - 中文开源技术交流社区

我们都知道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++;
    }
}

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK