问题

  • (1)volatile是如何保证可见性的?
  • (2)volatile是如何禁止重排序的?
  • (3)volatile的实现原理?
  • (4)volatile的缺陷?

一、介绍

1.1 volatile定义

Java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量。Java语言提供了volatile,在某些情况下比锁要更加方便。如果一个字段被声明成volatile,Java线程内存模型确保所有线程看到这个变量的值是一致的。

1.2 CPU的术语定义

术语 英文 描述
内存屏障 memory barriers 是一组处理器指令,用于实现对内存操作的顺序限制
缓冲行 cache line 缓存中可以分配的最小存储单位。处理器填写缓存时会加载整个缓存线,需要使用多个主内存读周期
原子操作 atomic operations 不可中断的一个或一系列操作
缓存行填充 cache line fill 当处理器识别到从内存中读取操作数是可缓存的,处理器读取整个缓存行到适当的缓存(L1,L2,L3的或所有)
缓存命中 cache hit 如果进行高速缓存行填充操作的内存位置仍然是下一次处理器访问的地址时,处理器从缓存中读取操作数,而不是从内存读取
写命中 write hit 当处理器操作数写回到一个内存缓存的区域时,它首先会检查这个缓存的内存地址是否在缓存中,如果存在一个有效的缓存行,则处理器将这个操作数写回到缓存,而不是写回内存,这个操作被称为写命中
写缺失 write misses the cahe 一个有效的缓存行别写入到不存在的内存区域

二、可见性

2.1 volatile实现可见性

  • 在生成汇编指令时,会多出Lock前缀的指令
  • Lock前缀的指令在多核处理器下会引发了两件事情
    • 将当前处理器缓存行的数据写回到系统内存
    • 这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效

      2.2 例子说明

  1. public class TestVolatile {
  2. private volatile boolean flag = true;
  3. class MyRunable implements Runnable {
  4. @Override
  5. public void run() {
  6. while (flag) {
  7. //doSomething...
  8. }
  9. System.out.println("finished");
  10. }
  11. }
  12. private void stop() {
  13. this.flag = false;
  14. }
  15. public static void main(String[] args) throws InterruptedException {
  16. TestVolatile t = new TestVolatile();
  17. new Thread(t.new MyRunable()).start();
  18. Thread.sleep(1000);
  19. t.stop();
  20. System.out.println("main finished");
  21. }
  22. }

输出结果

  1. main finished
  2. finished

注意:如果变量flag没有被volatile修饰,则程序不会停止。为什么会这样呢?以下用图说明

  • 在线程MyRunable 运行后

image.png

  • 在主线程调用stop()方法后

image.png

如果变量flag 不加volatile 关键字修饰,则线程MyRunable永远不会停止,因为当主线程调用stop方法后,改变了主内存中的flag变量的值为false,但是线程依然还是使用开始读取到的值flag=true。 加了volatile 后,线程MyRunable 则不会读取本地线程内存的值,而是直接从主内存中重新读取。

三、禁止重排序

3.1 volatile重排序规则表

是否能重排序 第二个操作
第一个操作 普通读写 volatile读 volatile写
普通读写 NO
volatile读 NO NO NO
volatile写 NO NO
  • 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。
  • 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。
  • 当第一个操作是volatile写,第二个操作是volatile读时,不能重排序

3.2 实现禁止重排序--内存屏障

为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序

内存屏障有两个作用:

  • 阻止屏障两侧的指令重排序;
  • 强制把写缓冲区/高速缓存中的数据回写到主内存,让缓存中相应的数据失效;

基于保守策略的JMM内存屏障插入策略:

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

图示

  • volatile写插入内存屏障后生成的指令序列示意图

image.png
JMM在采取了保守策略:在每个volatile写的后面,或者在每个volatile读的前面插入一个StoreLoad屏障。此屏障的作用是避免volatile写与后面可能有的volatile读/写操作重排序。

  • volatile读插入内存屏障后生成的指令序列示意图

image.png

  • 内存屏障的优化图示

image.png

四、volatile 使用例子

单例模式的实现,典型的双重检查锁定(DCL)

  1. public class DCLSingleton {
  2. /**
  3. * volatile保持线程间的可见性,阻止指令重排
  4. * 在没有初始化完成之前,不能赋值
  5. */
  6. private static volatile DCLSingleton instance;
  7. private DCLSingleton() {
  8. }
  9. public static DCLSingleton getSingleton() {
  10. if (instance == null) {
  11. synchronized (DCLSingleton.class) {
  12. if (instance == null) {
  13. instance = new DCLSingleton();
  14. }
  15. }
  16. }
  17. return instance;
  18. }
  19. }

五、总结

  • volatile关键字可以保证可见性,有序性。注意volatile 不能保证原子性,要保证原子性需要借助synchronized、Lock或者原子类
  • volatile关键字的底层主要是通过内存屏障来实现的。
  • volatile关键字的使用场景必须是场景本身就是原子的。

参考