Java并发编程 volatile关键字

220 阅读7分钟

被volatile关键字修饰的变量的特性:

  • 保证了不同线程对该变量操作的可见性
  • 禁止指令重排序(有序性

有关volatile,就不得不提及并发编程中的三个特性及其与volatile的关系:原子性、可见性、有序性

可见性

可见性:确保多个线程在读取变量时总能获取到变量的最新值。

为何可见性会是有关并发编程中的一个问题?这涉及到Java内存模型(JMM)

JMM图解

线程会将主内存中的变量拷贝到自己的工作内容中,在对工作内存中的变量进行操作后,会将变量再写回主内存中。

各个工作内存对应于CPU各个核心的缓存(通常每个核心的L1与L2缓存是各核心独立拥有,L3缓存则是所有核心共享的),主内存对应于内存。

注意:线程在自己的工作内存中修改变量后,并不一定会立即将改动更新到主内存中。这就引起了可见性问题。如果更新不及时,其他线程将不会获知变量的新改动,而是读取主内存中的旧值。

每个线程只能访问自己的工作内存,本线程的工作线程对于其他线程是不可见的。

Java定义了8种与主内存、工作内存交互相关的原子操作:

  • lock 将对象变成线程独占的状态
  • unlock 将线程独占状态的对象的锁释放出来
  • read 从主内存读数据
  • load 将从主内存读取的数据写入工作内存
  • use 工作内存使用对象
  • assign 对工作内存中的对象进行赋值
  • store 将工作内存中的对象传送到主内存当中
  • write 将对象写入主内存当中,并覆盖旧值

其中read、load必须成对出现,store、write必须成对出现。

volatile变量的可见性

两个线程对一个volatile变量的读写过程

image-20200710111241984

对volatile变量的内存操作控制包括:

  • 在本线程use之前,不允许其他线程对该变量进行read load操作;
  • 在本线程assign之后,必须紧接着连续进行store write操作。

这相当于将线程的read-load-use操作绑定为一个原子操作,将assign-store-write绑定为一个原子操作。

注意:volatile的可见性仅保证了主内存中的数据一致性,不保证多个线程的工作内存之间的数据一致性。如果线程2在线程1use操作后assign操作前读取了主内存中的volatile变量(这一操作不违反volatile的内存操作控制),那么在线程1assign操作后,线程1与线程2工作内存中的volatile变量存在数据不一致。

有序性

volatile变量的有序性

编译器为了提高程序的执行效率有时会进行指令重排,改变部分代码的执行顺序。

volatile变量的有序性在于:

  • 当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见(结果已经被刷新到主内存中),在其后面的操作肯定还没有进行;
  • 在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。

有序性通过限制指令重排实现,具体由Java编译器放置的内存屏障(Memory Barrier)实现。

内存屏障的功能:

  • 重排序时不能把后面的指令重排序到内存屏障之前的位置;
  • 使得本CPU核心的Cache写入内存 ;
  • 写入动作也会引起别的CPU核心无效其Cache,相当于让新写入的值对别的线程可见。

内存屏障包括:

  • **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变量缺失的原子性

volatile变量及对其的读写操作具有可见性、有序性。但volatile变量依然只有在某些情况下才是线程安全的,因为其不具备原子性。

public class Test {
    public volatile int inc = 0;
 
    public void increase() {
        inc++;
    }
 
    public static void main(String[] args) throws InterruptedException {
        final Test test = new Test();
        for(int i=0;i<10;i++){
            new Thread(){
                public void run() {
                    for(int j=0;j<1000;j++)
                        test.increase();
                };
            }.start();
        }
 
        Thread.sleep(3000);
        System.out.println(test.inc);
    }

在此例中多线程的运行结果不会与预期一致。

一个具体的情形:线程1从主内存读取到 i==10 后拥塞(此时线程1还未进入assign阶段),由线程2继续执行。线程2从主内存中读取到 i==10 后完成自增,并根据volatile的性质,立即刷新入主内存。此时主内存中 i == 11。然后线程2恢复,继续自增操作、将 i == 11写入主内存。2个线程分别进行了1次自增操作,但主内存中的volatile变量值却只增加了1。

问题在于volatile变量在一方线程仅写入,一方线程仅读取的情况下具有原子性(因为volatile变量的read-load-use和assign-store-write都是原子操作)。但对于多个线程都需要读取+写入的情况下,无法保证这种复合操作的原子性,如例子中 [线程1读取-线程2读取-线程2写入-线程1写入] 的情况。

注意:线程2将11写回主存,不会把线程A的缓存行设为无效吗?但是线程1的读操作已经完成,只有在做读操作时,发现自己缓存行无效,才会去读主存的值,所以这里线程1继续做自增了。这就是***”可见性:确保多个线程在==读取变量时==总能获取到变量的最新值。“*** 对于读取阶段之后线程,volatile变量刷新回主内存的行为不会导致该线程缓存行无效化。如果线程1对volatile变量完成了一次写入,新值会刷新到主内存中;同时线程2尚未开始读操作,此时线程2的缓存行将会无效化,线程2将去主内存中读入变量新值。

volatile变量的适用场景

状态量标记(利用可见性与有条件的原子性)

一个线程仅写状态量标记,一个线程仅读状态量标记。volatile可见性保证监控线程总能获取flag的最新值,volatile变量的单纯写操作也具有原子性。利用volatile进行状态量标记的方法脚synchronized、Lock有效率提升。

int a = 0;
volatile bool flag = false;

// Thread-1
public void write() {
    a = 2;              
    flag = true;        
}
// Thread-2
public void multiply() {
    if (flag) {         
        int ret = a * a;
    }
}

单例模式的实现,双重检查锁定

利用volatile的有序性确保单例对象的创建过程不会出现指令重排。对象创建的过程本因先对对象进行初始化,再先将指针指向对象;但编译器的指令重排可能会颠倒这两个过程,导致并行编程中访问未完全初始化的对象。

为什么单例模式要double check? 假设有2个线程调用了getInstance(),线程1和线程2都通过了第一层null check。随后线程1竞争到了锁并通过了第二次null check,创建了单例对象并释放了锁。此时线程2获取了锁,第二次null check防止线程2再创建一个单例对象(如果创建了就与单例对象的设计模式相违背了)。

class Singleton{
    private volatile static Singleton instance = null;
 
    private Singleton() {
 
    }
 
    public static Singleton getInstance() {
        if(instance==null) {
            synchronized (Singleton.class) {
                if(instance==null)
                    instance = new Singleton();
            }
        }
        return instance;
    }
}