本文大纲

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的特性

  • 线程间共享数据的可见性;
  • 有限的原子性。

  首先,我们用下面这张图来帮忙理解线程间共享数据的可见性。

Java中的volatile关键字-LMLPHP

  可以看到,每个线程都有一个自己的本地内存用于存放该线程所使用的共享变量的副本。本地内存是JMM中的一个抽象概念,并没有真实存在。当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存,由于内存写操作同时会无效化其他处理器所持有的、指向同一内存地址的缓存行,因此可以认为其他处理器能够立即见到该volatile字段的最新值。

  关于有限的原子性,是指对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性,如以下代码:

    class VolatileAtomicFeature {
        volatile long vl = 0L; // 使用volatile修饰long型变量

        public void set(long l) {
            vl = l;
        }

        public void getAndIncrement() {
            vl++;
        }

        public long get() {
            return vl;
        }
    }

  当有3个线程同时调用上面代码中的3个方法时,上面的代码和下面的代码在语义上是等价的:

    class VolatileAtomicFeature {
        long vl = 0L; // 型普通变量

        public synchronized void set(long l) { // 同步方法
            vl = l;
        }

        public void getAndIncrement() { // 普通方法
            long temp = get(); // 调用同步的读方法
            temp += 1L;
            set(temp); // 调用同步的写方法
        }

        public synchronized long get() { // 同步方法
            return vl;
        }
    }

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修饰符来避免这个问题。

  以给JmmTest.java文件中的成员变量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关系,method1中r2、b的赋值操作和method2中r1、a的赋值操作将不能自由地重排序,所以第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字段的值(以及写缓存中已有的其他内存修改),同步至主内存之中。

参考文章

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

  程晓明《深入理解Java内存模型》。

04-14 18:39