介绍
使用 volatile 修饰的变量是线程共享的全局变量,是轻量级锁的一种表现形式,因为不需要线程上线文切换和调度这些操作,效率杠杠的,但是不能保证原子性,并发场景下要小心使用,比如:多个线程同时执行 i++ 是有问题的。
volatile 的 Demo 代码:
/**
* 单例模式(懒汉式)
* @date:2020 年 7 月 14 日 上午 9:48:24
*/
public class Singleton {
public static volatile Singleton instance = null;
private Singleton() {
}
public static Singleton getInstance() {
if (instance == null) { //代码 1
synchronized (instance) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
Singleton 对象是使用 volatile 修饰,所有线程都可见此对象,即有可能被多个线程同时访问此对象,比如有 A 和 B 两条线程同时进入代码 1,如果 B 线程获取锁进行对象初始化,A 线程自旋等待拿锁,B 线程完成初始化对象后释放锁,然后 A 线程获取锁后判断对象是否为 null,为了避免再次初始化对象节约了系统开销,所以此处必须使用双重校验 null。
特性及原理
可见性
任意一个线程修改了 volatile 修饰的变量,其他线程可以马上识别到最新值。实现可见性的原理如下。
步骤 1:修改本地内存,强制刷回主内存。
[步骤 2:强制让其他线程的工作内存失效过期。
步骤 3:其他线程重新从主内存加载最新值。
单个读/写具有原子性
单个 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 {
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
boolean flag = true; //代码 3
int a = 3; //代码 4
int b = 4; //代码 5
PS:因为 flag 变量是使用 volatile 修饰,则在进行指令重排序时,不会把代码 3 放到代码 1 和代码 2 前面,也不会把代码 3 放到代码 4 或者代码 5 后面。但是指令重排时代码 1 和代码 2 顺序、代码 4 和代码 5 的顺序不在禁止重排范围内,比如:代码 2 可能会被移到代码 1 之前。
内存屏障类型分为四类。
1. LoadLoadBarriers
指令示例:LoadA —> Loadload —> LoadB
此屏障可以保证 LoadB 和后续读指令都可以读到 LoadA 指令加载的数据,即读操作 LoadA 肯定比 LoadB 先执行。
2. StoreStoreBarriers
指令示例:StoreA —> StoreStore —> StoreB
此屏障可以保证 StoreB 和后续写指令可以操作 StoreA 指令执行后的数据,即写操作 StoreA 肯定比 StoreB 先执行。
3. LoadStoreBarriers
指令示例: LoadA —> LoadStore —> StoreB
此屏障可以保证 StoreB 和后续写指令可以读到 LoadA 指令加载的数据,即读操作 LoadA 肯定比写操作 StoreB 先执行。
4. StoreLoadBarriers
指令示例:StoreA —> StoreLoad —> LoadB
此屏障可以保证 LoadB 和后续读指令都可以读到 StoreA 指令执行后的数据,即写操作 StoreA 肯定比读操作 LoadB 先执行。
实现有序性的原理:
如果属性使用了 volatile 修饰,在编译的时候会在该属性的前或后插入上面介绍的 4 类内存屏障来禁止指令重排,比如:
- 在 volatile 写操作的前面插入 StoreStoreBarriers 保证 volatile 写操作之前的普通读写操作执行完毕后再执行 volatile 写操作。
- 在 volatile 写操作的后面插入 StoreLoadBarriers 保证 volatile 写操作后的数据刷新到主内存,保证之后的 volatile 读写操作能使用最新数据(主内存)。
- 在 volatile 读操作的后面插入 LoadLoadBarriers 和 LoadStoreBarriers 保证 volatile 读写操作之后的普通读写操作先把线程本地的变量置为无效,再把主内存的共享变量更新到本地内存,之后都使用本地内存变量。
volatile 读操作内存屏障:
volatile 写操作内存屏障:
3.使用场景
状态标志,比如布尔类型状态标志,作为完成某个重要事件的标识,此标识不能依赖其他任何变量,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++;
}
}
最后
觉得不错的小伙伴记得转发关注哦,后续会持续更新精选技术文章!