在 java 并发编程中,线程和锁永远是最重要的概念。语言规范虽然是规范描述,但是其中也有非常多的知识和最佳实践是值得学习的,相信这篇文章还是可以给很多读者提供学习参考的。
本文主要是翻译 + 解释 Oracle 《The Java Language Specification, Java SE 8 Edition》 的第17章 《Threads and Locks》 ,原文大概30页pdf,我加入了很多自己的理解,希望能帮大家把规范看懂,并且从中得到很多你一直想要知道但是还不知道的知识。
注意,本文在说 Java 语言规范,不是 JVM 规范,JVM 的实现需要满足语言规范中定义的内容,但是具体的实现细节由各 JVM 厂商自己来决定。所以,语言规范要尽可能严谨全面,但是也不能限制过多,不然会限制 JVM 厂商对很多细节进行性能优化。
我能力有限,虽然已经很用心了,但有些地方我真的不懂,我已经在文中标记出来了。
建议分 3 部分阅读。
将 17.1、17.2、17.3 一起阅读,这里关于线程中的 wait、notify、中断有很多的知识;
17.4 的内存模型比较长,重排序和 happens-before 关系是重点;
剩下的 final、字分裂、double和long的非原子问题,这些都是相对独立的 topic。
Chapter 17. Threads and Locks
前言
在 java 中,线程由 Thread 类表示,用户创建线程的唯一方式是创建 Thread 类的一个实例,每一个线程都和这样的一个实例关联。在相应的 Thread 实例上调用 start() 方法将启动一个线程。
如果没有正确使用同步,线程表现出来的现象将会是令人疑惑的、违反直觉的。这个章节将描述多线程编程的语义问题,包括一系列的规则,这些规则定义了在多线程环境中线程对共享内存中值的修改是否对其他线程立即可见。
java编程语言内存模型定义了统一的内存模型用于屏蔽不同的硬件架构,在没有歧义的情况下,下面将用内存模型表示这个概念。
这些语义没有规定多线程的程序在 JVM 的实现上应该怎么执行,而是限定了一系列规则,由 JVM 厂商来满足这些规则,即不管 JVM 的执行策略是什么,表现出来的行为必须是可被接受的。
操作系统有自己的内存模型,C/C++ 这些语言直接使用的就是操作系统的内存模型,而 Java 为了屏蔽各个系统的差异,定义了自己的统一的内存模型。简单说,Java 开发者不再关心每个 CPU 核心有自己的内存,然后共享主内存。而是把关注点转移到:每个线程都有自己的工作内存,所有线程共享主内存。
17.1 同步(synchronization)
Java 提供了多种线程之间通信的机制,其中最基本的就是使用同步 (synchronization),其使用监视器 (monitor) 来实现。java中的每个对象都关联了一个监视器,线程可以对其进行加锁和解锁操作。
在同一时间,只有一个线程可以拿到对象上的监视器锁。如果其他线程在锁被占用期间试图去获取锁,那么将会被阻塞直到成功获取到锁。同时,监视器锁可以重入,也就是说如果线程 t 拿到了锁,那么线程 t 可以在解锁之前重复获取锁;每次解锁操作会反转一次加锁产生的效果。
synchronized 有以下两种使用方式:
synchronized 代码块。synchronized(object)在对某个对象上执行加锁时,会尝试在该对象的监视器上进行加锁操作,只有成功获取锁之后,线程才会继续往下执行。线程获取到了监视器锁后,将继续执行synchronized 代码块中的代码,如果代码块执行完成,或者抛出了异常,线程将会自动对该对象上的监视器执行解锁操作。
synchronized 作用于方法,称为同步方法。同步方法被调用时,会自动执行加锁操作,只有加锁成功,方法体才会得到执行。如果被 synchronized 修饰的方法是实例方法,那么这个实例的监视器会被锁定。如果是 static 方法,线程会锁住相应的 Class 对象的监视器。方法体执行完成或者异常退出后,会自动执行解锁操作。
Java语言规范既不要求阻止死锁的发生,也不要求检测到死锁的发生。如果线程要在多个对象上执行加锁操作,那么就应该使用传统的方法来避免死锁的发生,如果有必要的话,需要创建更高层次的不会产生死锁的加锁原语。
java 还提供了其他的一些同步机制,比如对 volatile 变量的读写、使用 java.util.concurrent 包中的同步工具类等。
同步这一节说了 Java 并发编程中最基础的 synchronized 这个关键字,大家一定要理解 synchronize 的锁是什么,它的锁是基于 Java 对象的监视器 monitor,所以任何对象都可以用来做锁。有兴趣的读者可以去了解相关知识,包括偏向锁、轻量级锁、重量级锁等。
小知识点:对 Class 对象加锁、对对象加锁,它们之间不构成同步。synchronized 作用于静态方法时是对 Class 对象加锁,作用于实例方法时是对实例加锁。
面试中经常会问到一个类中的两个 synchronized static 方法之间是否构成同步?构成同步。
17.2 等待集合 和 唤醒(Wait Sets and Notification)
每个 java 对象,都关联了一个监视器,也关联了一个等待集合。等待集合是一个线程集合。
当对象被创建出来时,它的等待集合是空的,对于向等待集合中添加或者移除线程的操作都是原子的,以下几个操作可以操纵这个等待集合:Object.wait, Object.notify, Object.notifyAll。
等待集合也可能受到线程的中断状态的影响,也受到线程中处理中断的方法的影响。另外,sleep 方法和 join 方法可以感知到线程的 wait 和 notify。
这里概括得比较简略,没看懂的读者没关系,继续往下看就是了。
这节要讲Java线程的相关知识,主要包括:
Thread 中的 sleep、join、interrupt
继承自 Object 的 wait、notify、notifyAll
还有 Java 的中断,这个概念也很重要
17.2.1 等待 (Wait)
等待操作由以下几个方法引发:wait(),wait(long millisecs),wait(long millisecs, int nanosecs)。在后面两个重载方法中,如果参数为 0,即 wait(0)、wait(0, 0) 和 wait() 是等效的。
如果调用 wait 方法时没有抛出 InterruptedException 异常,则表示正常返回。
前方高能,请读者保持高度精神集中。
我们在线程 t 中对对象 m 调用 m.wait() 方法,n 代表加锁编号,同时还没有相匹配的解锁操作,则下面的其中之一会发生:
如果 n 等于 0(如线程 t 没有持有对象 m 的锁),那么会抛出 IllegalMonitorStateException 异常。
注意,如果没有获取到监视器锁,wait 方法是会抛异常的,而且注意这个异常是IllegalMonitorStateException异常。这是重要知识点,要考。
如果线程 t 调用的是 m.wait(millisecs) 或m.wait(millisecs, nanosecs),形参millisecs 不能为负数,nanosecs 取值应为 [0, 999999],否则会抛出IllegalArgumentException 异常。
如果线程 t 被中断,此时中断状态为 true,则 wait 方法将抛出 InterruptedException 异常,并将中断状态设置为 false。
中断,如果读者不了解这个概念,可以参考我在 AQS(二) 中的介绍,这是非常重要的知识。
否则,下面的操作会顺序发生:
注意:到这里的时候,wait 参数是正常的,同时 t 没有被中断,并且线程 t 已经拿到了 m 的监视器锁。
1.线程 t 会加入到对象 m 的等待集合中,执行 加锁编号 n 对应的解锁操作
这里也非常关键,前面说了,wait 方法的调用必须是线程获取到了对象的监视器锁,而到这里会进行解锁操作。切记切记。。。
public Object object = new Object(); void thread1() { synchronized (object) { // 获取监视器锁 try { object.wait(); // 这里会解锁,这里会解锁,这里会解锁 // 顺便提一下,只是解了object上的监视器锁,如果这个线程还持有其他对象的监视器锁,这个时候是不会释放的。 } catch (InterruptedException e) { // do somethings } } }
2.线程 t 不会执行任何进一步的指令,直到它从 m 的等待集合中移出(也就是等待唤醒)。在发生以下操作的时候,线程 t 会从 m 的等待集合中移出,然后在之后的某个时间点恢复,并继续执行之后的指令。
并不是说线程移出等待队列就马上往下执行,这个线程还需要重新获取锁才行,这里也很关键,请往后看17.2.4中我写的两个简单的例子。
在 m上执行了 notify 操作,而且线程 t 被选中从等待集合中移除。
在 m 上执行了 notifyAll 操作,那么线程 t 会从等待集合中移除。
线程 t 发生了 interrupt 操作。
如果线程 t 是调用 wait(millisecs) 或者 wait(millisecs, nanosecs) 方法进入等待集合的,那么过了millisecs 毫秒或者 (millisecs*1000000+nanosecs) 纳秒后,线程 t也会从等待集合中移出。
JVM 的“假唤醒”,虽然这是不鼓励的,但是这种操作是被允许的,这样 JVM 能实现将线程从等待集合中移出,而不必等待具体的移出指令。
注意,良好的 Java 编码习惯是,只在循环中使用 wait 方法,这个循环等待某些条件来退出循环。
个人理解wait方法是这么用的:
synchronized(m) { while(!canExit) { m.wait(10); // 等待10ms; 当然中断也是常用的 canExit = something(); // 判断是否可以退出循环 } } // 2 个知识点: // 1. 必须先获取到对象上的监视器锁 // 2. wait 有可能被假唤醒
每个线程在一系列 可能导致它从等待集合中移出的事件 中必须决定一个顺序。这个顺序不必要和其他顺序一致,但是线程必须表现为它是按照那个顺序发生的。
例如,线程 t 现在在 m 的等待集合中,不管是线程 t 中断还是 m 的 notify 方法被调用,这些操作事件肯定存在一个顺序。如果线程 t 的中断先发生,那么 t 会因为 InterruptedException 异常而从 wait 方法中返回,同时 m 的等待集合中的其他线程(如果有的话)会收到这个通知。如果 m 的 notify 先发生,那么 t 会正常从 wait 方法返回,且不会改变中断状态。
我们考虑这个场景:
线程 1 和线程 2 此时都 wait 了,线程 3 调用了 :
synchronized (object) { thread1.interrupt(); //1 object.notify(); //2 }
本来我以为上面的情况 线程1 一定是抛出 InterruptedException,线程2 是正常返回的。感谢评论留言的 xupeng.zhang,我的这个想法是错误的,完全有可能线程1正常返回(即使其中断状态是true),线程2 一直 wait。
3.线程 t 执行编号为 n 的加锁操作
回去看 2 说了什么,线程刚刚从等待集合中移出,然后这里需要重新获取监视器锁才能继续往下执行。
4.如果线程 t 在 2 的时候由于中断而从 m 的等待集合中移出,那么它的中断状态会重置为 false,同时 wait 方法会抛出 InterruptedException 异常。
这一节主要在讲线程进出等待集合的各种情况,同时,最好要知道中断是怎么用的,中断的状态重置发生于什么时候。
这里的 1,2,3,4 的发生顺序非常关键,大家可以仔细再看看是不是完全理解了,之后的几个小节还会更具体地阐述这个,参考代码请看 17.2.4 小节我写的简单的例子。
17.2.2 通知(Notification)
通知操作发生于调用 notify 和 notifyAll 方法。
我们在线程 t 中对对象 m 调用 m.notify() 或 m.notifyAll() 方法,n 代表加锁编号,同时对应的解锁操作没有执行,则下面的其中之一会发生:
如果 n 等于 0,抛出 IllegalMonitorStateException 异常,因为线程 t 还没有获取到对象 m 上的锁。
这一点很关键,只有获取到了对象上的监视器锁的线程才可以正常调用 notify,前面我们也说过,调用 wait 方法的时候也要先获取锁
如果 n 大于 0,而且这是一个 notify 操作,如果 m 的等待集合不为空,那么等待集合中的线程 u 被选中从等待集合中移出。
对于哪个线程会被选中而被移出,虚拟机没有提供任何保证,从等待集合中将线程 u 移出,可以让线程 u 得以恢复。注意,恢复之后的线程 u 如果对 m 进行加锁操作将不会成功,直到线程 t 完全释放锁之后。
因为线程 t 这个时候还持有 m 的锁。这个知识点在 17.2.4 节我还会重点说。这里记住,被 notify 的线程在唤醒后是需要重新获取监视器锁的。
如果 n 大于 0,而且这是一个 notifyAll 操作,那么等待集合中的所有线程都将从等待集合中移出,然后恢复。
注意,这些线程恢复后,只有一个线程可以锁住监视器。
本小节结束,通知操作相对来说还是很简单的吧。
17.2.3 中断(Interruptions)
中断发生于 Thread.interrupt 方法的调用。
令线程 t 调用线程 u 上的方法 u.interrupt(),其中 t 和 u 可以是同一个线程,这个操作会将 u 的中断状态设置为 true。
顺便说说中断状态吧,初学者肯定以为 thread.interrupt() 方法是用来暂停线程的,主要是和它对应中文翻译的“中断”有关。中断在并发中是常用的手段,请大家一定好好掌握。可以将中断理解为线程的状态,它的特殊之处在于设置了中断状态为 true 后,这几个方法会感知到:
wait(), wait(long), wait(long, int), join(), join(long), join(long, int), sleep(long), sleep(long, int)这些方法都有一个共同之处,方法签名上都有throws InterruptedException,这个就是用来响应中断状态修改的。
如果线程阻塞在 InterruptibleChannel 类的 IO 操作中,那么这个 channel 会被关闭。
如果线程阻塞在一个 Selector 中,那么 select 方法会立即返回。
如果线程阻塞在以上3种情况中,那么当线程感知到中断状态后(此线程的 interrupt() 方法被调用),会将中断状态重新设置为 false,然后执行相应的操作(通常就是跳到 catch 异常处)。
如果不是以上3种情况,那么,线程的 interrupt() 方法被调用,会将线程的中断状态设置为 true。
当然,除了这几个方法,我知道的是 LockSupport 中的 park 方法也能自动感知到线程被中断,当然,它不会重置中断状态为 false。我们说了,只有上面的几种情况会在感知到中断后先重置中断状态为 false,然后再继续执行。
另外,如果有一个对象 m,而且线程 u 此时在 m 的等待集合中,那么 u 将会从 m 的等待集合中移出。这会让 u 从 wait 操作中恢复过来,u 此时需要获取 m 的监视器锁,获取完锁以后,发现线程 u 处于中断状态,此时会抛出 InterruptedException 异常。
这里的流程:t 设置 u 的中断状态 => u 线程恢复 => u 获取 m 的监视器锁 => 获取锁以后,抛出 InterruptedException 异常。
这个流程在前面 wait 的小节已经讲过了,这也是很多人都不了解的知识点。如果还不懂,可以看下一小节的结束,我的两个简单的例子。
一个小细节:u 被中断,wait 方法返回,并不会立即抛出 InterruptedException 异常,而是在重新获取监视器锁之后才会抛出异常。
实例方法 thread.isInterrupted() 可以知道线程的中断状态。
调用静态方法 Thread.interrupted() 可以返回当前线程的中断状态,同时将中断状态设置为false。
所以说,如果是这个方法调用两次,那么第二次一定会返回 false,因为第一次会重置状态。当然了,前提是两次调用的中间没有发生设置线程中断状态的其他语句。
17.2.4 等待、通知和中断的交互(Interactions of Waits, Notification, and Interruption)
以上的一系列规范能让我们确定 在等待、通知、中断的交互中 有关的几个属性。
如果一个线程在等待期间,同时发生了通知和中断,它将发生:
从 wait 方法中正常返回,同时不改变中断状态(也就是说,调用 Thread.interrupted 方法将会返回 true)
由于抛出了 InterruptedException 异常而从 wait 方法中返回,中断状态设置为 false
线程可能没有重置它的中断状态,同时从 wait 方法中正常返回,即第一种情况。
也就是说,线程是从 notify 被唤醒的,由于发生了中断,所以中断状态为 true
同样的,通知也不能由于中断而丢失。
这个要说的是,线程其实是从中断唤醒的,那么线程醒过来,同时中断状态会被重置为 false。
假设 m 的等待集合为 线程集合 s,并且在另一个线程中调用了 m.notify(), 那么将发生:
至少有集合 s 中的一个线程正常从 wait 方法返回,或者
集合 s 中的所有线程由抛出 InterruptedException 异常而返回。
考虑是否有这个场景:x 被设置了中断状态,notify 选中了集合中的线程 x,那么这次 notify 将唤醒线程 x,其他线程(我们假设还有其他线程在等待)不会有变化。
答案:存在这种场景。因为这种场景是满足上述条件的,而且此时 x 的中断状态是 true。
注意,如果一个线程同时被中断和通知唤醒,同时这个线程通过抛出 InterruptedException 异常从 wait 中返回,那么等待集合中的某个其他线程一定会被通知。
下面我们通过 3 个例子简单分析下 wait、notify、中断 它们的组合使用。
第一个例子展示了 wait 和 notify 操作过程中的监视器锁的 持有、释放 的问题。考虑以下操作:
public class WaitNotify { public static void main(String[] args) { Object object = new Object(); new Thread(new Runnable() { @Override public void run() { synchronized (object) { System.out.println("线程1 获取到监视器锁"); try { object.wait(); System.out.println("线程1 恢复啦。我为什么这么久才恢复,因为notify方法虽然早就发生了,可是我还要获取锁才能继续执行。"); } catch (InterruptedException e) { System.out.println("线程1 wait方法抛出了InterruptedException异常"); } } } }, "线程1").start(); new Thread(new Runnable() { @Override public void run() { synchronized (object) { System.out.println("线程2 拿到了监视器锁。为什么呢,因为线程1 在 wait 方法的时候会自动释放锁"); System.out.println("线程2 执行 notify 操作"); object.notify(); System.out.println("线程2 执行完了 notify,先休息3秒再说。"); try { Thread.sleep(3000); System.out.println("线程2 休息完啦。注意了,调sleep方法和wait方法不一样,不会释放监视器锁"); } catch (InterruptedException e) { } System.out.println("线程2 休息够了,结束操作"); } } }, "线程2").start(); } } output: 线程1 获取到监视器锁 线程2 拿到了监视器锁。为什么呢,因为线程1 在 wait 方法的时候会自动释放锁 线程2 执行 notify 操作 线程2 执行完了 notify,先休息3秒再说。 线程2 休息完啦。注意了,调sleep方法和wait方法不一样,不会释放监视器锁 线程2 休息够了,结束操作 线程1 恢复啦。我为什么这么久才恢复,因为notify方法虽然早就发生了,可是我还要获取锁才能继续执行。
上面的例子展示了,wait 方法返回后,需要重新获取监视器锁,才可以继续往下执行。
同理,我们稍微修改下以上的程序,看下中断和 wait 之间的交互:
public class WaitNotify { public static void main(String[] args) { Object object = new Object(); Thread thread1 = new Thread(new Runnable() { @Override public void run() { synchronized (object) { System.out.println("线程1 获取到监视器锁"); try { object.wait(); System.out.println("线程1 恢复啦。我为什么这么久才恢复,因为notify方法虽然早就发生了,可是我还要获取锁才能继续执行。"); } catch (InterruptedException e) { System.out.println("线程1 wait方法抛出了InterruptedException异常,即使是异常,我也是要获取到监视器锁了才会抛出"); } } } }, "线程1"); thread1.start(); new Thread(new Runnable() { @Override public void run() { synchronized (object) { System.out.println("线程2 拿到了监视器锁。为什么呢,因为线程1 在 wait 方法的时候会自动释放锁"); System.out.println("线程2 设置线程1 中断"); thread1.interrupt(); System.out.println("线程2 执行完了 中断,先休息3秒再说。"); try { Thread.sleep(3000); System.out.println("线程2 休息完啦。注意了,调sleep方法和wait方法不一样,不会释放监视器锁"); } catch (InterruptedException e) { } System.out.println("线程2 休息够了,结束操作"); } } }, "线程2").start(); } } output: 线程1 获取到监视器锁 线程2 拿到了监视器锁。为什么呢,因为线程1 在 wait 方法的时候会自动释放锁 线程2 设置线程1 中断 线程2 执行完了 中断,先休息3秒再说。 线程2 休息完啦。注意了,调sleep方法和wait方法不一样,不会释放监视器锁 线程2 休息够了,结束操作 线程1 wait方法抛出了InterruptedException异常,即使是异常,我也是要获取到监视器锁了才会抛出
上面的这个例子也很清楚,如果线程调用 wait 方法,当此线程被中断的时候,wait 方法会返回,然后重新获取监视器锁,然后抛出InterruptedException 异常。
我们再来考虑下,之前说的 notify 和中断:
package com.javadoop.learning; /** * Created by hongjie on 2017/7/7. */ public class WaitNotify { volatile int a = 0; public static void main(String[] args) { Object object = new Object(); WaitNotify waitNotify = new WaitNotify(); Thread thread1 = new Thread(new Runnable() { @Override public void run() { synchronized (object) { System.out.println("线程1 获取到监视器锁"); try { object.wait(); System.out.println("线程1 正常恢复啦。"); } catch (InterruptedException e) { System.out.println("线程1 wait方法抛出了InterruptedException异常"); } } } }, "线程1"); thread1.start(); Thread thread2 = new Thread(new Runnable() { @Override public void run() { synchronized (object) { System.out.println("线程2 获取到监视器锁"); try { object.wait(); System.out.println("线程2 正常恢复啦。"); } catch (InterruptedException e) { System.out.println("线程2 wait方法抛出了InterruptedException异常"); } } } }, "线程2"); thread2.start(); // 这里让 thread1 和 thread2 先起来,然后再起后面的 thread3 try { Thread.sleep(1000); } catch (InterruptedException e) { } new Thread(new Runnable() { @Override public void run() { synchronized (object) { System.out.println("线程3 拿到了监视器锁。"); System.out.println("线程3 设置线程1中断"); thread1.interrupt(); // 1 waitNotify.a = 1; // 这行是为了禁止上下的两行中断和notify代码重排序 System.out.println("线程3 调用notify"); object.notify(); //2 System.out.println("线程3 调用完notify后,休息一会"); try { Thread.sleep(3000); } catch (InterruptedException e) { } System.out.println("线程3 休息够了,结束同步代码块"); } } }, "线程3").start(); } } // 最常见的output: 线程1 获取到监视器锁 线程2 获取到监视器锁 线程3 拿到了监视器锁。 线程3 设置线程1中断 线程3 调用notify 线程3 调用完notify后,休息一会 线程3 休息够了,结束同步代码块 线程2 正常恢复啦。 线程1 wait方法抛出了InterruptedException异常
上述输出不是绝对的,有可能发生 线程1 是正常恢复的,虽然发生了中断,它的中断状态也确实是 true,但是它没有抛出 InterruptedException,而是正常返回。此时,thread2 将得不到唤醒,一直 wait。
17.3. 休眠和礼让(Sleep and Yield)
Thread.sleep(millisecs) 使当前正在执行的线程休眠指定的一段时间(暂时停止执行任何指令),时间取决于参数值,精度受制于系统的定时器。休眠期间,线程不会释放任何的监视器锁。线程的恢复取决于定时器和处理器的可用性,即有可用的处理器来唤醒线程。
需要注意的是,Thread.sleep 和 Thread.yield 都不具有同步的语义。在 Thread.sleep 和 Thread.yield 方法调用之前,不要求虚拟机将寄存器中的缓存刷出到共享内存中,同时也不要求虚拟机在这两个方法调用之后,重新从共享内存中读取数据到缓存。
例如,我们有如下代码块,this.done 定义为一个 non-volatile 的属性,初始值为 false。
while (!this.done) Thread.sleep(1000);
编译器可以只读取一次 this.done 到缓存中,然后一直使用缓存中的值,也就是说,这个循环可能永远不会结束,即使是有其他线程将 this.done 的值修改为 true。
yield 是告诉操作系统的调度器:我的cpu可以先让给其他线程。注意,调度器可以不理会这个信息。
这个方法太鸡肋,几乎没用。
17.4 内存模型(Memory Model)
内存模型这一节比较长,请耐心阅读
内存模型描述的是程序在 JVM 的执行过程中对数据的读写是否是按照程序的规则正确执行的。Java 内存模型定义了一系列规则,这些规则定义了对共享内存的写操作对于读操作的可见性。
简单地说,定义内存模型,主要就是为了规范多线程程序中修改或者访问同一个值的时候的行为。对于那些本身就是线程安全的问题,这里不做讨论。
内存模型描述了程序执行时的可能的表现行为。只要执行的结果是满足 java 内存模型的所有规则,那么虚拟机对于具体的实现可以自由发挥。
从侧面说,不管虚拟机的实现是怎么样的,多线程程序的执行结果都应该是可预测的。
虚拟机实现者可以自由地执行大量的代码转换,包括重排序操作和删除一些不必要的同步。
这里我画了一条线,从这条线到下一条线之间是两个重排序的例子,如果你没接触过,可以看一下,如果你已经熟悉了或者在其他地方看过了,请直接往下滑。
示例 17.4-1 不正确的同步可能导致奇怪的结果
java语言允许 compilers 和 CPU 对执行指令进行重排序,导致我们会经常看到似是而非的现象。
这里没有翻译 compiler 为编译器,因为它不仅仅代表编译器,后续它会代表所有会导致指令重排序的机制。
如表 17.4-A 中所示,A 和 B 是共享属性,r1 和 r2 是局部变量。初始时,令 A == B == 0。
表17.4-A. 重排序导致奇怪的结果 - 原始代码
按照我们的直觉来说,r2 == 2 同时 r1 == 1 应该是不可能的。直观地说,指令 1 和 3 应该是最先执行的。如果指令 1 最先执行,那么它应该不会看到指令 4 对 A 的写入操作。如果指令 3 最先执行,那么它应该不会看到执行 2 对 B 的写入操作。
如果真的表现出了 r2==2 和 r1==1,那么我们应该知道,指令 4 先于指令 1 执行了。
如果在执行过程出表现出这种行为( r2==2 和r1==1),那么我们可以推断出以下指令依次执行:指令 4 => 指令 1=> 指令 2 => 指令 3。看上去,这种顺序是荒谬的。
但是,Java 是允许 compilers 对指令进行重排序的,只要保证在单线程的情况下,能保证程序是按照我们想要的结果进行执行,即 compilers 可以对单线程内不产生数据依赖的语句之间进行重排序。如果指令 1 和指令 2 发生了重排序,如按照表17.4-B 所示的顺序进行执行,那么我们就很容易看到,r2==2 和 r1==1 是可能发生的。
表 17.4-B. 重排序导致奇怪的结果 - 允许的编译器转换
B = 1; => r1 = B; => A = 2; => r2 = A;
对于很多程序员来说,这个结果看上去是 broken 的,但是这段代码是没有正确的同步导致的:
其中有一个线程执行了写操作
另一个线程对同一个属性执行了读操作
同时,读操作和写操作没有使用同步来确定它们之间的执行顺序
简单地说,之后要讲的一大堆东西主要就是为了确定共享内存读写的执行顺序,不正确或者说非法的代码就是因为读写同一内存地址没有使用同步(这里不仅仅只是说synchronized),从而导致执行的结果具有不确定性。
这个是 数据竞争(data race) 的一个例子。当代码包含数据竞争时,经常会发生违反我们直觉的结果。
有几个机制会导致表 17.4-B 中的指令重排序。java 的 JIT 编译器实现可能会重排序代码,或者处理器也会做重排序操作。此外,java 虚拟机实现中的内存层次结构也会使代码像重排序一样。在本章中,我们将所有这些会导致代码重排序的东西统称为 compiler。
所以,后续我们不要再简单地将 compiler 翻译为编译器,不要狭隘地理解为 Java 编译器。而是代表了所有可能会制造重排序的机制,包括 JVM 优化、CPU 优化等。
另一个可能产生奇怪的结果的示例如表 17.4-C,初始时 p == q 同时 p.x == 0。这个代码也是没有正确使用同步的;在这些写入共享内存的写操作中,没有进行强制的先后排序。
Table 17.4-C
一个简单的编译器优化操作是会复用 r2 的结果给 r5,因为它们都是读取 r1.x,而且在单线程语义中,r2 到 r5之间没有其他的相关的写入操作,这种情况如表 17.4-D 所示。