并发编程—Volatile关键字

锁提供了两种主要特性:互斥(mutual exclusion) 和可见性(visibility)。互斥即一次只允许一个线程持有某个特定的锁,因此可以保证一次就只有一个线程在访问共享数据。可见性要复杂一些,它必须确保释放锁之前对共享数据做出的更改对于随后获得该锁的另一个线程是可见的。

volatile 变量可以被看作是一种 “轻量级的 synchronized”,与 synchronized 块相比,volatile 变量所需的编码较少,并且运行时开销也较少,但是它所能实现的功能也仅是 synchronized 的一部分。

volatile变量

一个共享变量被volatile修饰之后,则具有了两层语义:

  1. 保证了该变量在多个线程的可见性。
  2. 禁止了指令重排序

保证内存可见性

前面讲过Java内存模型,可以知道:对一个共享变量进行操作时,各个线程会将共享变量从主内存中拷贝到工作内存,然后CPU会基于工作内存中的数据进行处理。线程在工作内存进行操作完成之后何时会将结果写回主内存中?这个时机对普通变量是没有规定的。所以才导致了内存可见性问题。

volatile是如何解决可见性问题的?
如果代码中的共享变量被volatile修饰,在生成汇编代码时会在volatile修饰的共享变量进行写操作的时候会多出Lock前缀的指令。在多核处理器的情况下,这个Lock指令主要有3个功能:

  1. volatile的变量被修改后会立即写入到主存中
  2. 这个写回主存的操作会告知其它线程中该变量对应的缓存行失效,所以其它线程如果要操作这个变量,会重新去主存中读取最新的值。
  3. 禁止特定类型的重排序。

所以,被volatile修饰的变量能够保证每个线程能够获取该变量的最新值,从而避免出现数据脏读的现象

禁止指令重排序

对于volatile的共享变量,编译器在生成字节码时,会在指令序列中插入内存屏障(Lock指令)来禁止特定类型的重排序。这是在happens-before的原则下做进一步的约束

对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎是不可能的,为此,JMM采取了保守策略:

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

需要注意的是:volatile写是在前面和后面分别插入内存屏障,而volatile读操作是在后面插入两个内存屏障。

  • StoreStore屏障:禁止上面的普通写和下面的volatile写重排序;
  • StoreLoad屏障:防止上面的volatile写与下面可能有的volatile读/写重排序
  • LoadLoad屏障:禁止下面所有的普通读操作和上面的volatile读重排序
  • LoadStore屏障:禁止下面所有的普通写操作和上面的volatile读重排序

如下两张图来自《Java并发编程的艺术》一书:

  • volatile变量的写操作
    volatile写
  • volatile变量的读操作
    volatile读

根据上面的说明也能得出:虽然volatile关键字能禁止指令重排序,但是volatile也只能在一定程度上保证有序性。在volatile之前和之后的指令集不会乱序越过volatile变量执行,但volatile之前和之后的指令集在没有关联性的前提下,仍然会执行指令重排。

使用 volatile 变量的条件

volatile并不能代替synchronized,要使 volatile 变量提供理想的线程安全,必须同时满足下面两个条件:

  1. 对变量的写操作不依赖于当前值
    例如i++的操作就无法通过volatile保证结果准确性的,因为i++包含了读取-修改-写入三个步骤,并不是一个原子操作,所以 volatile 变量不能用作线程的安全计数器。
    例如下面的这段代码,可以说明volatile变量的操作不具有原子性

    package com.lzumetal.multithread.volatiletest;
    
    public class Counter {
    
      private volatile static int count = 0;
    
      private static void inc() {
          //延迟1毫秒,使得结果明显
          sleep(1);
          count++;
      }
    
      public static void main(String[] args) {
    
          //同时启动1000个线程,去进行i++计算,看看实际结果
          for (int i = 0; i < 1000; i++) {
              new Thread(Counter::inc).start();
          }
    
          sleep(1000);   
          System.out.println("运行结果:Counter.count=" + Counter.count); //结果很可能<1000
      }
    
      private static void sleep(long millis) {
          try {
              Thread.sleep(millis);
          } catch (InterruptedException e) {
              e.printStackTrace();
          }
      }
    
    }

    运行计数器的结果很大可能性是<1000的。对于计数器的这种功能,一般是需要使用JUC中atomic包下的类,利用CAS的机制去做。

  2. 该变量没有包含在具有其他变量的不变式中
    这句话有点拗口,看代码比较直观。

      public class NumberRange {
          private volatile int lower = 0;
           private volatile int upper = 10;
    
          public int getLower() { return lower; }
          public int getUpper() { return upper; }
    
          public void setLower(int value) { 
              if (value > upper) 
                  throw new IllegalArgumentException(...);
              lower = value;
          }
    
          public void setUpper(int value) { 
              if (value < lower) 
                  throw new IllegalArgumentException(...);
              upper = value;
          }
      }

上述代码中,上下界初始化分别为0和10,假设线程A和B在某一时刻同时执行了setLower(8)setUpper(5),且都通过了不变式的检查,设置了一个无效范围(8, 5),所以在这种场景下,需要使setLower()setUpper()操作原子化 —— 而将字段定义为 volatile 类型是无法实现这一目的的。

使用 volatile 举例

虽然使用 volatile 变量要比使用相应的锁简单得多,而且性能也更好,但是一般不会太多的使用它,主要是它比使用锁更加容易出错。
想要安全地使用volatile,必须牢记一条原则:只有在状态真正独立于程序内其他内容时才能使用 volatile

修饰状态标志量

volatile boolean shutdownRequested;
 
...
 
public void shutdown() { 
    shutdownRequested = true; 
}
 
public void doWork() { 
    while (!shutdownRequested) { 
        // do stuff
    }
}

在这个示例使用 synchronized 块编写循环要比使用 volatile 状态标志编写麻烦很多。由于 volatile 简化了编码,并且状态标志并不依赖于程序内任何其他状态,因此此处非常适合使用 volatile。

double-check 单例模式

public class Singleton {

    private volatile static Singleton instance;

    public static Singleton getInstance() {
        if (instance == null) {                 //1
            syschronized(Singleton.class) {     //2
                if (instance == null) {         //3
                    instance = new Singleton(); //4
                }
            }
        }
        return instance;
    } 
}

为什么要用volatile修饰才是最安全的呢?可能有人会觉得是这样:线程1执行完第4步,释放锁。线程2获得锁后执行到第4步,由于可见性的原因,发现instance还是null,从而初始化了两次。
但是不会存在这种情况,因为synchronized能保证线程1在释放锁之前会讲对变量的修改刷新到主存当中,线程2拿到的值是最新的。

实际存在的问题是无序性。
第4步这个new操作是无序的,它可能会被编译成:
a.先分配内存,让instance指向这块内存
b.在内存中创建对象

synchronized虽然是互斥的,但不代表一次就把整个过程执行完,它在中间是可能释放时间片的,时间片不是锁。
也就是说可能在a执行完后,时间片被释放,线程2执行到1,此时它读到的instance是不是null呢?基于可见性,可能是null,也可能不是null。 有意思的是,在这个例子中,如果读到的是null,反而没问题了,接下来会等待锁,然后再次判断时不为null,最后返回单例。
如果读到的不是null,按代码逻辑直接return instance,但这个instance还没执行构造参数,所以使用的时候就会出现问题。

阅读 748

推荐阅读
技术文档整理
用户专栏

后端开发

21 人关注
31 篇文章
专栏主页