深入理解关键字Volatile!

相比于 synchronized 关键字(重量级锁)对性能影响较大。

使用 volatile 不会引起上下文的切换和调度,所以 volatile 对性能的影响较小,开销较低。

volatile 可以保证其修饰的变量的可见性有序性

无法保证原子性(不能保证完全的原子性,只能保证单次读/写操作具有原子性,即无法保证复合操作的原子性)。

volatile 如何实现可见性?

volatile 修饰的共享变量 flag 被一个线程修改后。

JMM(Java内存模型)会把该线程的CPU内存中的共享变量 flag 立即强制刷新回主存中。

并且让其他线程的CPU内存中的共享变量 flag 缓存失效。

这样当其他线程需要访问该共享变量 flag 时,就会从主存获取最新的数据。

image-20231017152846451

volatile 实现可见性的原理

Lock指令(汇编指令):

volatile 修饰的变量会多一个lock前缀的指令

会将处理器缓存的数据写回主存中,同时使其他线程的处理器缓存的数据失效。

这样其他线程需要使用数据时,会从主存中读取最新的数据,从而实现可见性。

内存屏障(CPU指令):

为了性能优化,JMM 在不改变正确语义的前提下,会允许编译器和处理器对指令序列进行重排序。

JMM 提供了内存屏障阻止这种重排序。

Store屏障:

  • 当一个线程修改了volatile变量的值,它会在修改后插入一个写屏障
  • 告诉处理器在写屏障之前将所有存储在缓存中的数据同步到主内存

Load屏障:

  • 当另一个线程读取volatile变量的值,它会在读取前插入一个读屏障
  • 告诉处理器在读屏障之后的所有读操作都能获得内存屏障之前的所有写操作的最新结果

如果被 volatile 修饰时会多一个 ACC_VOLATILE,JVM把字节码生成机器码时会在相应位置插入内存屏障指令。

因此可以通过读写屏障实现 volatile 修饰变量的可见性。

volatile 如何实现有序性?

volatile 保证变量有序性,禁止指令重排序。

volatile 实现有序性的原理

Java编译器会在生成指令时在适当位置插入内存屏障来禁止特定类型的处理器重排序。

内存屏障中禁止指令重排序的内存屏障的四种指令:

指令 说明
LoadLoad 屏障 保证在该屏障之后的读操作,不会被重排序到该屏障之前的读操作
StoreStore屏障 保证在该屏障之后的写操作,不会被重排序到该屏障之前的写操作,并且该屏障之前的写操作已被刷入主存
StoreLoad 屏障 保证在该屏障之后的读操作,能够看到该屏障之前的写操作对应变量的最新值
LoadStore 屏障 保证在该屏障之后的写操作,不会被重排序到该屏障之前的读操作

volatile的插入屏障策略

  • 在每个 volatile 操作的前面插入一个 StoreStore 屏障
  • 在每个 volatile 操作的后面插入一个 StoreLoad 屏障
  • 在每个 volatile 操作的后面插入一个 LoadLoad 屏障
  • 在每个 volatile 操作的后面插入一个 LoadStore 屏障

即在每个volatile写操作前后分别插入内存屏障,在每个volatile读操作后插入两个内存屏障。

image-20231018135610476

volatile 为什么不能保证原子性?

volatile 无法保证复合操作的原子性,但能保证单个操作的原子性。

volatile 常见的应用场景?

状态标志位:

使用 volatile 修饰一个变量通过赋值不同的常数或值来标识不同的状态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 可以通过布尔值来控制线程的启动和停止
*/
public class MyThread extends Thread {

// 状态标志变量
private volatile boolean flag = true;

// 根据状态标志位来执行
public void run() {
while (flag) {
// do something
}
}
// 根据状态标志位来停止
public void stopThread() {
flag = false; // 改变状态标志变量
}
}

双重检查DLC:

单例模式的双重检查DLC可以通过 volatile 来修饰从存储单例模式对象的变量。