Volatile

请谈谈你对Volatile的理解

Volatile是Java虚拟机提供轻量级的同步机制。请先阅读JMM相关文章

保证可见性

volatile关键字保证了不同线程对这个变量进行读取时的可见性,即一个线程修改了某个变量的值,这时新值对其他线程来说是立即可见的。

具体表现为:

  • 使用volatile关键字会强制将在某个线程中修改的共享变量的值立即写入主内存。

  • 使用volatile关键字的话,当线程2进行修改时,会导致线程1 的工作内存中变量的缓存行无效(反映到硬件层的话,就是CPU的L1或者L2缓存中对应的缓存行无效)。

  • 由于线程1的工作内存中变量的缓存行无效,所以线程1再次读取变量的值时回先去主内存读取。

基于这一点,所以我们经常会看到文章中说volatile能保证可见性。

public class VolatileFoo {
    //init_value最大值
   final  static int MAX = 5;

    //init_value初始值
    static int init_value =0;

    public static void main(String[] args){
        // 启动一个Reader线程,当发现local_value和init_value不同时,则输出init_value被修改的信息
        new Thread(()->{
            int localValue = init_value;
            while (localValue < MAX){
                if(init_value != localValue){
                    System.out.println("this init_value is updated to "+init_value);
                    //对local_value重新赋值
                    localValue = init_value;
                }
            }
        },"Reader").start();

        new Thread(()->{
            int localValue = init_value;
            while (localValue < MAX){
                    System.out.println("this init_value will be changed to "+ ++localValue);
                    //对local_value重新赋值
                    init_value = localValue;
                    try {
                        Thread.sleep(2000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            },"Updater").start();
    }
}

上面程序分别启动两个线程,一个线程对变量进行修改,一个线程负责变量进行输出。运行程序,输出结果如下:

this init_value will be changed to 1
this init_value is updated to 1
this init_value will be changed to 2
this init_value will be changed to 3
this init_value will be changed to 4
this init_value will be changed to 5

从输出信息我们发现,Reader线程没有感知到init_value的变化,我们期望的是在Updater线程更新init_value的值之后,Reader线程能够打印出变化的init_value的值,但结果并不是我们预料的那样。

当我们在init_value前面加上volatile

static volatile int init_value = 0;

我们再次运行程序,结果如下:

this init_value will be changed to 1
this init_value is updated to 1
this init_value will be changed to 2
this init_value is updated to 2
this init_value will be changed to 3
this init_value is updated to 3
this init_value will be changed to 4
this init_value is updated to 4
this init_value will be changed to 5
this init_value is updated to 5

这时候Reader线程就能够感受到init_value的值的变化了,并且在条件不满足时就退出了运行。

禁止指令重排

禁止进行指令重排序,禁止编译器对代码的优化。

普通的变量仅仅会保证在该方法的执行过程总所有依赖赋值的结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。

Map configOptions;
char[] configText;
//此变量必须定义为volatile
volatile boolean initialized = false;

//假设一下代码在线程A中执行
//模拟读取配置信息,当读取完成后将initialized设置为true以通知其他线程配置可用
configOptions = new HashMap();
configText = readConfigFile(fileName);
processCongigOptions(configText,configOptions);
initialized = true

//假设一下代码在线程B中执行
//等待initialized为true,代表线程A已经吧配置信息初始化完成
while(!initialized) {
    sleep();
}
//使用线程A中初始化好的配置信息
doSomethingWithConfig();

上面这段代码如果定义的initialized没有使用volatile来修饰,就可能会由于指令重排序的优化,导致位于线程A中最后一句代码initialized = true被提前执行(这里虽然使用Java作为伪代码,但所指的重排序优化是机器级的优化操作,提前执行时值这句话对于的汇编代码被提前执行),这样在线程B中使用配置信息的代码就可能出现错误,而volatile能避免此类情况的发生。

不保证原子性

volatile能够保证可见性,但是不能保证程序的原子性。

public class VolatileTest {
    public static volatile int race = 0;
    
    public static void increase() {
        race ++;
    }
    private static final int THREAD_COUNT = 20;

    public static void main(String[] args) {
        Thread[] threads = new Thread[THREAD_COUNT];
        for(int i =0 ;i<THREAD_COUNT;i++) {
            threads[i] = new Thread(() ->{
                for(int j =0;j< 10000;j++) {
                    increase();
                }
            });
            threads[i].start();
        }
        
        while(Thread.activeCount() > 1)
            Thread.yield();
        
        System.out.println(race);
    }
}

这段代码发起了20个线程,每个线程对race变量进行10000次自增操作,如果这段代码能够正确并发的话,最后输出的结果应该是200000。我们运行完这段代码之后,并没有获得期望的结果,而且发现每次运行程序。输出的结果都不一样,都是一个小于200000的数字。

问题就出在自增运算race++之中,我们用javap反编译这段代码后发现只有一行代码的increase()方法在Class文件中是由4条字节码指令构成的。

  public static void increase();
    Code:
       0: getstatic     #13                 // Field race:I
       3: iconst_1
       4: iadd
       5: putstatic     #13                 // Field race:I
       8: return

从字节码层面上很容易分析出原因了:当getstatic指令把race的值取到操作栈时,volatile关键字保证了race的值此时是正确的,但是在执行iconst_1、iAdd这些指令的时候,其他线程可能已经把race的值加大了,而在操作栈订的值就变成了过期的数据,所以putstati指令执行后就可能把较小的值同步回主内存中去了。
其实这里我们通过字节码来分析这个问题是不严谨的,因为即使编译出来的只有一条字节指令,也并不意味执行这条指令就是一个原子操作。一条字节码指令在解释执行时,解释器将要运行许多行代码才能实现它的语义,如果是编译执行,一条字节码指令也可能转化成若干条本地机器码指令。关于解释执行和编译执行,我们还会再讲到。

由于volatile变量只能保证可见性,在不符合以下两条规则的运算场景中,我们仍然要通过加锁(synchronized或java.util.concurrent中的原子类)来保证原子性。

  • 运输结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
  • 变量不需要与其他状态变量共同参与不变约束。

类似下面的场景就时候采用volatile来控制并发。

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

如果我们想让上面的那个自增操作保持原子性,我们可以使用AtomicInteger,具体程序如下,这里就不多做介绍了。

import java.util.concurrent.atomic.AtomicInteger;

public class VolatileTest {
//  public static volatile int race = 0;
    public static AtomicInteger race =new  AtomicInteger(0);
    
    public static void increase() {
//      race ++;
        race.incrementAndGet();
    }
    private static final int THREAD_COUNT = 20;

    public static void main(String[] args) {
        Thread[] threads = new Thread[THREAD_COUNT];
        for(int i =0 ;i<THREAD_COUNT;i++) {
            threads[i] = new Thread(() ->{
                for(int j =0;j< 10000;j++) {
                    increase();
                }
            });
            threads[i].start();
        }
        while(Thread.activeCount() > 1)
            Thread.yield();
        
        System.out.println(race.get());
    }
}

深入理解volatile关键字

volatile关键字修饰的共享变量在进行写操作的时候会多出一行汇编

0x01a3de1d:movb $0×0,0×1104800(%esi);0x01a3de24:lock addl $0×0,(%esp);

Lock前缀的指令在多核处理器下会引发两件事情。

1)将当前处理器缓存行的数据写回到系统内存。

2)这个写回内存的操作会使其他cpu里缓存了该内存地址的数据无效。

那为什么说它能禁止指令重排呢?从硬件架构上讲,指令重排序是指CPU采用了运行将多条指令不按程序规定的顺序分开发送给各相应的点了单元处理,但并不是指令任意重排,CPU需要能正确处理指令依赖情况以保障程序能得出正确的执行结果。lock addl $0x0,(%rsp) 指令把修改同步到内存时,意味着所有之前的操作都已经执行完成,这样便形成了" 指令重排序无法越过内存屏障"的效果。

多处理器总线嗅探

为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存后再进行操作,但操作不知道何时会写到内存。如果对声明了volatile的变量进行写操作,JVM就会向处理器发送一条lock前缀的指令,将这个变量所在行的数据写回主内存。但是在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议(mesi),每个处理器通过嗅探在总线上传播的数据来检查自己的缓存值是不是过期了,如果处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存中。

编译器不会对volatile读后面的任意内存操作重排序;

编译器不会对volatile写前面的任意内存操作重排序;

总线风暴

由于volatile的msi缓存一致性协议需要不断的从主内存嗅探和CAS不断循环无效交互导致总线带宽达到峰值。

解决办法:部分volatile的cas使用synchronized关键字。

Q.E.D.

知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议

弱小和无知不是生存的障碍,傲慢才是。