前提概要
我们都知道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 之前。
内存屏障类型分为四类。
- LoadLoadBarriers
指令示例:LoadA —> Loadload —> LoadB
此屏障可以保证 LoadB 和后续读指令都可以读到 LoadA 指令加载的数据,即读操作 LoadA 肯定比 LoadB 先执行。
- StoreStoreBarriers
指令示例:StoreA —> StoreStore —> StoreB
此屏障可以保证 StoreB 和后续写指令可以操作 StoreA 指令执行后的数据,即写操作 StoreA 肯定比 StoreB 先执行。
- LoadStoreBarriers
指令示例: LoadA —> LoadStore —> StoreB
此屏障可以保证 StoreB 和后续写指令可以读到 LoadA 指令加载的数据,即读操作 LoadA 肯定比写操作 StoreB 先执行。
- StoreLoadBarriers
指令示例:StoreA —> StoreLoad —> LoadB
此屏障可以保证 LoadB 和后续读指令都可以读到 StoreA 指令执行后的数据,即写操作 StoreA 肯定比读操作 LoadB 先执行。
实现有序性的原理:
如果属性使用了 volatile 修饰,在编译的时候会在该属性的前或后插入上面介绍的 4 类内存屏障来禁止指令重排,比如:
- 在 volatile 写操作的前面插入 StoreStoreBarriers 保证volatile写操作之前的普通读写操作执行完毕后再执行 volatile 写操作。
- 在 volatile 写操作的后面插入 StoreLoadBarriers 保证 volatile 写操作后的数据刷新到主内存,保证之后的 volatile 读写操作能使用最新数据(主内存)。
- 在 volatile 读操作的后面插入 LoadLoadBarriers 和 LoadStoreBarriers 保证 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++; } }