Volatile

Volatile

volatile 作用

  1. 保证有序性:防止指令重排序
  2. 保证可见性:保证共享变量的可见性

为什么可以保证有序性(防止重排序)

为什么要重排序

image-20230913185055524

  • 图中左侧是 3 行 Java 代码,右侧是这 3 行代码可能被转化成的指令。
  • 可以看出 a = 100 对应的是 Load a、Set to 100、Store a,意味着从主存中读取 a 的值,然后把值设置为 100,并存储回去,同理, b = 5 对应的是下面三行 Load b、Set to 5、Store b,最后的 a = a + 10,对应的是 Load a、Set to 110、Store a。
  • 如果你仔细观察,会发现这里有两次“Load a”和两次“Store a”(图中黑线),说明存在一定的重排序的优化空间。

优化:

image-20230913185125737

  • 重排序后, a 的两次操作被放到一起,指令执行情况变为 Load a、Set to 100、Set to 110、 Store a。
  • 下面和 b 相关的指令不变,仍对应 Load b、 Set to 5、Store b。
  • 可以看出,重排序后 a 的相关指令发生了变化,节省了一次 Load a 和一次 Store a。
  • 重排序通过减少执行指令,从而提高整体的运行速度,这就是重排序带来的优化和好处。

即指令重排序是在不影响单线程程序执行结果的前提下,计算机为了最大限度的发挥机器性能,会对机器指令重排序优化

image-20230918192654886

但重排序会遵循 as-if-serialhappens-before 原则;

重排序带来的问题

单线程下,重排序时是没有问题的,但是多线程下就会出现问题

例如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class Main {
static int x = 0, y = 0, a = 0, b = 0;

public static void main(String[] args) throws InterruptedException {
Set<String> res = new HashSet<>();
for (int i = 0; i < 1000000000; i++) {
x = 0;
y = 0;
a = 0;
b = 0;

Thread one = new Thread(() -> {
a = y; // 1
x = 1; // 2
});

Thread two = new Thread(() -> {
b = x; // 3
y = 1; // 4
});
one.start();
two.start();
one.join();
two.join();
res.add("a=" + a + "," + "b=" + b);
System.out.println(res);
}
}
}

会出现结果:

1
[a=0,b=0, a=1,b=0, a=0,b=1, a=1,b=1]

其中,最后一个就是指令重排序的结果,即将 2 排在了 1 前面,4 排在了 3前面。

怎么防止重排序

对于 volatile 修饰的变量,会使用内存屏障阻止重排序,内存屏障使用了 lock 前缀的汇编指令,该条指令的作用是通过硬件阻止重排序(见 lock 汇编指令第一条)

Java 规范定义的内存屏障

屏障类型 指令示例 说明
LoadLoad Load1;LoadLoad;Load2 保证 Load1 的读取操作在 Load2 及后续读取操作之前执行
StoreStore Store1;StoreStore;Store2 在 Store2 及其后的写操作执行前,保证 Store1 的写操作已刷新到主内存
LoadStore Load1;LoadStore;Store2 在 Store2 及其后的写操作执行前,保证 Load1 的读操作已读取结束
StoreLoad Store1;StoreLoad;Load2 保证 Load1 的写操作已刷新到主内存之后,Load2 及其后的读操作才能执行

Load:读取

Store:写入

Java 规定 volatile 需要实现的内存屏障

    • 在每个 volatile 写操作的前面插入一个 StoreStore 屏障。
    • 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。
    • image-20230918205948041
    • 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。
    • 在每个 volatile 读操作的后面插入一个 LoadStore 屏障
    • image-20230918205952756

为什么可以保证可见性

对于 volatile 修饰的变量,会增加一条 lock 前缀的汇编指令,该条指令的作用是让当前线程的工作内存写入主存(见 lock 汇编指令第三条)

image-20230918185709446

为什么不能保证原子性

1
2
3
4
5
6
 public class Main {
private static volatile int i = 0;
public static void main(String[] args) {
i++;
}
}

对应的字节码如下:

1
2
3
4
5
+ 0 getstatic #2 <Main.i : I>
+ 3 iconst_1
+ 4 iadd
+ 5 putstatic #2 <Main.i : I>
8 return
  • 参考《深入理解 Java 虚拟机》:i ++ 对应的操作对应绿色部分,当 getstatic 指令把 i 的值取到操作栈顶时,volatile 关键字保证了 i 的值在此时是正确的,但在执行 iconst_1, iadd 这些指令的时候,其他线程可能已经把 i 的值改变了,而操作栈顶的值就变成了过期的数据,所以 putstatic 指令执行后就可能把较小的 i 值同步回主存之中。

  • 参考《为什么volatile能保证有序性不能保证原子性》:i ++,实际上是 3 步操作,首先读取 i 的值,然后将 i + 1 赋值给中间变量,最后将中间变量的值赋给 i;因此即使是使用 volatile 修饰,也只能保证第一步强制从主存获取 i 的值,假设两个线程(A和B)都已经获取到了 i 的值,也将 i + 1 的值赋给了中间变量,即使线程 A 修改完成后,并通知其他线程 i 的值已经失效,需要重新从主存中获取,但是线程 B 已经走到了第二步,i 的值更改成什么,都已经晚了,换句话说,volatile 确实是保证了可见性,但这个可见性来的太晚了一些;

保证原子性需要用到锁机制;

as-if-serial

as-if-serial 语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime 和处理器都必须遵守 as-if-serial 语义。

为了遵守 as-if-serial 语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。

happens-before

Java 内存模型具备一些先天的“有序性”,即不需要通过任何同步手段(volatile、synchronized 等)就能够得到保证的安全,这个通常也称为 happens-before 原则,它是可见性与有序性的一套规则总结

不符合 happens-before 规则,JMM 并不能保证一个线程的可见性和有序性

  1. 程序次序规则 (Program Order Rule):一个线程内,逻辑上书写在前面的操作先行发生于书写在后面的操作 ,因为多个操作之间有先后依赖关系,则不允许对这些操作进行重排序

  2. 锁定规则 (Monitor Lock Rule):一个 unlock 操作先行发生于后面(时间的先后)对同一个锁的 lock 操作,所以线程解锁 m 之前对变量的写(解锁前会刷新到主内存中),对于接下来对 m 加锁的其它线程对该变量的读可见

  3. volatile 变量规则 (Volatile Variable Rule):对 volatile 变量的写操作先行发生于后面对这个变量的读

  4. 传递规则 (Transitivity):具有传递性,如果操作 A 先行发生于操作 B,而操作 B 又先行发生于操作 C,则可以得出操作 A 先行发生于操作 C

  5. 线程启动规则 (Thread Start Rule):Thread 对象的 start() 方法先行发生于此线程中的每一个操作

    1
    2
    static int x = 10;//线程 start 前对变量的写,对该线程开始后对该变量的读可见
    new Thread(()->{ System.out.println(x); },"t1").start();
  6. 线程中断规则 (Thread Interruption Rule):对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生

  7. 线程终止规则 (Thread Termination Rule):线程中所有的操作都先行发生于线程的终止检测,可以通过 Thread.join() 方法结束、Thread.isAlive() 的返回值手段检测到线程已经终止执行

  8. 对象终结规则(Finaizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始

对比于 synchronized

volatile 只能解决可见性和有序性;

synchronized 可以解决原子性、可见性和有序性;

  • 原子性:指的是一个或多个操作执行过程中不被打断的特性。被 synchronized 修饰的代码是具有原子性的,要么全部都能执行成功,要么都不成功。
  • 可见性:指的是一个线程改变了共享变量之后,其他线程能够立即知道这个变量被修改。我们知道在 Java 内存模型中,不同线程拥有自己的本地内存,而本地内存是主内存的副本。如果线程修改了本地内存而没有去更新主内存,那么就无法保证可见性。synchronized在修改了本地内存中的变量后,解锁前会将本地内存修改的内容刷新到主内存中,确保了共享变量的值是最新的,也就保证了可见性。
  • 有序性:指的是程序按照代码先后顺序执行。synchronized是能够保证有序性的。根据 as-if-serial 语义,无论编译器和处理器怎么优化或指令重排,单线程下的运行结果一定是正确的。而synchronized保证了单线程独占CPU,也就保证了有序性。

lock 汇编指令

不同的底层硬件对于 lock 指令是不同的,对于 IA-32 和 Intel 64 架构软件开发者手册中的解释是

  1. 会将当前处理器缓存行的数据立即写回到系统内存

  2. 这个写回内存的操作会引起在其他 CPU 里缓存了该内存地址的数据无效( MESI 协议),关于 MESI 协议,参阅:CPU缓存一致性协议MESI

  3. 提供内存屏障功能,使 lock 前后指令不能重排序(另一个作用,防止指令重排序)

Lock 前缀,Lock 不是一种内存屏障,但是它能完成类似内存屏障的功能。Lock 会对 CPU 总线和高速缓存加锁,可以理解为 CPU 指令级的一种锁。

JDK 源码实现

当变量名有 volatile 修饰时,赋值结束之后就会加上 storeload

image-20230918212145420

storeload 实现就是加上 lock 前缀的汇编指令

image-20230918212438427

图一的类和图二的源码如下
image-20230918212653647

参考文章


Volatile
https://wangtao.site/posts/73afee0.html
作者
wt
发布于
2023年9月13日
许可协议