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.wait
、Object.notify
、Object.notifyAll
方法进行操作。
等待集的操作还会受到线程的中断和 Thread 类中用于处理中断的方法的影响。另外,Thread 类中用于睡眠和连接其他线程的方法也具有从等待和通知动作中导出的属性。
调用 wait()
时,或者调用具有定时机制的 wait(long millisecs)
和 wait(long millisecs, int nanosecs)
时会发生“等待动作”。
向带有定时机制的 wait 方法传入参数值 0 等同于调用没有定时机制的 wait 方法。
如果线程返回时没有抛出 InterruptedException 异常,那么该线程就是正常返回的。
设线程 t 在对象 m 上执行 wait 方法,n 是线程 t 在对象 m 上尚未解锁的锁定动作的数量,那么将会发生下列动作之一:
@@@ note
第 2 条款迫使开发者必须循序以下 Java 编码习惯:对于在线程等待某个逻辑条件满足时才会终止的循环,才适合使用 wait。
@@@
每个线程必须确定可以引发从等待集中被移除的事件顺序。该顺序不必与其他排序方式一致,但是线程的行为必须是看起来就像这些事件是按照这个顺序发生的一样。
例如,如果线程 t 在 m 的等待集中,并且 t 的中断和 m 的通知都发生了,那么这些事件必然有一个顺序。如果中断被认为首先发生,那么 t 最终会抛出中断异常而从 wait 返回,并且位于 m 的等待集的另一个线程(如果在发通知时存在的话)必须收到这个通知;如果通知被认为首先发生,那么 t 最终会从 wait 中正常返回,而中断将被悬挂。
调用 notify 或 notifyAll 时发生通知动作。
设线程 t 在对象 m 上执行这两个方法,n 是线程 t 在对象 m 上尚未解锁的锁定动作的数量,那么将会发生下列动作之一:
但是需要注意,其中每次仅有一个线程可以在等待过程中锁定所需的监视器。
调用 Thread.interrupt 或 ThreadGoup.interrupt 方法时,发生中断动作。
设 t 是调用 u.interrupt 的线程,其中 u 是某个线程,t 和 u 可以相同。该调用动作会使得 u 的中断状态被设置为 true。
另外,如果存在某个对象 m,其等待集合包含 u,那么 u 会从 m 的等待集合中移除。这使得 u 从等待动作中恢复,在这种情况下,这个等待在重新锁定 m 的监视器之后,会抛出中断异常。
调用 Thread.isInterrupted 可以确定线程的中断状态。静态方法 Thread.interrupted 可以被线程调用以观察和清除自身的中断状态。
如果线程在等待时被通知了然后又被中断,那么它可以:
线程不可以重置它的中断状态并从对 wait 的调用中返回。类似的,通知不能因中断而丢失。假设线程集 s 在对象 m 的等待集中,另一个线程在 m 上执行另一个 notify,那么:
@@@ note
如果一个线程被 notify 中断和唤醒,并且该线程以抛出中断异常的方式从 wait 返回,那么在等待集中的其他线程必须必通知。
@@@
Thread.sleep 会导致当前运行的线程睡眠(暂时中止执行)指定的一段时间,具体时间取决于系统定时器和调度器的精确度。睡眠的线程不会丧失对任何监视器的所有权,而继续执行的时机则依赖于执行该线程的处理器的调度时机和可用性。
注意到这一点很重要:无论是 Thread.sleep 还是 Thread.yield 都没有任何同步语义。特别是,编译器不必在调用 Thread.sleep 或 Thread.yield 之前将寄存器中缓存的写操作冲刷到共享内存中,也不必在调用 Thread.sleep 或 Thread.yield 之后重新加载寄存器中缓存的值。
例如在下面的代码中,假设 this.done 是非 volatile 的 boolean 域:
1 | while(!this.done) |
编译器可以只读取 this.done 一次,并且在循环中的每次迭代中重用缓存的值。这意味着即使另一个线程修改了 this.done 的值,该循环永远也不会停止。
给定一个程序和该程序的执行轨迹,“内存模型”可以描述该执行轨迹是否是该程序的一次合法执行。Java 编程语言的内存模型是通过以下方式实现的:查验执行轨迹中的每个读操作,并依据特定的规则检查该读操作所观察到的写操作是否有效。
内存模型描述了程序的潜在行为。Java 语言的实现可以按照其喜好来产生任何代码,只要程序的执行过程都会产生内存模型可以预测的结果。
这为 Java 语言的实现者提供了很大的自由度去执行大量的代码转换,包括重排序动作和移除不必要的同步。
Java 编程语言的语义允许编译器和微处理器执行优化,与未正确同步的代码进行交互,而这种交互方式可能会产生看起来很荒谬的行为。下面的几个示例展示了未正确同步的程序可能会展示出惊人的行为。
例如,考虑下表中展示的样例程序轨迹。该程序使用了局部变量 r1 和 r2、共享变量 A 和 B。最初 A==B==0:
Thread 1 | Thread 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 1 | Thread 2 |
---|---|
1: B = A; | 3: r1 = B; |
2: r2 =A; | 4: A = 2; |
对有些开发者而言,这种行为看起来像是“受损了”。但是应该注意到,这种代码实际上只是没有进行正确的同步:
这种情况是“数据竞争”的一个实例。当代码包含数据竞争时,经常会产生有悖直觉的结果。
很多机制都可以产生上述重排序。Java 虚拟机实现中的即时编译器(JIT)可以重新安排代码或处理器。另外,对于 Java 虚拟机的实现而言,其架构的内存层次结构使得代码看起来就像是被重排序过一样。在本章中,我们将任何可以重排序代码的事物都归类为“编译器”。
另一个会出现惊人结果的例子可以在下表中看到。最初,p==q 且 p.x==0。该程序也未进行正确的同步,它对其中的写操作没有进行任何强制排序就向共享内存执行了写操作。
Thread 1 | Thread 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 1 | Thread 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 这样的变量名来表示方法或线程的局部变量。这种变量对其他线程是不可访问的。
可以在线程间共享的内存被称为“共享内存”或“堆内存”。所有实例域、静态域、数组元素都存储在堆内存。在本章,我们将使用“变量”来指代这些域或数组元素。
局部变量、形式方法参数、异常处理参数永远都不会在线程间共享,因此也就不受内存模型的影响。
如果至少有一个访问是写操作,两个对相同变量的访问(读或写)被称为是“冲突的”。
“线程间动作”是指一个线程执行的动作可以被另一个线程探测到或者直接受另一个线程影响。程序可以执行的线程间动作有如下几种:
本规范只关注线程间的动作,我们不需要关心线程内的工作(如将两个局部变量加起来存储到第三个局部变量中)。如前所述,所有线程都需要遵守正确的 Java 程序线程内语义。我们通常将线程间动作更简洁的称为“动作”。
一个动作 a 由元组 <t, k, v, u> 构成,其中:
外部动作元组还包含额外的组成部分,它包含执行该动作的线程可以感知到的该外部动作的结果,可以是表示该动作成功或失败的信息,以及该动作读取的值。
外部动作的参数(如哪些字节要写到哪些 socket)不是外部动作元组的组成部分。这些参数由线程内的其他动作设置,并且可以通过检查线程内语义而确定。它们在内存模型中没有专门的讨论。
在不终止的执行中,不是所有的外部动作都是可观察的。
在每个线程 t 执行的所有线程动作中,t 的程序顺序是一种全序,反映了这些动作按照 t 的线程内语义执行的顺序。
动作集是连续一致的,如果其所有动作都按照与程序顺序一致的全序(执行顺序)发生,并且每个对变量 v 的读操作 r 都可以看到由对 v 的写操作 w 写入的值,使得:
连续一致性是对程序执行中的可见性和排序做出的非常强的保障。在连续一致的执行中,在所有单独的动作(如读操作和写操作)之上存在全序(total order),它与程序的顺序一致,并且每个单独的动作都是原子性的,且对每个线程都是立即可见的。
如果程序中没有任何数据竞争,那么程序的所有执行看起来都是连续一致的。
对于由“需要被原子性的感知”和“不需要被原子性的感知”的操作构成的组,连续一致性和不存在的数据竞争仍旧不能保证这样的组中不会产生错误。
@@@ note
如果我们要使用连续一致性作为我们的内存模型,那么我们讨论过的编译器和处理器的很多优化都是非法的。
@@@
每次执行都有一个同步顺序。同步顺序是执行中所有同步动作之上的全序。对于每个线程 t,t 中的同步动作的同步顺序与 t 的程序顺序是一致的。
同步动作可以归纳出动作上的“被同步”关系,定义如下:
在表示同步关系的边中,源头称为释放,目的地称为获取。
两个动作可以通过 “Happens-Before” 关系进行排序。如果一个动作在另一个动作之前发生,那么第一个动作对第二个动作就是可见的,并且排在第二个动作之前。
如果我们有两个动作 x 和 y,那么我们写作 hb(x,y) 来表示 x 在 y 之前发生。
Object 类的 wait 方法与其相关联的锁定和解锁动作,它们之间的 “Happens-Before” 关系是由这些关联的动作定义的。
@@@ note
应该注意,两个动作之间存在 “Happens-Before” 关系并不意味着在代码实现中它们必须按照该顺序发生。如果重排序产生的结果与合法的执行是一致的,那么它就并不是非法的。
@@@
例如,对由某线程构造的对象的每个域写入其缺省值的操作,就不必非要在该线程的开始之前发生,只要没有任何读操作可以观察到这个事实即可。
更具体的说,如果两个动作共享 “Happens-Before” 关系,那么对于不和它们共享 “Happens-Before” 关系的任何代码来说,它们不必看起来非要是以该顺序发生的。例如,如果在一个线程中的写操作会与在另一个线程中的读操作产生数据竞争,那么这些写操作对那些读操作来说可以看起来像是乱序发生的。
在数据竞争发生时,需要定义 “Happens-Before” 关系。
由同步的边组成集合 S 是充分的,如果他是一个最小集,使得带有程序顺序的 S 的传递闭包可以确定在执行中的所有 “Happens-Before” 的边。这个集是唯一的。
由上面的定义可以得出下面的内容:
如果一个程序包含了两个互相冲突且没有 “Happens-Before” 排序关系的访问操作,那么就称该程序包含“数据竞争”。
对于不是线程间动作的操作,例如对数组长度的读操作、受检强制类型转换的执行和对虚拟方法的调用,其语义不受数据竞争的直接影响。
这对开发者来说是很好的保障。开发者不需要推断重排序方式以确定他们的代码是否包含数据竞争,因此也就不需要在确定他们的代码是否被正确的同步时推断重排序方式。一旦确定了代码是正确同步的,开发者就不需要担心重排序是否会影响他们的代码。
程序必须被正确同步以避免各种在代码重排序时会被观察到的反常行为。正确的同步并不能确保程序的整体行为是正确的,但是它使得开发者可以以简单的方式推断程序可能的行为。对于正确同步的程序,其行为对可能的重排序形成的依赖要少的多。没有正确的同步,就可能会产生非常奇怪的、混乱和反常的行为。
我们称变量 v 的读操作 r 允许观察对 v 的写操作 w,如果在执行轨迹的 “Happens-Before” 的偏序(partial-order)关系中:
非正式的讲,读操作 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” 排序机制允许看到的写操作。
对于下图中的轨迹,初始时 A==B==0。该轨迹可以观察到 r2==0 和 r1==0,并且在 “Happens-Before” 上仍旧保持一致性,因为执行顺序允许每个读操作看到恰当的写操作。
Thread 1 | Thread 2 |
---|---|
1: B = 1; | 1: A = 2; |
2: r2 = A; | 2: r1 = B; |
因为没有任何同步,所有每个读操作都可以看到写入初始值的写操作或由另一线程执行的写操作。下面的执行顺序展示了这种行为:
1 | 1: B=1; |
另一种具有 “Happens-Before 一致性” 的执行顺序为:
1 | 1: r2=A; // seen write of A=2 |
在该执行中,读操作看到的是在执行顺序之后发生的写操作。这看起来很反常,但是是 “Happens-Before一致性” 所允许的。允许读操作看到之后发生的写操作有时可能会产生不可接受的行为。
执行 E 可以用元组 <P, A, po, so, W, V, sw, hb> 表示,其构成为:
@@@ note
“与….同步”和“之前发生”元素是由执行中的其他组成部分以及有关良构执行的规则唯一确定的。
@@@
执行具有 “Happens-Before 一致性”,如果它的工作集具有“Happens-Before 一致性”。
我们只考虑良构(Well-Formed)的执行。如果下面的条件都为 true,则执行 E = <P, A, po, so, W, V, sw, hb> 是良构的:
我们使用 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 编程语言内存模型的因果关系要求当且仅当存在:
“Happens-Before 一致性” 是必要的但不是充分的约束集。仅仅强制实现 “Happens-Before 一致性” 仍旧会允许不可接受的行为发生,这些行为会违反我们已经为程序实现的需求。例如,“Happens-Before 一致性”使得值看起来像是“无中生有”的。通过对下表的轨迹进行详细检查就会看到这一点。
Thread 1 | Thread 2 |
---|---|
1: r1 = x; | 1: r2 = y |
2: if(r1 != 0) y = 1; | 2: if(r2 != 0) x = 1; |
上表中展示的代码是正确同步的。这看起来很令人惊讶,因为它没有执行任何同步动作。但是请记住,当且仅当在它以连续一致的方式执行时程序是正确同步的,不会有任何数据竞争。如果这段代码是以连续一致的方式执行的,每个动作都按照程序顺序发生,每个写操作都不会发生。因为不会发生任何写操作,所以不会有任何数据竞争,因此:该程序是正确同步的。
既然这个程序是正确同步的,那么我们唯一允许其产生的行为只能是连续一致的行为。但是,这个程序存在这样一种执行:它是“Happens-Before 一致”的,但不是连续一致的:
1 | r1 = x; // sees write of 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 是有限外部动作集,并且:
@@@ note
行为 B 没有描述 B 中的外部动作被观察到的顺序,但是其他外部动作应该如何被生成和执行的(内部)约束条件可以被施加这种限制。
@@@
声明为 final 的域只会被初始化一次,但是在正常情况下永远都不会再变更。final 域的详细语义与普通域的语义有些不同。特别是,编译器在同步栅栏和对任意或未知方法的调用之间可以有很大的自由度去移动对 final 域必须被重载的场景中,也不会从内存从载它。
final 域还使得开发者无需同步而实现线程安全的不可变对象。线程安全的不可变对象可以被所有线程看做是不可变的,即使数据竞争被用来在线程间传递不可变对象的引用,也是如此。这可以提供安全保障,以防止通过不正确或有恶意的代码误用不可变类。final 域必须被正确使用,以提供不可变性的保障。
对象在其构造器执行完成时被认为是完全初始化的。对于只能在对象完全初始化之后才能看到对该对象的引用的线程,可以保证它看到该对象的 fianl 域是被正确初始化的值。
final 域的使用模型非常简单:在对象的构造器中设置 fianl 域,并且不要在另一个线程可以在该对象的构造器质性完成之前看到它的地方,对该对象的引用执行写操作。如果遵循了这一点,那么当该对象被另一个线程看到时,这个线程就总是会看到该对象的 final 域的正确构造版本,并且对于任何被这些 fianl 域引用的对象或数组,这个线程也会看到它们至少与这些 final 域同样新的版本。
下面的程序展示了 final 域与普通域的比较:
1 | class FinalFiledExample { |
一个线程可能会执行该类的 wirter 方法,而另一个线程可能会执行其 reader 方法。
因为 writer 方法在该对象构造器执行完成之后会写入 f,因此可以保证 reader 方法能正确看到 f.x 的初始化值:3。但是,f.y 不是 final 的,因此不能保证 reader 方法会看到它的值为 4。
final 域被设计用来保证必要的安全性。请考虑下面的程序,其中一个线程(称为线程 1)会执行:
1 | Global.s = "/tmp/user".substring(4); |
而另一个线程(2)执行:
1 | Strin myS = Global.s; |
String 对象被设计为不可变的,并且字符串操作也不执行同步。尽管 String 的实现没有任何数据竞争,但是其他涉及使用 String 对象的代码也许会有数据竞争,并且内存模型对具有数据竞争的程序只提供弱保证。特别是,如果 String 类的域不是 final 的,那么它就有可能出现这样的情况(尽管不大可能):线程 2 最初会看到字符串对象的偏移量为缺省值 0,使得可以将它与 “/tmp” 进行比较看它们是否相等。而稍后在 String 对象上的操作可能会看到正确的偏移量 4,使得该 String 对象可以被感知到是 “/usr”。Java 编程语言的很多安全特性都依赖于 String 对象被感知为真的不可变,即使恶意代码可以利用数据竞争在线程间传递 String 引用也是如此。
设 o 是对象,c 是 o 的构造器,在 c 中 final 域 f 会被写入。在 o 的 final 域上的“冻结”动作会在 c 退出时发生,无论是正常退出还是猝然退出。
注意,如果一个构造器调用了另一个构造器,并且被调用的构造器设置了 final 域,那么该 final 域的冻结就会在被调用的构造器的结尾处发生。
对于每次执行,读操作的行为会受到两个额外的偏序关系影响,即解引用链 dereferences() 和内存链 mc(),它们被认为是执行的一部分(因此被认为对任何特定执行都是不变的)。这些偏序必须满足下面的约束条件(不必有唯一解决方案):
假设没有写操作 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 域的读操作是根据通常的 “Happens-Before” 规则,针对该域的初始化而排序的。如果该读操作出现在该域在构造器中被设置之后,那么它就会看到该 final 域已经赋过的值,否则,它会看到缺省值。
在某些情况下,例如反序列化,系统需要在对象构造之后修改其 final 域。final 域可以通过反射和其他依赖于 Java 具体实现的方式被修改。唯一能够是这种修改具有合理语义的模式,就是允许先构造对象,然后再修改对象的 fianl 域的模式。这种对象不应该对其其他线程是可见的,而 final 域也不应该被读取,直至所有该对象的 final 域的更新都结束。final 域的冻结可以发生在设置该 fianl 域的构造器的末尾,或者在紧挨每个通过反射或其他特殊机制修改该 final 域的操作之后。
即使如此,还存在大量的复杂性。如果 final 域被初始化为域声明中的编译时常量表达式,那么对该 final 域的修改可能不会被观察到,因为对该 final 域的使用在编译器时就已经替换成了该常量表达式。
另一个问题是本规范允许对 final 域进行积极优化。在线程中,允许重排序对 final 域的读操作和对不再构造器中发生的对该域的修改操作。
1 | class A { |
在方法 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 虚拟机的实现有一种考虑,即每个域和数组元素都被认为是有区别的,对一个域或元素的更新不必与其他域或元素的读或更新操作交互。特别是,分别更新字节数组中毗邻元素的两个线程必定不会互相干涉或交互,因此也就不需要同步以确保连续的一致性。
某些处理器并不提供对单个字节进行写操作的能力。在这种处理器上通过直接读取整个字、更新恰当的字节,然后将整个字写回内存的方式来实现字节数组的更新是非法的。这个问题有时被称为字撕裂,在不能很容易的单独更新单个字节的处理器上,需要其他的实现方式。
1 | public class WordTearing extends Thread { |
这里的关键是字节必须不能被写操作覆盖为毗邻的字节。
考虑到 Java 编程语言的内存模型,对非 volatile 的 long 或 double 的单个写操作会当做两个分离的写操作处理:每个操作处理 32 位。这会导致一种情况:一个线程会看到由某个写操作写入的 64 位值的头 32 位、由另一个写操作写入的后 32 位。
对 volatile 的 long 或 double 值的读操作和写操作总是原子性的。
对引用的读操作和写操作总是原子性的,无论它们被实现为 32 位还是 64 位的值。
某些实现会发现将单个对 64 位的 long 或 double 值的写动作分成两个毗邻的 32 位值的写动作会更方便。由于效率的原因,这种行为是实现相关的,Java 虚拟机的实现可以自由选择对 long 或 double 值的写操作是原子性的还是分成两部分。
我们鼓励 Java 虚拟机的实现应该避免将 64 位值分开,并鼓励开发者将共享的 64 位值声明为 volatile 的,或者正确的同步使用它们的程序以避免可能出现的复杂性。