文章目录
1. volatile的特点
被volatile修饰的变量有两大特点
- 可见性
- 有序性:加了volatile说明这变量有排序要求,有时候需要禁重排(存在数据依赖的时候禁重排)
而JMM规范下有三大特性
- 可见性
- 有序性
- 原子性
对比可以看出,volatile不支持原子性
volatile的内存语义
- 当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值立即刷新回主内存中
- 当读一个volatile变量时,JMM会把该线程对应本地内存设置为无效,重新回到主线程读取最新共享变量(也就是说A线程修改了这个变量,然后刷回到主内存中,就会通知B线程,这个变量无效了,回主内存重新拿把)
- 所以volatile的写内存语义是直接刷新主内存,读的内存语义是直接从主内存中读取
2. volatile的四大屏障
屏障是什么?
内存屏障(也称内存栅栏、屏障指令等,是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作的一个同步点,使得此点之前的所有写操作都会执行后才可以开始执行此点之后的操作),避免代码重排序。内存屏障其实就是一种JVM指令,Java内存模型的重排规则会要求Java编译器在生成JVM指令时插入特定的内存屏障指令,通过这些内存屏障指令,volatile实现了Java内存模型中的可见性和有序性(禁重排),但volatile无法保证原子性
- 内存屏障之前的所有写操作都要回写到主内存中
- 内存屏障之后,所有读操作都能获得内存屏障之间的所有写操作的最新结果(实现了可见性)
- 写屏障(Store Memory Barrier):告诉处理器在写屏障之前将所有存储在缓存中的数据同步到主内存。也就是说当看见Store屏障,就必须把该指令之前所有写入指令执行完毕才能继续往下执行
- 读屏障(Load Memory Barrier):处理器在读屏障之后的读操作,都在读屏障之后执行。也就是说Load屏障之后就能保证后面读取数据指令一定能读到最新的数据
重排序时,不允许把内存屏障之后的指令重排序到内存屏障之前。也就是说对一个bolatile变量的写,先行发生于任意后续对这个volatile变量的读,也叫写后读
内存屏障粗分可分为读屏障和写屏障,细分可以分为四种
屏障类型 | 指令实例 | 说明 |
---|---|---|
LoadLoad | Load1;LoadLoad;Load2 | 保证load1读取操作在load2及厚度读操作之前执行 |
StoreStore | Store1;Store;Store2 | 在store2及其后的写操作执行前,保证store1的写操作已刷新到主内存 |
LoadStore | Load1;LoadStore;Store2 | 在store2及其后的写操作执行前,保证load1的读操作已经结束完成 |
StoreLoad | Store1;StoreLoad;Load2 | 保证store1的写操作已刷新到主内存之后,load2及其后的读操作才能执行 |
3. volatile读写屏障插入策略
第一个操作 | 第二个操作:普通读写 | 第二个操作:volatile读 | 第二个操作:vplatile写 |
---|---|---|---|
普通读写 | 可以重排 | 可以重排 | 不可以重排 |
volatile读 | 不可以重排 | 不可以重排 | 不可以重排 |
volatile写 | 可以重排 | 不可以重排 | 不可以重排 |
- 当第一个操作为volatile读时,不论第二个操作是什么,都不能重排序,这个操作保证了volatile读之后的操作不会被重排到volatile读之间
- 第二个操作为volatile写时,不论第二个操作是什么,都不能重排序。这个操作保证了volatile写之前的操作不会被重拍到volatile写之后
- 当第一个操作是volatile写时,第二个操作为volatile读时,不能重排
读写屏障的四种规则
-
读屏障
- 在每个volatile读操作的后面插入一个LoadLoad屏障,禁止处理器把上面的volatile读与下面的普通读重排序
- 在每个volatile读操作的后面插入一个LoadStore屏障,禁止处理器把上面的volatile读与下面的普通写重排序
-
写屏障
- 在每个volatile写操作的前面插入一个StoreStore屏障,可以便面volatile写之前,其前面的所有普通写操作都已经刷新到主内存中
- 在每个volatile写操作之间插入一个StoreLoad屏障,作用是可以便面volatile写和后面可能有的volatile写/读操作重排序
4. volatile可见性
public class volatileDemo {
static volatile boolean flag = true;
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + "\t---come in");
while (flag){
}
System.out.println(Thread.currentThread().getName() + "\t---end");
}, "t1").start();
TimeUnit.SECONDS.sleep(1);
new Thread(() -> {
flag = false;
}, "t2").start();
System.out.println(Thread.currentThread().getName() + "\t---end--\t");
}
}
上述代码中,t2线程比t1线程启动晚了1s,1s后t2线程将flag改为false,这是因为flag是volatile,当flag修改后,t1=1线程会重新从主内存中拉去flag的值(false),线程死循环结束,t1线程结束
如果flag不是由volatile修饰,那么就算t2线程将flag改为false,t2线程的循环也不会结束
出现这种情况是因为volatile的可见性
volatile变量的读写过程流程如下:
首先t1线程现在主内存中读取flag,读完之后就加载进t1线程的工作内存
接下来t1就可以使用了
这时候1s后,t2线程启动将flag赋值为false,修改完毕后存储在自己的工作内存内存中
然后t2将修改后的flag的值写入主内存中,写的时候必须要要加锁,因为有可能别的线程在读(为的是保证线程的安全性)
加锁后会清空工作内存的值,使用线程前必须load或assign,也就是t1线程必须从主内存中重新读flag的值,然后就能知道别的线程将flag从true改为false,然后程序停止
- read:作用于主内存,将变量的值从主内存传输到工作内存,主内存到工作内存
- load:作用于工作内存,将read从主内存传输变量值放入工作内存变量副本中,即数据加载
- use:作用于工作内存,将工作内存变量副本的值传递给执行引擎,每当JVM遇到需要该变量的字节码指令时会执行该操作
- assign:作用于工作内存,将从执行引擎接收到的值赋值给工作内存变量,每当JVM遇到一个给变量赋值字节码指令时会执行该操作
- store:作用于工作内存,将赋值完毕的工作变量的值写回主内存
- write:作用于主内存,将store传输过来的变量值赋值给主内存的变量
- lock:作用于主内存,将一个变量标记为一个线程独占的状态,只是写时候加锁,就是只是锁了写变量的过程
- unlock:作用于主内存,把一个处于锁定状态的变量释放,然后才能被其他线程占用
5. volatile无原子性
原子性:一个线程的某个操作不会被其他线程打断。
/**
* @Author: lrk
* @Date: 2022/10/11 下午 3:44
* @Description:
*/
class Number {
volatile int num;
public void add() {
num++;
}
}
public class volatileDemo02 {
public static void main(String[] args) {
Number number = new Number();
for (int i = 0; i < 10; i++) {
new Thread(() -> {
for (int j = 0; j < 1000; j++) {
number.add();
}
}).start();
}
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println(number.num);
}
}
上面代码我们预期的结果是10000,但是无论程序执行多少遍,结果只会无限接近10000
出现这个原因是为什么呢???
对于volatile变量来说,其具有可见性,JVM只是保证从主内存加载到工作内存的值是最新的,也就是仅仅保证数据加载时是最新的。但是多线程环境下,“数据计算”和“数据赋值”操作可能多次出现,若数据加载之后,若主内存volatile修饰变量发生修改之后,线程工作内存中的操作将会作废去读取主内存最新值,操作出现写丢失问题。
即各线程私有内存和主内存公共内存中变量不同步,进而导致数据不一致。
由此可见volatile解决的是变量读时的可见性问题,无法保证原子性,对于多线程修改主内存共享变量的场景必须使用加锁同步
因此,volatile变量不适合参加到依赖当前的运算,如i++
通常volatile用作保存某种状态的boolean值或者int值
由于volatile变量只能保证可见性,在不符合以下两条规律的运算中,仍然需要通过加锁来保证原子性
- 运算结果并不依赖变量的当前值,或者能够保证只有单一线程修改变量的值
- 变量不需要于其他状态变量共同参与不变约束
6. volatile使用场景
-
单一赋值可以,但是含符合运算赋值不可以(i++之类)
-
状态标志,判断业务是否结束
-
public class volatileDemo { static boolean flag = true; public static void main(String[] args) throws InterruptedException { new Thread(() -> { System.out.println(Thread.currentThread().getName() + "\t---come in"); while (flag){ } System.out.println(Thread.currentThread().getName() + "\t---end"); }, "t1").start(); TimeUnit.SECONDS.sleep(1); new Thread(() -> { flag = false; }, "t2").start(); System.out.println(Thread.currentThread().getName() + "\t---end--\t"); } }
-
-
开销较低的读,写锁策略
-
public class volatileDemo03 { public class Counter{ private volatile int value; public int getValue() { //利用volatile保证读取操作的可见性 return value; } public synchronized int increment(){ //利用synchronized保证复合操作的原子性 return value++; } } }
-
-
DCL双端锁的发布
-
--public class SafeDoubleCheckSingleton { private static SafeDoubleCheckSingleton singleton; private SafeDoubleCheckSingleton(){} //双重锁设计 public static SafeDoubleCheckSingleton getInstance(){ if (singleton == null){ //1.多线程并发创建对象,会通知加锁保证只有一个线程能创建对象 synchronized (SafeDoubleCheckSingleton.class){ if (singleton == null){ //隐患:多线程环境下,由于重排序,该对象可能还未完成初始化就被其他线程读取 singleton = new SafeDoubleCheckSingleton(); } } } //对象初始化完毕,执行getInstance将不需要锁,直接返回创建对象 return singleton; } }-
-
7. 面试题
内存屏障是什么
内存屏障是一种屏障指令,它使得CPU或编译器对屏障指令前后所发出的内存操作执行一个排序约束。也叫内存栅栏或者栅栏指令
内存屏障能干嘛
组织屏障两边指令重排序
写数据加入屏障,强制将线程私有工作内存的数据刷回主物理内存
读数据时加入屏障,线程私有工作内存的数据失效,重新到主物理内存中获取