volatile 的作用
1.保存变量的内存可见性
解决变量不可见的问题,对于一写多读,是可以解决变量同步问题,但是对于多写,同样无法解决线程安全问题。
内存可见性是指:当某个线程修改了一个变量的值,别的线程直接可以知道该线程被修改。所有变量是存在主内存的,但是每个线程都有自己的本地内存,所有的读取操作都是在本地内存的副本上操作的,不加 volatile 修饰,线程是不知道别的线程是否被修改的,而加了 volatile ,当线程操作变量副本写回主内存后,CPU 会通过 CPU 总线嗅探机制 告诉其他线程该变量已经被修改,需要从主内存中获取。
所以如果大量使用 volatile 会导致总线风暴。
也可以使用 synchronized 保证内存可见性,因为线程获取到锁之后会清空本地内存,从主内存重新获取数据。
以下模拟一下因为内存不可见导致的死循环:
private static boolean flag = true;
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
while (flag) {
// 加上这个也会解决死循环的问题,因为 println() 方法里加了 synchronized
// System.out.println();
}
System.out.println("end");
}).start();
Thread.sleep(1000);
flag = false;
}
缓存一致性
CPU 缓存
在现代计算器中,CPU 的运算速度比内存读写速度快很多,就会出现 CPU 需要花费很长时间等待数据读写,所以出现了 CPU 缓存。
CPU 缓存位于 CPU 与内存之间的临时数据交换器(寄存器)上,它的速度比内存读写快很多。
缓存比内存小的多,很可能出现 cache miss,但是缓存存在还是有意义的,就是满足两种局部性原理。
局部性原理
- 时间局部性:如果某个数据被访问,那么不久的将来很可能再次被访问。
- 空间局部性:如果某个数据被访问,那么不久的将来它相邻的数据很可能被访问。
缓存行(Cache Line)
可以认为 Cache Line 是 Cache 最小的单位,在 64 位 CPU 上,一个 Cache Line 是 64 Byte 的,整块读到 Cache 也满足空间局部性。
缓存一致性协议
在多核情况下,每个 CPU 都有自己的寄存器,有些操作可能会在自己的缓存上进行,别的 CPU 再操作这个数据可能不是最新的数据,这就是缓存一致性问题。
每种 CPU 有自己的缓存一致性协议,就是保证缓存一致性的。如果缓存中的数据被修改之后,需要告诉总线数据被修改了,别的 CPU 再对这个数据操作需要从主内存中取。
缓存行对齐
想办法让数据独占一个缓存行,可以减少缓存一致性有可能导致的伪共享问题,被称为缓存行对齐。
多 CPU 修改不同的变量时,如果两个变量处于同一个缓存行,就会互相影响彼此的速度,被称为伪共享。
伪共享问题,JDK 1.8 使用 @Contended 注解解决
2. 禁止指令重排序
指令重排序是编译器和处理器为了优化指令、提高程序运行效率。
双重检查加锁( DCL )单例需要加 volatile 吗,必须加,因为在 JVM 层面,new 一个对象是几个指令完成的,如不加可能发生指令重排序,导致错误。
在 Object obj = new Object() 命令之后,会进行以下几步操作:
- 如果没有把 class load 到内存过,那么会执行对象的加载过程,见笔记 JVM
- 申请内存
- 成员变量赋初始值
- 调用构造方法
- 成员变量顺序赋初始值
- 执行构造方法语句
- 把申请好并且赋初始值的堆空间指向栈引用
但是在指令重排序之后,有可能出现还没有赋初始值,就已经指向了堆空间,然后再来一个线程判断该对象已经不为 null 了,就会拿着这块未赋默认值的对象使用。
3. 如何保证的指令重排?
字节码层面
volatile 修饰的变量多了个 ACC_VOLATILE 标志。
硬件级别保证
CPU 内存屏障用于保障有序性,特定的指令。
CPU 内存屏障,Intel X86 设计得比较简单,总共只有3条指令:
- SFENCE——在 SFENCE 指令之前的写操作和 SFENCE 指令后的写操作不能进行重排,但是不影响读操作。
- LFENCE——在 LFENCE 指令之前的读操作和 LFENCE 指令后的写操作不能进行重排,但是不影响写操作。
- MFENCE——在 MFENCE 指令之前的读操作和 MFENCE 指令后的读写操作不能进行重排。
Power PC,mac,Intel 对于内存屏障的设计均不一样,Intel 比较简单。
JVM 级别规范
四种内存屏障:
- java 的内存屏障通常所谓的四种即 LoadLoad,StoreStore,LoadStore,StoreLoad 实际上也是上述两种的组合,完成一系列的屏障和数据同步功能。
- LoadLoad 屏障:对于这样的语句 Load1; LoadLoad; Load2,在 Load2 及后续读取操作要读取的数据被访问前,保证 Load1 要读取的数据被读取完毕。
- StoreStore 屏障:对于这样的语句 Store1; StoreStore; Store2,在 Store2 及后续写入操作执行前,保证Store1 的写入操作对其它处理器可见。
- LoadStore 屏障:对于这样的语句 Load1; LoadStore; Store2,在 Store2 及后续写入操作被刷出前,保证Load1 要读取的数据被读取完毕。
- StoreLoad 屏障:对于这样的语句 Store1; StoreLoad; Load2,在 Load2 及后续所有读取操作执行前,保证Store1 的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能
实现方式:
在每个 volatile 写操作前插入 StoreStore 屏障,在写操作后插入 StoreLoad 屏障;bnr> 在每个 volatile 读操作前插入 LoadLoad 屏障,在读操作后插入 LoadStore 屏障;