深入理解Java中的volatile关键字

35 阅读5分钟

  在并发编程中,我们通常会遇到以下三个问题:原子性问题,可见性问题,有序性问题。volatile是Java提供的一种轻量级的同步机制,它可以解决可见性问题有序性问题

volatile保证内存可见性

  根据java的内存模型我们知道,线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程以读/写共享变量的副本。

image.png

  如果线程A对主内存中的某个数据进行了修改,而此时线程B不知道该数据已经发生了修改,它会从本地内存中去读取这个数据。也就是说,本次线程A修改后的数据,对线程B来说,此时是不可见的

public class Visible {


    private static boolean SWITCH = true;

    public static void main(String[] args) {
        // 线程A
        new Thread("Thread A") {
            @Override
            public void run() {
                while (SWITCH) {
                }
                System.out.println(Thread.currentThread() + " 停止了");
            }
        }.start();


        try {
            TimeUnit.MILLISECONDS.sleep(3000);

        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        // 关闭
        System.out.println(Thread.currentThread() + " 关闭开关,停止线程A执行");
        SWITCH = false;
    }
}

执行输出如下: image.png

  可以看到程序一直在执行,线程A由于可见性看不到已经修改SWITCH的值。当我们给变量SWITCH加上volatile修饰后,程序按我们预期执行结束。

private static volatile boolean SWITCH = true;

那么volatile关键字如何保证内存可见性呢?

  我们通过工具得到编译后的汇编代码后可以看到和没有volatile修饰的变量的赋值操作字节码相比,volatile修饰的变量的赋值操作仅仅是多了一个lock指令前缀

  JMM中主内存和线程的本地内存之间的交互分为8个原子操作:

  • lock(锁定): 作用于主内存的变量,它把一个变量标识为一条线程独占的状态。

  • unlock(解锁): 作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。

  • read(读取): 作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。

  • load(载入): 作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。

  • use(使用): 作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作。

  • assign(赋值): 作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。

  • store(存储): 作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。

  • write(写入): 作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。

  如果要把一个变量从主内存复制到工作内存,那么应该顺序的执行read和load操作。从工作内存存入主内存时,应该顺序的执行store和write操作。注意这里说的是顺序执行就行,而不需要连续执行。

  lock后的写操作会强制回写已修改的数据到主内存,相当于连续执行了store和write操作。根据MESI协议,其他线程一直在监听主内存,发现数据V修改后,会将他们的工作内存中的数据V的状态改为失效状态,最终迫使其他线程在使用V之前,必须去主内存读取最新值。 volatile 修饰的变量就通过这种机制实现了可见性。

volatile有序性

  有序性问题,大多是由JVM的指令重排优化引起的。我们从一个最经典的例子来分析重排序问题。单例模式的实现,我们通常可以采用双重检查加锁(DCL)的方式来实现。

public class Singleton {
    public static volatile Singleton singleton;
    /**
     * 私有构造函数,禁止外部实例化
     */
    private Singleton() {};
    public static Singleton getInstance() {
        if (singleton == null) {
            synchronized (Singleton.class) {
                if (singleton == null) {
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
}

为什么要在变量singleton之间加上volatile关键字呢?

  那是因为instance = new Singleton();并非一个原子操作,实例化一个对象其实可以分为三个步骤:

  1. 分配内存空间。
  2. 初始化对象。
  3. 将内存空间的地址赋值给对应的引用

  在编译器运行时,因为步骤3和步骤2无依赖关系,故而JVM会对其进行指令重排优化,从1-2-3顺序优化为1-3-2顺序。也就是说我们可能得到一个未初始化的对象引用,从而导致出现问题。

那volatile如何防止指令重排序的呢?

image.png

  使用 volatile 修饰变量时,根据 volatile 重排序规则表,Java 编译器在生成字节码时,会在指令序列中插入内存屏障指令来禁止特定类型的处理器重排序。

JMM 把内存屏障指令分为下列四类(内存屏障的作用是禁止指令重排序和解决内存可见性的问题):

image.png

总结

  volatile关键字它可以解决可见性问题和有序性问题,但不能保证原子性。volatile比较适合用来修饰一个会被单线程更改,但又需要立刻让其他线程感知到的值,比如代码逻辑里面的业务开关等,实现轻量级同步。