本文大纲
1. 重排序
2. volatile的特性
3. happens-before
3.1 线程内的happens-before
3.2 线程间的happens-before
4. JMM底层实现原理
1. 重排序
首先,我们来看一段代码:
public class JmmTest1 { static int a = 0, b = 0, x = 0, y = 0; public static void main(String[] args) throws InterruptedException { Thread t1 = new Thread(() -> { x = b; a = 1; }); Thread t2 = new Thread(() -> { y = a; b = 1; }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println("x=" + x + " y=" + y); } }
上面这段代码中,x、y的结果可能会有如下三种情况:
x=0,y=0(例如t1执行完第一个赋值语句后,再切换到t2执行赋值语句);
x=0,y=1(t1先执行完,再执行t2);
x=1,y=0(t2先执行完,再执行t1)。
(注:本文中,在非代码片段中的“=”均念作等于,非赋值操作。)
但是,还存在一种看起来不可能的结果x=1,y=1。
我们可以对上面的代码稍加修改,以便展示x=1,y=1的情况:
public class JmmTest2 { private static int count = 0; static int a = 0, b = 0, x = 0, y = 0; public static void main(String[] args) throws InterruptedException { boolean flag = true; while (flag) { Thread t1 = new Thread(() -> { x = b; a = 1; }); Thread t2 = new Thread(() -> { y = a; b = 1; }); t1.start(); t2.start(); // 让t1、t2线程先执行 t1.join(); t2.join(); System.out.println("第" + ++count + "次打印: " + "x=" + x + " y=" + y); if (x == 1 && y == 1) { flag = false; // 停止循环 } else { // 复位 a = 0; b = 0; x = 0; y = 0; } } } }
我在我的机器上跑了一次上面的代码,在进行了179007次循环后出现了x=1,y=1的情况,截图如下:
造成这种结果的原因可能有:
- 即时编译器的重排序;
- 处理器的乱序执行。
即时编译器和处理器可能将代码中没有数据依赖的代码进行重排序。但如果代码存在数据依赖关系,那么这部分代码不会被重排序。上面的示例代码中,t1线程中对a、x的赋值就不存在依赖关系,所以可能会发生重排序。t2线程同理。
代码被重排序后,可能存在如下的顺序:
Thread t1 = new Thread(() -> { a = 1; // 重排序后,t1先对a进行赋值 x = b; }); Thread t2 = new Thread(() -> { b = 1; // 重排序后,t2先对b进行赋值 y = a; });
这种情况下,当一个线程对a、b其中的一个变量进行赋值后,CPU切换到另外一个线程对另外一个变量进行赋值,就会出现x=1,y=1的结果。
需要指出的是,在单线程情况中,即使经过重排序的代码也不会影响代码输出正确的结果。因为即时编译器和处理器会遵守as-if-serial语义,即在单线程情况下,要给程序一个顺序执行的假象,即使经过重排序的代码的执行结果要和代码顺序执行的结果一致。但是,在多线程的情况下,即时编译器和处理器是不会对经过重排序的代码做任何保证。同时,Java语言规范将这种归咎于我们的程序没有做出恰当的同步操作,即我们没有显式地对数据加上volatile声明或者其他加锁操作。
2. volatile的特性
- 禁止某些编译器的重排序
- 线程间共享数据的可见性;
- 不保证原子性。
类字段加上volatile修饰符后,就不会出现上述的代码片段中的重排序问题。
我们用下面这张图来帮忙理解线程间共享数据的可见性。
可以看到,每个线程都有一个自己的本地内存用于存放该线程所使用的共享变量的副本。本地内存是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。
在同一个线程中,字节码的先后顺序暗含了happens-before的关系。在代码中靠前的代码happens-before靠后的代码。但是,这并不意味前面的代码一定比后面的代码先执行,如果后面的代码没有依赖于前面代码的结果,那么它们可能会被重排序,从而后面的代码可能会先执行,就像文中前面提到的一样。
如果一个操作A happens-before另一个操作B,那么操作A的执行结果对操作B可见。
先重点关注下面的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关系,并且t1线程和t2线程中的赋值操作没有数据依赖关系,所以可能会发生重排序,从而得到x=1,y=1的结果。根据线程间的happens-before关系,我们可以对a或者b加上volatile修饰符来避免这个问题。
以给JmmTest2.java文件中的成员变量a加上volatile修饰符为例:
public class JmmTest2 { private static int count = 0; static volatile int a = 0; // 变量a加上volatile关键字 static int b = 0, x = 0, y = 0; public static void main(String[] args) throws InterruptedException { boolean flag = true; while (flag) { Thread t1 = new Thread(() -> { x = b; a = 1; }); Thread t2 = new Thread(() -> { y = a; b = 1; }); t1.start(); t2.start(); // 让t1、t2线程先执行 t1.join(); t2.join(); System.out.println("第" + ++count + "次打印: " + "x=" + x + " y=" + y); if (x == 1 && y == 1) { flag = false; // 停止循环 } else { // 复位 a = 0; b = 0; x = 0; y = 0; } } } }
一旦a加上了volatile,即时编译器和CPU需要考虑到多线程happens-before关系,t1线程中x、a的赋值操作和t2线程中y、b的赋值操作将不能自由地重排序,所以x的赋值操作先于a的赋值操作执行。同时,根据volatile字段的写操作happens-before之后对同一字段的读操作,所以a的赋值操作先于y的赋值操作执行,这也就意味着,当对b进行赋值时,对x的赋值操作已经完成了(确切的说,这段话不是十分准确,但这样可以帮助你理解)。正确的理解应该是:两个操作具有happens-before关系时,并不意味前一个操作必须要在后一个操作前执行,happens-before仅仅要求前一个操作的执行结果对后一个操作可以见。所以,在a为volatile字段的情况下,程序不可能出现x=1,y=1的情况。
总之,解决这种问题的关键在于构造一个线程间的happens-before关系。
4. JMM底层实现原理
Java内存模型是通过内存屏障(memory barrier)来禁止重排序的。这些内存屏障会限制即时编译器的重排序操作。以 volatile 字段访问为例,所插入的内存屏障将不允许volatile字段写操作之前的内存访问被重排序至其之后;也将不允许volatile 字段读操作之后的内存访问被重排序至其之前。在碰到内存写操作时,处理器并不会等待该指令结束,而是直接开始下一指令,并且依赖于写缓存将更改的数据同步至主内存(main memory)之中。强制刷新写缓存,将使得当前线程写入volatile字段的值(以及写缓存中已有的其他内存修改),同步至主内存之中。
参考文章
极客时间郑雨迪《深入拆解Java虚拟机》专栏的《Java内存模型》。
程晓明《深入理解Java内存模型》。