CH01-JMM规范

The Java ® Language Specification SE 8 - Chapter 17 Threads and Locks

Java 内存模型定义了线程之间如何通过内存进行交互,是深入学习 Java 并发编程的必要前提。

Java 虚拟机可以支持多线程同时执行。这些线程可以独立的对驻留在内存中的值和对象执行操作代码。对这些线程的支持,可以是多硬件处理器,也可以是单硬件处理器的时间片机制,或者是多硬件处理器的时间片机制。

线程由 Thread 类表示。对用户来说,创建线程的唯一方式就是创建该类的对象,每个线程都和一个这样的对象关联。在 Thread 类的对象上调用 start 方法将会启动对应的线程。

在进行不正确的同步操作时,可能会引起线程行为的混淆与反常。本章描述的是多线程的语义,其中包含以下规则:若主存(内存)是由多个线程更新的,那么对主存的读操作可以看到哪些值。 因为这些规范与针对不同硬件架构的“内存模型”类似,因此也被称为“Java 编程语言内存模型”。当不会产生任何混淆时,我们将直接称这些规则为“内存模型”。

这些语义并未规定多线程程序应该如何执行,它们描述的是多线程程序允许展示出来的行为。

同步

Java 语言为线程间通信提供了多种机制,这些方法中最简单的就是“同步(synchronization)”,它是使用“监视器(monitor)”实现的。Java 中每个对象都与一个可以被线程锁定或解锁的监视器相关联。在任何时刻,只有一个线程可以持有某个监视器上的锁。任何其他试图锁定该监视器的线程都将被阻塞,直至它们可以获得该监视器上的锁。一个线程可以多次锁定某个指定的监视器,每个解锁操作都会抵消前一次锁定。

synchronized 语句计算的是对象的引用,然后试图锁定该对象的监视器,并且在锁定动作完成之前,不会执行下一个动作。在锁定动作执行之后,synchronized 语句体被执行。如果该语句体执行结束,无论是正常结束还是猝然结束,都会在之前锁定的监视器上执行解锁操作。

synchronized 方法在被调用时会自动执行锁定动作,它的方法体在该锁定动作完成之前是不会被执行的。如果该方法是实例方法,那么会锁定调用该实例方法的对象所关联的监视器。如果该方法是静态方法,那么它会锁定定义该方法的类的 Class 对象相关联的监视器。如果该方法体执行结束,无论是正常结束还是猝然结束,都会在之前锁定的监视器上执行解锁操作。

Java 编程语言既不阻止也不要求对死锁情况的探测。线程(间接或直接)持有多个对象上的锁的的程序应该使用避免死锁的惯用技术,如果必要的话,可以创建更高级别的不会产生死锁的锁定原语。

其他机制,如 volatile 变量的读写和对 JUC 包中的类的使用,都提供了可替代的同步方式。

等待集合通知

每个对象除了拥有关联的监视器,还拥有关联的“等待集”,即一个线程集。

当对象最先被创建时,它的等待集为空。向等待集中添加或移除线程的基础动作都是原子性的。等待集只能通过 Object.waitObject.notifyObject.notifyAll 方法进行操作。

等待集的操作还会受到线程的中断和 Thread 类中用于处理中断的方法的影响。另外,Thread 类中用于睡眠和连接其他线程的方法也具有从等待和通知动作中导出的属性。

等待

调用 wait() 时,或者调用具有定时机制的 wait(long millisecs)wait(long millisecs, int nanosecs) 时会发生“等待动作”。

向带有定时机制的 wait 方法传入参数值 0 等同于调用没有定时机制的 wait 方法。

如果线程返回时没有抛出 InterruptedException 异常,那么该线程就是正常返回的。

设线程 t 在对象 m 上执行 wait 方法,n 是线程 t 在对象 m 上尚未解锁的锁定动作的数量,那么将会发生下列动作之一:

  • 如果 n 是 0(即线程 t 还没有锁定目标 m),那么会抛出 IllegalMonitorStateException。
  • 如果是定时的等待,并且 nanosecs 的范围不在 0~999999 范围内,或者 millisecs 是负数,那么将会抛出 IllegalArgumentException。
  • 如果线程 t 被中断,那么会抛出 InterruptedException,并且 t 的中断状态会被设为 false。
  • 除此之外,会执行下面的序列:
    1. 线程 t 被添加到对象 m 的等待集,并且在 m 上执行 n 个解锁动作。
    2. 线程 t 不执行任何更进一步的指令,直到它从 m 的等待集中移除。线程 t 可以因下列任何一个动作而从等待集中移除,并在之后某个时刻继续执行:
    • 在 m 上执行 notify 动作,在该动作中 t 被选中并从等待集中移除。
    • 在 m 上执行 notifyAll 动作。
    • 在 t 上执行 interrupt 动作。
    • 如果是定时等待,那么在从该等到动作开始至少 millisecs 毫秒加上 nanosecs 纳秒的时间流逝之后,一个内部动作将 t 从 m 的等待集中移除。
    • 内部动作由 Java 编程语言的实现执行。我们允许但不鼓励 Java 编程语言的实现执行的是“欺骗性唤醒”,即将线程从等待集中移除,由此无需显式指令就可以使得线程能够继续执行。
    1. 线程 t 在 m 上执行 n 个锁定操作。
    2. 如果线程 t 在第 2 步因为竞争而从 m 的等待集中被移除,那么 t 的中断状态会被设置为 false,并且 wait 方法会抛出中断异常。

@@@ note

第 2 条款迫使开发者必须循序以下 Java 编码习惯:对于在线程等待某个逻辑条件满足时才会终止的循环,才适合使用 wait。

@@@

每个线程必须确定可以引发从等待集中被移除的事件顺序。该顺序不必与其他排序方式一致,但是线程的行为必须是看起来就像这些事件是按照这个顺序发生的一样。

例如,如果线程 t 在 m 的等待集中,并且 t 的中断和 m 的通知都发生了,那么这些事件必然有一个顺序。如果中断被认为首先发生,那么 t 最终会抛出中断异常而从 wait 返回,并且位于 m 的等待集的另一个线程(如果在发通知时存在的话)必须收到这个通知;如果通知被认为首先发生,那么 t 最终会从 wait 中正常返回,而中断将被悬挂。

通知

调用 notify 或 notifyAll 时发生通知动作。

设线程 t 在对象 m 上执行这两个方法,n 是线程 t 在对象 m 上尚未解锁的锁定动作的数量,那么将会发生下列动作之一:

  • 如果 n = 0,将会抛出非法监视器状态异常。这种情况表示线程 t 还没有处理目标 m 的锁。
  • 如果 n > 0,并且是 notify 动作,那么如果 m 的等待集不为空,那么作为 m 的当前等待集中的成员线程 u 将被选中并从等待集中移除。不能保证等待集中哪个线程会被选中。从等待集中移除使得 u 可以在等待动作中继续。但是,需要注意 u 在继续执行时的加锁动作只有在 t 完全解锁 m 的监视器之后的某个时刻才能成功。
  • 如果 n > 0,并且是 notifyAll 动作,那么所有线程都会从 m 的等待集中移除,因此也就都可以继续执行。

但是需要注意,其中每次仅有一个线程可以在等待过程中锁定所需的监视器。

中断

调用 Thread.interrupt 或 ThreadGoup.interrupt 方法时,发生中断动作。

设 t 是调用 u.interrupt 的线程,其中 u 是某个线程,t 和 u 可以相同。该调用动作会使得 u 的中断状态被设置为 true。

另外,如果存在某个对象 m,其等待集合包含 u,那么 u 会从 m 的等待集合中移除。这使得 u 从等待动作中恢复,在这种情况下,这个等待在重新锁定 m 的监视器之后,会抛出中断异常。

调用 Thread.isInterrupted 可以确定线程的中断状态。静态方法 Thread.interrupted 可以被线程调用以观察和清除自身的中断状态。

等待、通知、中断之间的交互

如果线程在等待时被通知了然后又被中断,那么它可以:

  • 从 wait 中正常返回,尽管仍然具有悬挂的中断。
  • 从 wait 中抛出中断异常。

线程不可以重置它的中断状态并从对 wait 的调用中返回。类似的,通知不能因中断而丢失。假设线程集 s 在对象 m 的等待集中,另一个线程在 m 上执行另一个 notify,那么:

  • s 中至少有一个线程必须从 wait 中正常返回。
  • s 中所有线程都必须抛出中断异常并退出 wait。

@@@ note

如果一个线程被 notify 中断和唤醒,并且该线程以抛出中断异常的方式从 wait 返回,那么在等待集中的其他线程必须必通知。

@@@

睡眠与让步

Thread.sleep 会导致当前运行的线程睡眠(暂时中止执行)指定的一段时间,具体时间取决于系统定时器和调度器的精确度。睡眠的线程不会丧失对任何监视器的所有权,而继续执行的时机则依赖于执行该线程的处理器的调度时机和可用性。

注意到这一点很重要:无论是 Thread.sleep 还是 Thread.yield 都没有任何同步语义。特别是,编译器不必在调用 Thread.sleep 或 Thread.yield 之前将寄存器中缓存的写操作冲刷到共享内存中,也不必在调用 Thread.sleep 或 Thread.yield 之后重新加载寄存器中缓存的值。

例如在下面的代码中,假设 this.done 是非 volatile 的 boolean 域:

while(!this.done)
  Thread.sleep(1000);

编译器可以只读取 this.done 一次,并且在循环中的每次迭代中重用缓存的值。这意味着即使另一个线程修改了 this.done 的值,该循环永远也不会停止。

内存模型

给定一个程序和该程序的执行轨迹,“内存模型”可以描述该执行轨迹是否是该程序的一次合法执行。Java 编程语言的内存模型是通过以下方式实现的:查验执行轨迹中的每个读操作,并依据特定的规则检查该读操作所观察到的写操作是否有效。

内存模型描述了程序的潜在行为。Java 语言的实现可以按照其喜好来产生任何代码,只要程序的执行过程都会产生内存模型可以预测的结果。

这为 Java 语言的实现者提供了很大的自由度去执行大量的代码转换,包括重排序动作和移除不必要的同步。

示例:不正确的同步程序会展示惊人的行为

Java 编程语言的语义允许编译器和微处理器执行优化,与未正确同步的代码进行交互,而这种交互方式可能会产生看起来很荒谬的行为。下面的几个示例展示了未正确同步的程序可能会展示出惊人的行为。

例如,考虑下表中展示的样例程序轨迹。该程序使用了局部变量 r1 和 r2、共享变量 A 和 B。最初 A==B==0:

Thread 1Thread 2
1: r2 = A;3: r1 = B;
2: B =1;4: A = 2;

看起来好像不可能产生 r2==2 和 r1==1 这样的结果。直觉上,在某次执行中,要么是指令 1,要么是指令 3 先到。如果是指令 1 先到,那么它应该看不到指令 4 的写操作。如果指令 3 先到,那么它应该看不到指令 2 的写操作。

如果某次执行确实展示了这种行为,即产生 r2==2 和 r1==1 这样的结果,那么我们就知道指令的顺序是 4、1、2、3,这表面上看起来很荒谬。

但是,编译器可以对其中一个线程的指令进行重排序,只要重排序不会影响该线程单独执行时的效果即可。如果指令 1、2 进行重排序,就像下表中展示的轨迹顺序,那么就很容易看到 r2==2 和 r1==1 这样的结果。

Thread 1Thread 2
1: B = A;3: r1 = B;
2: r2 =A;4: A = 2;

对有些开发者而言,这种行为看起来像是“受损了”。但是应该注意到,这种代码实际上只是没有进行正确的同步:

  • 在一个线程中存在对一个变量的写操作。
  • 在另一个线程中存在对同一个变量的读操作。
  • 而写操作和读操作并未通过同步进行排序。

这种情况是“数据竞争”的一个实例。当代码包含数据竞争时,经常会产生有悖直觉的结果。

很多机制都可以产生上述重排序。Java 虚拟机实现中的即时编译器(JIT)可以重新安排代码或处理器。另外,对于 Java 虚拟机的实现而言,其架构的内存层次结构使得代码看起来就像是被重排序过一样。在本章中,我们将任何可以重排序代码的事物都归类为“编译器”。

另一个会出现惊人结果的例子可以在下表中看到。最初,p==q 且 p.x==0。该程序也未进行正确的同步,它对其中的写操作没有进行任何强制排序就向共享内存执行了写操作。

Thread 1Thread 2
1: r1 = P;1: r6 = p;
2: r2 = r1.x;2: r6.x =
3: r3 = q;
4: r4 = r3.x;
4: r5 = r1.x;

一项常见的编译器优化是,在对 r5 执行读操作时,复用了对 r2 执行读操作后所获得的值,因为它们都是在没有任何具有干扰效果的写操作时对 r1.x 的读操作。

现在请考虑这样的情况:在 Thread 2 中对 r6.x 的赋值发生在 Thread 1 中对 r1.x 的第一次读操作和对 r3.x 的读操作之间。如果编译器决定对 r5 重用 r2 的值,那么 r2 和 r5 的值就都是 0,而 r4 的值将是 3。从开发者的角度看,在 p.x 中存储的值从 0 变成了 3,之后又变回去了。

Thread 1Thread 2
1: r1 = P;1: r6 = p;
2: r2 = r1.x;2: r6.x = 3;
3: r3 = q;
4: r4 = r3.x;
4: r5 = r1.x;

线程内语义

内存模型可以确定程序中的每个点可以读取什么值。每个线程单独的动作必须由该线程的语义来管制其行为,但是每个读取操作看到的值是由内存模型决定的。当我们提到这一点时,就称程序遵循“线程内语义”。线程内语义是用于单线程程序的语义,并且允许对线程行为进行完整的预测,而该行为是基于该线程内的读动作所能看到的值的。为了确定在程序执行中线程 t 的动作是否是合法的,我们可以直接计算线程 t 的实现,因为它将在单线程上下文中执行,就像在本规范其他部分中定义的那样。

每当线程 t 的计算会生成线程间动作时,它必须匹配为在程序顺序中紧接着到来的 t 的线程间动作 a。如果 a 是读操作,那么 t 的进一步计算将使用由内存模型确定的 a 所看到的值。

本节将提供 Java 编程语言内存模型的规范,但是不包括处理 final 域的话题,它们将在下一节进行描述。

这里描述的内存模型不基于 Java 编程语言的面向对象特性。为了保持例子的简洁性和简单性,我们经常展示的是没有类或方法定义或显式引用的代码片段。大多数例子都包括两个或多个线程,它们包含对对象的局部变量、共享全局变量、实例域的访问语句。典型情况是,我们将使用 r1 和 r2 这样的变量名来表示方法或线程的局部变量。这种变量对其他线程是不可访问的。

共享变量

可以在线程间共享的内存被称为“共享内存”或“堆内存”。所有实例域、静态域、数组元素都存储在堆内存。在本章,我们将使用“变量”来指代这些域或数组元素。

局部变量、形式方法参数、异常处理参数永远都不会在线程间共享,因此也就不受内存模型的影响。

如果至少有一个访问是写操作,两个对相同变量的访问(读或写)被称为是“冲突的”。

动作

“线程间动作”是指一个线程执行的动作可以被另一个线程探测到或者直接受另一个线程影响。程序可以执行的线程间动作有如下几种:

  • 读(正常读或对非 volatile 的读):对变量的读。
  • 写(正常写或对非 volatile 的写):对变量的写。
  • 同步动作,包括:
    • volatile 读:对变量的 volatile 读。
    • volatile 写:对变量的 volatile 写。
    • 锁定:锁定监视器。
    • 解锁:解锁监视器。
    • 线程(合成)的第一个和最后一个动作。
    • 启动线程或探测线程是否已被终止的动作。
  • 外部动作:可以从“执行外部”观察到的动作,其结果基于“执行外部”的环境。
  • 线程分岔动作:它只能由在不执行任何内存、同步或外部动作的无限循环中的线程执行。如果一个线程执行了线程分岔动作,那么它后续会跟随无限数量的线程分岔动作。
    • 引入线程分岔动作是为了对一个线程可能会如何导致所有其他线程停顿或不能有所进展的情况进行建模。

本规范只关注线程间的动作,我们不需要关心线程内的工作(如将两个局部变量加起来存储到第三个局部变量中)。如前所述,所有线程都需要遵守正确的 Java 程序线程内语义。我们通常将线程间动作更简洁的称为“动作”。

一个动作 a 由元组 <t, k, v, u> 构成,其中:

  • t 是执行动作的线程。
  • k 是动作的种类。
  • v 是动作涉及的变量和监视器。
    • 对于锁定动作,v 是被锁定的监视器;对于解锁动作,v 是被解锁的监视器。
    • 如果动作是(volatile 或非 volatile)的读,那么 v 就是被读取的变量。
    • 如果动作是(volatile 或非 volatile)的写,那么 v 就是被写入的变量。
  • u 是该动作的任意唯一的标识符。

外部动作元组还包含额外的组成部分,它包含执行该动作的线程可以感知到的该外部动作的结果,可以是表示该动作成功或失败的信息,以及该动作读取的值。

外部动作的参数(如哪些字节要写到哪些 socket)不是外部动作元组的组成部分。这些参数由线程内的其他动作设置,并且可以通过检查线程内语义而确定。它们在内存模型中没有专门的讨论。

在不终止的执行中,不是所有的外部动作都是可观察的。

程序与程序顺序

在每个线程 t 执行的所有线程动作中,t 的程序顺序是一种全序,反映了这些动作按照 t 的线程内语义执行的顺序。

动作集是连续一致的,如果其所有动作都按照与程序顺序一致的全序(执行顺序)发生,并且每个对变量 v 的读操作 r 都可以看到由对 v 的写操作 w 写入的值,使得:

  • w 在执行顺序中在 r 之前到来。
  • 没有任何其他写操作 w’ 使得在执行顺序中 w 在 w’ 之前到来、而且 w’ 在 r 之前到来。

连续一致性是对程序执行中的可见性和排序做出的非常强的保障。在连续一致的执行中,在所有单独的动作(如读操作和写操作)之上存在全序(total order),它与程序的顺序一致,并且每个单独的动作都是原子性的,且对每个线程都是立即可见的。

如果程序中没有任何数据竞争,那么程序的所有执行看起来都是连续一致的。

对于由“需要被原子性的感知”和“不需要被原子性的感知”的操作构成的组,连续一致性和不存在的数据竞争仍旧不能保证这样的组中不会产生错误。

@@@ note

如果我们要使用连续一致性作为我们的内存模型,那么我们讨论过的编译器和处理器的很多优化都是非法的。

@@@

同步顺序

每次执行都有一个同步顺序。同步顺序是执行中所有同步动作之上的全序。对于每个线程 t,t 中的同步动作的同步顺序与 t 的程序顺序是一致的。

同步动作可以归纳出动作上的“被同步”关系,定义如下:

  • 在监视器 m 上的解锁动作会同步所有后续的在 m 上的锁定动作(其中“后续”是按照同步顺序定义的)。
  • 对于 volatile 变量 v 的写操作会同步所有后续由任何线程执行的对 v 的读操作(其中“后续”是按照同步顺序定义的)。
  • 启动线程的动作会同步它所启动的线程中的第一个动作。
  • 对每个变量写入缺省值(0、false、null)的操作会同步每个线程中的第一个动作。
    • 尽管看起来有点怪,在给包含变量的对象分配内存之前就对该变量写入了缺省值,但是在概念上,每个对象是在程序开始时使用缺省的初始化值创建的。
  • 线程 T1 中的最后一个动作会与探测到 T1 已经终止的另一个线程 T2 中的任何动作同步。
    • T2 可以通过调用 T1.isAlive 或 T1.join 来实现这种探测。
  • 如果线程 T1 中断了线程 T2,那么对于代码中的任何点,只要其他任何线程(包含 T2)能够确定 T2 已经被中断(可以通过抛出中断异常、调用 Thread.interrupted、Thread.isInterrupted 方法来实现),那么 T1 执行的中断动作就会在这些点进行同步。

在表示同步关系的边中,源头称为释放,目的地称为获取。

Happens-Before 顺序

两个动作可以通过 “Happens-Before” 关系进行排序。如果一个动作在另一个动作之前发生,那么第一个动作对第二个动作就是可见的,并且排在第二个动作之前。

如果我们有两个动作 x 和 y,那么我们写作 hb(x,y) 来表示 x 在 y 之前发生。

  • 如果 x 和 y 是同一个线程的动作,并且按照程序顺序 x 在 y 之前到来,那么 hb(x,y)。
  • 从对象的构造器的末尾到该对象的终结器的开头,存在一条表示 “Happens-Before” 的边。
  • 如果动作 x 会同步接下来的动作 y,那么我们也可以得出 hb(x,y)。
  • 如果 hb(x,y) 且 hb(y,z),那么 hb(x,z)。

Object 类的 wait 方法与其相关联的锁定和解锁动作,它们之间的 “Happens-Before” 关系是由这些关联的动作定义的。

@@@ note

应该注意,两个动作之间存在 “Happens-Before” 关系并不意味着在代码实现中它们必须按照该顺序发生。如果重排序产生的结果与合法的执行是一致的,那么它就并不是非法的。

@@@

例如,对由某线程构造的对象的每个域写入其缺省值的操作,就不必非要在该线程的开始之前发生,只要没有任何读操作可以观察到这个事实即可。

更具体的说,如果两个动作共享 “Happens-Before” 关系,那么对于不和它们共享 “Happens-Before” 关系的任何代码来说,它们不必看起来非要是以该顺序发生的。例如,如果在一个线程中的写操作会与在另一个线程中的读操作产生数据竞争,那么这些写操作对那些读操作来说可以看起来像是乱序发生的。

在数据竞争发生时,需要定义 “Happens-Before” 关系。

由同步的边组成集合 S 是充分的,如果他是一个最小集,使得带有程序顺序的 S 的传递闭包可以确定在执行中的所有 “Happens-Before” 的边。这个集是唯一的。

由上面的定义可以得出下面的内容:

  • 在监视器上的解锁动作在每个后续在该监视器上的锁定操作之前发生。
  • 对 volatile 域的写操作在每个后续对该域的读操作之前发生。
  • 在线程上对 start() 的调用在被启动线程的所有动作之前发生。
  • 一个线程中的所有动作在任何其线程成功的从该线程上的 join 发生之前发生。
  • 任何对象的缺省值初始化在程序中的其他任何动作(除了缺省的写操作)之前发生。

如果一个程序包含了两个互相冲突且没有 “Happens-Before” 排序关系的访问操作,那么就称该程序包含“数据竞争”。

对于不是线程间动作的操作,例如对数组长度的读操作、受检强制类型转换的执行和对虚拟方法的调用,其语义不受数据竞争的直接影响。

  • 因此,数据竞争不能引发不正确的行为,例如返回错误的数组长度。
  • 当且仅当所有连续一致的执行都没有数据竞争,程序则是正确同步的。
  • 如果程序是正确同步的,那么该程序的所有执行看起来都是连续一致的。

这对开发者来说是很好的保障。开发者不需要推断重排序方式以确定他们的代码是否包含数据竞争,因此也就不需要在确定他们的代码是否被正确的同步时推断重排序方式。一旦确定了代码是正确同步的,开发者就不需要担心重排序是否会影响他们的代码。

程序必须被正确同步以避免各种在代码重排序时会被观察到的反常行为。正确的同步并不能确保程序的整体行为是正确的,但是它使得开发者可以以简单的方式推断程序可能的行为。对于正确同步的程序,其行为对可能的重排序形成的依赖要少的多。没有正确的同步,就可能会产生非常奇怪的、混乱和反常的行为。

我们称变量 v 的读操作 r 允许观察对 v 的写操作 w,如果在执行轨迹的 “Happens-Before” 的偏序(partial-order)关系中:

  • r 的排序不在 w 之前,即非 hb(r,w)。
  • 中间没有介入任何对 v 的写操作 w’,即没有任何对 v 的写操作 w’ 使得 hb(w,w’) 和 hb(w’,r) 同时成立。

非正式的讲,读操作 r 允许看到写操作 w 结果,如果没有任何 “Happens-Before” 排序会阻止该操作。

如果对该动作集 A 中的每个读操作 r,用 w(r) 表示 r 可以看到的写操作,都不满足 hb(r, w(r)),或 A 中不存在些操作 w 使得 w.v = r.v、hb(w(r), w) 和 hb(w, r) 同时成立,那么动作集 A 具有 “Happens-Before” 一致性。

在具有 “Happens-Before 一致性” 的工作集中,每个读操作看到的写操作都是 “Happens-Before” 排序机制允许看到的写操作。

示例: Happens-Before 一致性

对于下图中的轨迹,初始时 A==B==0。该轨迹可以观察到 r2==0 和 r1==0,并且在 “Happens-Before” 上仍旧保持一致性,因为执行顺序允许每个读操作看到恰当的写操作。

Thread 1Thread 2
1: B = 1;1: A = 2;
2: r2 = A;2: r1 = B;

因为没有任何同步,所有每个读操作都可以看到写入初始值的写操作或由另一线程执行的写操作。下面的执行顺序展示了这种行为:

1: B=1;
2: A=2;
3: r2=A; // seen initial write of 0
4: r1=B; // seen initial write of 0

另一种具有 “Happens-Before 一致性” 的执行顺序为:

1: r2=A; // seen write of A=2
2: r1=B; // seen write of B=1
3: B=1;
4: A=2;

在该执行中,读操作看到的是在执行顺序之后发生的写操作。这看起来很反常,但是是 “Happens-Before一致性” 所允许的。允许读操作看到之后发生的写操作有时可能会产生不可接受的行为。

执行

执行 E 可以用元组 <P, A, po, so, W, V, sw, hb> 表示,其构成为:

  • P:程序。
  • A:动作集。
  • po:程序顺序,对于每个线程 t,是在 A 中由 t 执行的所有动作上的全序。
  • so:同步顺序,即 A 中所有同步动作上的全序。
  • W:“被看到的写动作”函数,对 A 中的每个读操作 r,会给出 W(r),即在 E 中 r 看到的写动作。
  • V:“被写入的值”函数,对 A 中的每个写操作 w,会给出 V(w),即在 E 中 w 写入的值。
  • sw:与….同步,即同步关系上的偏序。
  • hb:之前发生,即动作上的偏序。

@@@ note

“与….同步”和“之前发生”元素是由执行中的其他组成部分以及有关良构执行的规则唯一确定的。

@@@

执行具有 “Happens-Before 一致性”,如果它的工作集具有“Happens-Before 一致性”。

良构执行

我们只考虑良构(Well-Formed)的执行。如果下面的条件都为 true,则执行 E = <P, A, po, so, W, V, sw, hb> 是良构的:

  • 每个读操作看到的都是在该执行中对同一个变量的写操作。
    • 所有对 volatile 变量的读操作和写操作都是 volatile 动作。对于 A 中的所有读操作,其 W(r) 都在 A 内,且 W(r).v = r.v。变量 r.v 是 volatile 的,当前仅当 r 是 volatile 读操作;w.v 是 volatile 的,当且仅当 W 是 volatile 的。
  • “之前发生”顺序是偏序。
    • “之前发生”顺序是由“与….同步”边和程序顺序的传递闭包给出的。它必须是有效的偏序:自反的、传递的且反对称的。
  • 该执行遵守线程内一致性。
    • 对每个线程 t,在 A 中由 t 执行的动作与该线程在单独执行时的程序顺序中生成的动作相同,如果每个读操作 r 看到的值都是 v(w(r)),那么每个写操作都会写入 v(w)。每个读操作看到的值是由内存模型确定的。所给出的程序顺序必须反映按照 P 的线程内语义执行动作的程序顺序。
  • 该执行是 “Happens-Before 一致性”。
  • 该执行遵循同步顺序一致性。
    • 对所有 A 中的 volatile 读操作,即不存在 so(r, W(t)),也不存在 A 中的写操作 W 使得 w.v = r.v、so(w(r), w) 和 so(w,r)同时成立。

执行和因果关系要求

我们使用 Fd 表示这样的函数:将 F 的域限定为 d。对于所有在 d 中的 x,Fd(x) = F(x),并且对于所有不在 d 中的 x,Fd(x) 无定义。

我们使用 Pd 表示将偏序 P 限定为 d 中的元素。对于所有在 d 中的 x 和 y,P(x,y) 成立当且仅当 Pd(x,y)。如果 x 和 y 不在 d 中,那么就不存在 Pd(x,y)。

良构 E = <P, A, po, so, W, V, sw, hb> 是由 A 中的“提交动作”所验证的。如果 A 中的所有动作都能够提交,那么该执行就满足 Java 编程语言内存模型有关因果关系的要求。

从空集 C0 开始,我们执行一系列步骤,将动作从动作集 A 中取出,并将其添加到提交动作集 Ci 中,以得到新的提交集 C i+1。为了证明这种方式的合理性,对于每个 Ci,我们需要证明包含 Ci 的执行 E 满足特定的条件。

形式化的将,执行 E 满足 Java 编程语言内存模型的因果关系要求当且仅当存在:

  • 动作集 C0、Ci、….,使得:
    • C0 是空集。
    • Ci 是 C i+1 的真子集。
    • A = U(C0, C1, …)。
    • 如果 A 是有限集,那么 C0、C1、….Cn 序列是有限的,以 Cn=A 结尾。
    • 如果 A 是无限集,那么 C0、C1、….Cn、… 序列也可能是无限的,并且必须满足这个无限序列中的所有元素的并集等于 A。
  • 良构的执行 E0、…、Ei、…,其中 Ei = <P, Ai, poi, soi, Wi, Vi, swi, hbi>
    • 给定这些动作集 C0、… 和执行 Ei、…,每个在 Ci 中的动作必须是 Ei 的动作之一。所有在 Ci 中的动作必须共享与 Ei 和 E 中相同的相对的之前发生顺序和同步顺序。形式化的讲:
      • Ci 是 Ai 的子集。
      • hbi|ci = hb|ci
      • soi|ci = so|ci
    • 由 Ci 中的写操作写入的值必须与在 Ei 和 E 中写入的值相同。只有在 Ci-1 中的读操作才要求在 Ei 中看到的写操作与在 E 中看到的写操作相同。形式化的讲:
      • Vi|ci = V|ci
      • Wi|ci-1 = W|ci-1
    • 所有在 Ei 中但不在 Ci-1 中的读操作必须看到在它们知己去哪发生的写操作。每个在 Ci - Ci-1 中的读操作 r 都必须在 Ei 和 E 中看到在 Ci-1 中的写操作,但是在 Ei 中看到的写操作可以在 E 中看到的写操作不同。形式化的讲:
      • 对于任何在 Ai - Ci 中的读操作 r,都有 hbi(Wi(r), r)。
      • 对于任何在 (Ci - Ci-1) 中的读操作 r,都有 Wi(r) 在 Ci-1 中并且 W(r) 在 Ci-1 中。
    • 给定 Ei 的充分的“与…同步”边的集合,如果有一个“释放-获取”对在你正在提交的动作之前发生,那么该操作对必须在所有的 Ej 中都存在,其中 j >= i。形式化的讲:
      • 设 SSWi 是在 hbi 的传递归约中但不在 po 中的 SWi 的边。我们称是 SSWi 为 “Ei 的充分的与…同步的边”。如果 SSWi(x,y) 和 hbi(y,z),且 z 在 Ci 中,那么对所有的 j>=i,都有 SWj(x,y)。
      • 如果动作 y 被提交,那么所有在 y 之前发生的所有外部动作也都会被提交。
      • 如果 y 在 Ci 中,x 是外部动作,且有 hbi(x,y),那么 x 在 Ci 中。

示例:“Happens-Before 一致性” 是不充分的

“Happens-Before 一致性” 是必要的但不是充分的约束集。仅仅强制实现 “Happens-Before 一致性” 仍旧会允许不可接受的行为发生,这些行为会违反我们已经为程序实现的需求。例如,“Happens-Before 一致性”使得值看起来像是“无中生有”的。通过对下表的轨迹进行详细检查就会看到这一点。

Thread 1Thread 2
1: r1 = x;1: r2 = y
2: if(r1 != 0) y = 1;2: if(r2 != 0) x = 1;

上表中展示的代码是正确同步的。这看起来很令人惊讶,因为它没有执行任何同步动作。但是请记住,当且仅当在它以连续一致的方式执行时程序是正确同步的,不会有任何数据竞争。如果这段代码是以连续一致的方式执行的,每个动作都按照程序顺序发生,每个写操作都不会发生。因为不会发生任何写操作,所以不会有任何数据竞争,因此:该程序是正确同步的。

既然这个程序是正确同步的,那么我们唯一允许其产生的行为只能是连续一致的行为。但是,这个程序存在这样一种执行:它是“Happens-Before 一致”的,但不是连续一致的:

r1 = x;	// sees write of x = 1
y = 1;	
r2 = y; // sees write of y = 1
x = 1;

这个结果是“Happens-Before 一致”的:没有任何“Happens-Before”关系会阻止它发生。但是很明显,这个结果不可接受:没有任何连续一致性的执行会产生这种行为。因此,由于我们允许读操作看到在执行顺序中之后到来的写操作,所以有时就会产生这种不可接受的行为。

尽管允许“读操作看到在执行顺序中之后到来的写操作”有时并不是我们想要的,但是它有时又是必须的。就像我们在上一个示例的表中看到的,其中的轨迹就要求某些读操作要看到在执行顺序中之后到来的写操作。由于在每个线程中,读操作总是先到,所以在执行顺序中第一个动作必然是读操作。如果该读操作不能看到之后发生的写操作,那么它就看不到它所读取的变量初始值之外的任何其他值。很明显,这将无法反映所有的行为。

我们将“读操作何时可以看到将来的写操作”的问题称为“因果关系”,因为这些问题与上表中所展示的情况类似。在那种情况中,读操作导致写操作发生,而写操作又导致读操作发生。对于这些动作而言,没有“首因”。因此,我们在内存模型中需要一种一致的方式,以确定哪些读操作可以提前看到写操作。

诸如上个示例的表中证明了本规范在描述读操作是否可以看到执行中之后发生的写操作时,必须格外小心(一定要记住,如果一个读操作可以看到执行中之后发生的读操作,那么就表示该写操作实际上是之前就执行过的)。

内存模型将给定的执行和程序作为输入,确定该执行是否是该程序的和合法执行。它是通过渐进的构建“提交”动作集来实现这一目的的,该动作集反映了程序执行了哪些动作。 通常,下一个被提交的动作将表现为在连续一致执行中可以执行的下一个动作。但是,为了表现需要看到之后发生的写操作的读操作,我们允许某些动作的提交时机早于在它们之前发生的工作的提交时机。

很明显,某些动作可以被提早提交,但是某些动作则不行。如本例的表中的某个写操作是在对该变量的读操作之前提交的,那么读操作将看到写操作,因为会产生“无中生有”的结果。非形式化的讲,我们允许某个动作早提交,前提是我们知道该动作的发生不会导致任何数据竞争。在上表中,两个写操作都不能提早执行,因为除非读操作可以看到数据竞争的结果,否则这些写操作就不能发生。

可观察的行为和不终止的执行

对于总是会在某个边界的有限时间内终止的程序,它们的行为可以直接根据它们允许的执行而被(以非形式化的方式)理解。对于不会在有限时间段内终止的程序,会产生更多微妙的问题。

程序的可观察的行为是用该程序可以执行的外部动作的有限集来定义的。例如,只是打印“Hello”的程序可以用这样的行为集描述:对于任何非负整数 i,该行为集包含打印 “Hello” i 次的行为。

“终止”不会被显式的建模为行为,但是程序可以很容易的扩展为生成额外的外部动作 executionTermination,该动作在所有线程被终止时发生。

我们还定义了一个特殊的悬挂(hand)动作。如果行为是用包含悬挂动作的外部动作集描述的,那么它表示的行为是:在外部动作被观察到之后,程序可以在时间上无界的运行,而不需要执行任何额外的外部动作或不需要终止。程序可以悬挂,如果所有线程都被阻塞,或者该程序可以执行在数量上无界的动作,而不需要执行任何外部动作。

线程可以在各种各样的环境中被阻塞,例如当它视图获取锁或者执行依赖于外部数据的外部动作(诸如读操作)时。

执行可以导致某个线程被无限阻塞,并且该执行不会终止。在这种情况下,被阻塞线程所产生的动作必须由该线程到被阻塞时为止所产生的所有动作构成,包括导致该线程被阻塞的动作,并且不包含在导致阻塞的动作之后该线程所产生的动作。

为了推断可观察到的行为,我们需要谈谈可观察动作集。

如果 o 是执行 E 的可以观察动作集,那么 o 必须是 E 的动作集 A 的子集,并且必须包含有限数量的动作,即使 A 包含无限数量的动作也是如此。并且,如果 y 是在 o 中的动作,并且有 hb(x,y) 或 so(x,y),那么 x 在 o 中。

@@@ note

可观察动作集并没有被限制为仅能包含外部动作,但是只有在动作集中的外部动作才会被当做可观察的外部动作。

@@@

行为 B 是程序 P 允许的行为,当且仅当 B 是有限外部动作集,并且:

  • 存在 P 的执行 E 和 E 的可观察动作集 O,使得 B 是 O 中的外部动作集(如果 E 中的任何线程都归于阻塞状态,并且 O 包含 E 中的所有动作,那么 B 也可以包含悬挂动作)。
  • 存在动作集 O,使得 B 由悬挂动作和所有 O 中的外部动作构成,并且对于所有 k >= |O|,都存在带有动作集 的 P 的执行 E,以及动作集 O’,使得:
    • O 和 O’ 都是 A 的子集,且它们满足可观察动作集的要求。
    • O <= O’ <= A。
    • |O’| >= k。
    • O’ - O 不包含任何外部动作。

@@@ note

行为 B 没有描述 B 中的外部动作被观察到的顺序,但是其他外部动作应该如何被生成和执行的(内部)约束条件可以被施加这种限制。

@@@

final 域的语义

声明为 final 的域只会被初始化一次,但是在正常情况下永远都不会再变更。final 域的详细语义与普通域的语义有些不同。特别是,编译器在同步栅栏和对任意或未知方法的调用之间可以有很大的自由度去移动对 final 域必须被重载的场景中,也不会从内存从载它。

final 域还使得开发者无需同步而实现线程安全的不可变对象。线程安全的不可变对象可以被所有线程看做是不可变的,即使数据竞争被用来在线程间传递不可变对象的引用,也是如此。这可以提供安全保障,以防止通过不正确或有恶意的代码误用不可变类。final 域必须被正确使用,以提供不可变性的保障。

对象在其构造器执行完成时被认为是完全初始化的。对于只能在对象完全初始化之后才能看到对该对象的引用的线程,可以保证它看到该对象的 fianl 域是被正确初始化的值。

final 域的使用模型非常简单:在对象的构造器中设置 fianl 域,并且不要在另一个线程可以在该对象的构造器质性完成之前看到它的地方,对该对象的引用执行写操作。如果遵循了这一点,那么当该对象被另一个线程看到时,这个线程就总是会看到该对象的 final 域的正确构造版本,并且对于任何被这些 fianl 域引用的对象或数组,这个线程也会看到它们至少与这些 final 域同样新的版本。

示例:Java 内存模型中的 fianl 域

下面的程序展示了 final 域与普通域的比较:

class FinalFiledExample {
  final int x;
  int y;
  static final FinalFieldExample f;
  
  punlic FinalFieldExample() {
    x = 3;
    y = 4;
  }
  
  static void writer() {
    f = new FinalFieldExample();
  }
  
  static void reader() {
    if(f != null) {
      int i = f.x;	// guranteed to see 3
      int j = f.y;	// cound see 0
    }
  }
}

一个线程可能会执行该类的 wirter 方法,而另一个线程可能会执行其 reader 方法。

因为 writer 方法在该对象构造器执行完成之后会写入 f,因此可以保证 reader 方法能正确看到 f.x 的初始化值:3。但是,f.y 不是 final 的,因此不能保证 reader 方法会看到它的值为 4。

示例:用于安全目的的 final 域

final 域被设计用来保证必要的安全性。请考虑下面的程序,其中一个线程(称为线程 1)会执行:

Global.s = "/tmp/user".substring(4);

而另一个线程(2)执行:

Strin myS = Global.s;
if(myS.equals("/tmp")) System.out.println(myS);

String 对象被设计为不可变的,并且字符串操作也不执行同步。尽管 String 的实现没有任何数据竞争,但是其他涉及使用 String 对象的代码也许会有数据竞争,并且内存模型对具有数据竞争的程序只提供弱保证。特别是,如果 String 类的域不是 final 的,那么它就有可能出现这样的情况(尽管不大可能):线程 2 最初会看到字符串对象的偏移量为缺省值 0,使得可以将它与 “/tmp” 进行比较看它们是否相等。而稍后在 String 对象上的操作可能会看到正确的偏移量 4,使得该 String 对象可以被感知到是 “/usr”。Java 编程语言的很多安全特性都依赖于 String 对象被感知为真的不可变,即使恶意代码可以利用数据竞争在线程间传递 String 引用也是如此。

final 域的语义

设 o 是对象,c 是 o 的构造器,在 c 中 final 域 f 会被写入。在 o 的 final 域上的“冻结”动作会在 c 退出时发生,无论是正常退出还是猝然退出。

注意,如果一个构造器调用了另一个构造器,并且被调用的构造器设置了 final 域,那么该 final 域的冻结就会在被调用的构造器的结尾处发生。

对于每次执行,读操作的行为会受到两个额外的偏序关系影响,即解引用链 dereferences() 和内存链 mc(),它们被认为是执行的一部分(因此被认为对任何特定执行都是不变的)。这些偏序必须满足下面的约束条件(不必有唯一解决方案):

  • 解引用链:如果线程 t 没有初始化对象 o,但是动作 a 是由线程 t 执行的对对象 o 的某个域或元素的读操作或写操作,那么必然存在某个由线程 t 执行的可以看到 o 的地址的读操作 r,使得 r dereferences(r,a) 成立。
  • 内存链:在内存链排序上有若干约束条件。
    • 如果 r 是可以看到写操作 w 的读操作,那么必然有 mc(w,r)。
    • 如果 r 和 a 是使得 dereferences(r,a) 成立的动作,那么必然有 mc(r,a)。
    • 如果线程 t 没有初始化对象 o,但是 w 是由线程 t 执行的对对象 o 的地址的写操作,那么必然存在某个线程 t 执行的可以看到 o 的地址读操作 r,使得 r mc(r,w) 成立。

假设没有写操作 w、冻结动作 f、动作 a(不是对 final 域的读操作)、对由 f 冻结的 final 域的读操作 r1,以及读操作 r2,使得 hb(w,f)、hb(f,a)、mc(a,r1) 和 dereferences(r1,r2) 成立,那么在确定哪些值可以被 r2 看到时,我们认为 hb(w,r2)。(这个“之前发生”排序与其他的“之前发生”排序没有构成产地闭包)

注意:dereferences 顺序是自反的,并且 r1 可以和 r2 相同。

对于对 final 域的读操作,只有被认为在这个对 final 域的读操作之前到来的写操作才是可以通过 final 域语义导出的操作。

在构造阶段读 final 域

如果某个对象位于构造它的线程中,那么对这个对象的 final 域的读操作是根据通常的 “Happens-Before” 规则,针对该域的初始化而排序的。如果该读操作出现在该域在构造器中被设置之后,那么它就会看到该 final 域已经赋过的值,否则,它会看到缺省值。

对 final 域的后续修改

在某些情况下,例如反序列化,系统需要在对象构造之后修改其 final 域。final 域可以通过反射和其他依赖于 Java 具体实现的方式被修改。唯一能够是这种修改具有合理语义的模式,就是允许先构造对象,然后再修改对象的 fianl 域的模式。这种对象不应该对其其他线程是可见的,而 final 域也不应该被读取,直至所有该对象的 final 域的更新都结束。final 域的冻结可以发生在设置该 fianl 域的构造器的末尾,或者在紧挨每个通过反射或其他特殊机制修改该 final 域的操作之后。

即使如此,还存在大量的复杂性。如果 final 域被初始化为域声明中的编译时常量表达式,那么对该 final 域的修改可能不会被观察到,因为对该 final 域的使用在编译器时就已经替换成了该常量表达式。

另一个问题是本规范允许对 final 域进行积极优化。在线程中,允许重排序对 final 域的读操作和对不再构造器中发生的对该域的修改操作。

示例:对 final 域的积极优化

class A {
  final int x;
  
  A() {
    x = 1;
  }
  
  inf f() {
    return d(this,this);
  }
  
  int d(A a1, A a2) {
    int i = a1.x;
    g(a1);
    int i = a2.x;
    return j - i;
  }
  
  static void g(A A) {
    // use reflection to change a.x to 2
  }
}

在方法 d 中,编译器可以任意对 x 的读操作和对 g 的调用进行重排序。因此,new A().f() 可能会返回 -1、0、1。

Java 编程语言的实现可以提供一种方式,用来在 final 域安全的上下文中执行代码块。如果某个对象是在 final 域安全的上下文中出现的对该 final 域的修改操作进行重排序。

final 域安全的上下文具有额外的保护措施。如果一个线程已经看到了未正确发布的对某个对象的引用,该线程通过该引用可以看到 final 域的缺省值,并且之后在 final 域安全的上下文中读取了正确发布的对该对象的引用,那么可以保证该线程可以看到该 final 域的正确的值。在形式上,在 final 域安全的上下文中执行的代码会被当做单独的线程处理(这种处理仅仅只针对 final 域的语义)。

在 Java 编程语言的实现中,编译器不应该将对 final 域的访问操作移入或移出 final 域安全的上下文(尽管它可以围绕着这种上下文的执行而移动,只要该对象不是在该上下文中构造的)。

有一种场景适合使用 fianl 域安全的上下文,即在执行器或线程池中。通过执行在彼此分离的 final 域安全的上下文中的每一个 Runnable 对象,执行器可以保证某个 Runnable 对象 o 的不正确访问将不会影响对由同一个执行器处理的其他 Runnable 做出的对 final 域的保证。

写受保护的域

正常情况下,是 final 且是 static 的域不能被修改。

但是,因历史遗留问题,System.in、System.out 和 System.err 虽然是 static final 域,但是它们必须通过 System.setIn、System.setOut、System.setErr 方法进行修改。我们将这些域称为“写受保护”的域,以便与普通 final 域区分。

编译器需要将这些域与其他 final 域区别对待。例如,对普通 final 域的读操作对同步是“免疫的”:涉及锁或 volatile 读的屏障不会影响从 final 域中读出的值。但是,由于我们可以看到对写受保护的域的值所做的变更,所以同步事件应该对它们有影响。因此,由于我们可以看到对写受保护的域的值所做的变更,所以同步事件应该对它们有影响。因此,其语义要求这些域应该被当做不能由用户代码修改的普通域进行处理,除非用户代码在 System 类中。

字撕裂

对 Java 虚拟机的实现有一种考虑,即每个域和数组元素都被认为是有区别的,对一个域或元素的更新不必与其他域或元素的读或更新操作交互。特别是,分别更新字节数组中毗邻元素的两个线程必定不会互相干涉或交互,因此也就不需要同步以确保连续的一致性。

某些处理器并不提供对单个字节进行写操作的能力。在这种处理器上通过直接读取整个字、更新恰当的字节,然后将整个字写回内存的方式来实现字节数组的更新是非法的。这个问题有时被称为字撕裂,在不能很容易的单独更新单个字节的处理器上,需要其他的实现方式。

示例:探测字撕裂

public class WordTearing extends Thread {
  static final int LENGTH = 8;
  static final int ITERS = 1000000;
  static byte[] counts = new byte[LENGTH];
  static Thread[] threads = new Thread[LENGTH];
  
  final int id;
  
  WordTearing(int i) {
    id = i;
  }
  
  public void run() {
    byte b = 0;
    for(int i=0; i < ITERS; i++) {
      byte v2 = counts[id];
      if(v != v2) {
        System.err.pringln("Word-Tearing foung: " +
          "counts[" + id + "] =" + v2 +
          ", shoube be " + v);
        return;
      }
      v++;
      counts[id] = v;
    }
  }
}

这里的关键是字节必须不能被写操作覆盖为毗邻的字节。

double 和 long 的非原子化处理

考虑到 Java 编程语言的内存模型,对非 volatile 的 long 或 double 的单个写操作会当做两个分离的写操作处理:每个操作处理 32 位。这会导致一种情况:一个线程会看到由某个写操作写入的 64 位值的头 32 位、由另一个写操作写入的后 32 位。

对 volatile 的 long 或 double 值的读操作和写操作总是原子性的。

对引用的读操作和写操作总是原子性的,无论它们被实现为 32 位还是 64 位的值。

某些实现会发现将单个对 64 位的 long 或 double 值的写动作分成两个毗邻的 32 位值的写动作会更方便。由于效率的原因,这种行为是实现相关的,Java 虚拟机的实现可以自由选择对 long 或 double 值的写操作是原子性的还是分成两部分。

我们鼓励 Java 虚拟机的实现应该避免将 64 位值分开,并鼓励开发者将共享的 64 位值声明为 volatile 的,或者正确的同步使用它们的程序以避免可能出现的复杂性。