Java中的volatile关键字

By | 2019年4月25日

本文大纲

1. 重排序
2. volatile的作用
3. happens-before
  3.1 线程内的happens-before
  3.2 线程间的happens-before
4. JMM底层实现原理

1. 重排序

  我们来看一段代码:

public class JmmTest implements Runnable {
    int a = 0;
    int b = 0;

    public void method1() {
        int r2 = a;
        b = 1;
        System.out.println("r2: " + r2);
    }

    public void method2() {
        int r1 = b;
        a = 2;
        System.out.println("r1: " + r1);
    }

    public static void main(String[] args) {
        JmmTest tmmTest = new JmmTest();
        Thread t1 = new Thread(tmmTest, "t1");
        Thread t2 = new Thread(tmmTest, "t2");
        t1.start();
        t2.start();
    }

    @Override
    public void run() {
        if ("t1".equals(Thread.currentThread().getName())) {
            method1();
        } else {
            method2();
        }
    }
}

  上面这段代码中,r1、r2的结果可能会有如下三种情况:

r1=0,r2=0;

r1=0,r2=2;

r1=1,r2=0。

(注:本文中,在非代码片段中的“=”均念作等于,非赋值操作。)

  但是,还存在一种看起来不可能的结果r1=1,r2=2。造成这种结果的原因可能有:

  • 即时编译器的重排序;
  • 处理器的乱序执行。

  即时编译器和处理器可能将代码中没有数据依赖的代码进行重排序。但如果代码存在数据依赖关系,那么这部分代码不会被重排序。上面的示例代码中,method1方法中对r2、b的赋值就不存在依赖关系,所以可能会发生重排序。method2方法同理。

  代码被重排序后,可能存在如下的顺序:

    public void method1() {
        b = 1; // 重排序后,method1先对b进行赋值
        int r2 = a;
        System.out.println("r2: " + r2);
    }

    public void method2() {
        a = 2; // 重排序后,method2先对a进行赋值
        int r1 = b;
        System.out.println("r1: " + r1);
    }

  这种情况下,当一个线程对a、b其中的一个变量进行赋值后,CPU切换到另外一个线程对另外一个变量进行赋值,就会出现r1=1,r2=2的结果。

  需要指出的是,在单线程情况中,即使经过重排序的代码也不会影响代码输出正确的结果。因为即时编译器和处理器会遵守as-if-serial属性,即在单线程情况下,要给程序一个顺序执行的假象,即使经过重排序的代码的执行结果要和代码顺序执行的结果一致。但是,在多线程的情况下,即时编译器和处理器是不会对经过重排序的代码做任何保证。同时,Java语言规范将这种归咎于我们的程序没有做出恰当的同步操作,即我们没有显式地对数据加上volatile声明或者其他加锁操作。

2. volatile的作用

  • 禁止指令重排序;
  • 线程间共享数据的可见性。

3. happens-before

  为了让我们的代码免于上述问题的困扰,Java 5明确定义了Java内存模型。其中最为重要的一个概念就是happens-before。

  happens-before是用于描述两个操作间数据的可见性的。如果X happens-before Y,那么X的结果对于Y可见。下面我将讲述单一线程和多线程情况下的happens-before。

3.1 线程内的happens-before

  在同一个线程中,字节码的先后顺序暗含了happens-before的关系。在代码中靠前的代码happens-before靠后的代码。但是,这并不意味前面的代码一定比后面的代码先执行,如果后面的代码没有依赖于前面代码的结果,那么它们可能会被重排序,从而后面的代码可能会先执行,就像文中前面提到的一样。

3.2 线程间的happens-before

  先重点关注下面的happens-before关系中标红的部分。

  • volatile字段的写操作happens-before 之后(这里指时钟顺序先后)对同一字段的读操作
  • 解锁操作happens-before之后(这里指时钟顺序先后)对同一把锁的加锁操作;
  • 线程的启动操作(即 Thread.starts()) happens-before 该线程的第一个操作;
  • 线程的最后一个操作happens-before它的终止事件(即其他线程通过 Thread.isAlive() 或 Thread.join() 判断该线程是否中止);
  • 线程对其他线程的中断操作happens-before被中断线程所收到的中断事件(即被中断线程的 InterruptedException 异常,或者第三个线程针对被中断线程的 Thread.interrupted 或者 Thread.isInterrupted 调用);
  • 构造器中的最后一个操作happens-before析构器的第一个操作;
  • happens-before具备传递性

  上文我们的代码中,除了有线程内的happens-before关系,没有定义其他任何线程间的happens-before关系,并且method1和method2两个方法中的赋值操作没有数据依赖关系,所以可能会发生重排序,从而得到r1=1,r2=2的结果。根据线程间的happens-before关系,我们可以对a或者b加上volatile修饰符来避免这个问题。

  以给成员变量b加上volatile修饰符为例:

    int a = 0;
    volatile int b = 0; // 加上volatile修饰符

    public void method1() {
        int r2 = a;
        b = 1;
        System.out.println("r2: " + r2);
    }

    public void method2() {
        int r1 = b;
        a = 2;
        System.out.println("r1: " + r1);
    }

  一旦b加上了volatile,即时编译器和CPU需要考虑到多线程happens-before关系,r2=a和b=1将不能自由地重排序,所以第r2的赋值操作先于b的赋值操作执行,同时,根据volatile字段的写操作happens-before之后对同一字段的读操作,所以b的赋值操作先于r1的赋值操作执行。这也就意味着,当对a进行赋值时,对r2的赋值操作已经完成了。因此,在b为volatile字段的情况下,程序不可能出现r1=1,r2=2的情况。

  总之,解决这种问题的关键在于构造一个线程间的happens-before关系。

4. JMM底层实现原理

  Java内存模型是通过内存屏障(memory barrier)来禁止重排序的。这些内存屏障会限制即时编译器的重排序操作。以 volatile 字段访问为例,所插入的内存屏障将不允许volatile字段写操作之前的内存访问被重排序至其之后;也将不允许volatile 字段读操作之后的内存访问被重排序至其之前。

  在碰到内存写操作时,处理器并不会等待该指令结束,而是直接开始下一指令,并且依赖于写缓存将更改的数据同步至主内存(main memory)之中。强制刷新写缓存,将使得当前线程写入volatile字段的值(以及写缓存中已有的其他内存修改),同步至主内存之中。

由于内存写操作同时会无效化其他处理器所持有的、指向同一内存地址的缓存行,因此可以认为其他处理器能够立即见到该volatile字段的最新值。

参考文章

极客时间《深入拆解Java虚拟机》专栏的《Java内存模型》。


注:www2014.aspxhtml.com转载自cnblogs,若看不到图片,请移步来源网址查看。
via:https://www.cnblogs.com/pedlar/p/10703705.html

发表评论

电子邮件地址不会被公开。 必填项已用*标注