虽然前面主要讨论的是单线程执行,但 Java 虚拟机支持多线程执行。可以通过有许多硬件处理器、时间分片单硬件处理器或时间分片多硬件处理器来支持线程。
通过 Thread 类来表示线程。用户创建线程的唯一方式是创建此类的对象;每条线程与这样一个对象相关联。当在相应的 Thread 对象上调用 start() 方法时,将会启动线程。
当在未正确同步的情况下,线程的行为看起来是令人困惑且违反直觉的。本章描述了多线程程序的语义;包括通过由多线程更新的共享内存的读可以看到哪个值的规则。因为规范类似于不同硬件架构的内存模型,所以这些语义被称为 Java 编程语言内存模型。当不会出现混淆时,我们会将这些规则简单地引用为“内存模型”。
这些语义不规定应该如何执行多线程程序。相反,他们描述了允许多线程程序展示的行为。任何只生成允许行为的执行策略都是可接受的执行策略。
Java 编程语言提供了多种线程间通信机制。这些方式中最基本的是同步,其是使用管程实现的。Java 中的每个对象都与一个管程相关联,线程可以锁定或解锁。一次只有一条线程可以持有一个管程上的锁。阻塞任何试图锁定该规程的其他线程,直到他们可以获取该管程上的锁。一条线程可以多次锁定一个特定的管程;每个解锁都反转一个锁定操作的效果。
synchronized 语句(14.19)计算一个对象引用;然后它尝试在该对象的管程上执行锁定操作,并且直到该锁定操作成功完成之前不会采取进一步操作。锁定操作执行后,执行 synchronized 语句的 body。如果 body 的执行已经完成,正常地或异常地,则自动在该管程上执行一个解锁操作。
当调用时,synchronized 方法(8.4.3.6)自动地执行锁定操作;直到该锁定操作成功完成之前,不会执行它的 body。如果方法是实例方法,则它锁定与实例,其调用该方法,相关联的管程(即,方法 body 执行期间被称为 this 的对象)。如果方法是 static,则它锁定与 Class 对象,其表示定义该方法的类,相关联的管程。如果方法的 body 的执行已经完成,正常地或异常地,则自动地在该同一管程上执行一个解锁操作。
Java 编程语言既不阻止也不要求死锁条件检测。如有必要,程序,其中线程在多个对象上持有(直接地或间接地)锁,应该使用常规的死锁避免技术,创建更高层次的不会死锁的锁基元。
其他技术,例如读写 volatile 变量、java.util.concurrent 包下类的使用,提供了可选的同步方式。
每个对象,除了有一个相关联的管程之外,还有一个相关联的等待集合。一个等待集合是一组线程。
当首次创建对象时,它的等待集合是空的。向等待集合添加线程或从等待集合移除线程的基本操作是自动的。只能通过方法 Object.wait、Object.notify 和 Object.notifyAll 来操作等待集合。
等待集合也可以受线程的中断状态和 Thread 类的处理中断的方法的影响。此外,Thread 类的休眠和加入其他线程的方法具有派生自这些等待和统治操作的属性。
在调用 wait() 或定时的 wait(long millisecs) 和 wait(long millisecs, int nanosecs) 时发生等待操作。
用一个零调用 wait(long millisecs) 或用两个零调用 wait(long millisecs, int nanosecs) 等价于调用 wait()。
线程从等待正常返回,如果它返回时没有抛出 InterruptedException。
让线程 t 是正在对象 m 上执行 wait 方法的线程,让 n 是由 t 在 m 上已经执行的还未匹配解锁操作的锁定操作的数量。以下之一操作发生:
* 如果 n 是零(即,线程 t 还未拥有目标 m 的锁),则抛出 IllegalMonitorStateException。
* 如果是定时等待且 nanosecs 参数不在 0-999999 范围内或 millisecs 参数是负数,则抛出 IllegalArgumentException。
* 如果线程 t 被中断,则抛出 InterruptedException 且将 t 的中断状态设置为 false。
* 否则,发生以下操作序列:
1. 将线程 t 添加到对象 m 的等待集合中,在 m 上执行 n 个解锁操作。
2. 线程 t 不执行进一步的指令,直到将它从 m 的等待集合中移除。此线程可能由于以下任一操作而从等待集合中移除,并在以后的某个时刻恢复执行:
在 m 上执行的一个 notity 操作,其中 t 被选择从等待集合中移除。
在 m 上执行的 notifyAll 操作。
在 t 上执行的中断操作。
如果这是一个定时等待,则从此等待操作开始到至少 millisecs 微秒加 nanosecs 纳秒之后,发生一个内部的将 t 从 m 的等待集合中移除的操作。
由实现执行的内部操作。尽管不鼓励,但允许实现执行“伪唤醒”,即,以从等待集合中移除线程且因此不需要显式的指令这样做就可以使程序恢复执行。
请注意,此项规定使仅在当线程正在等待持有的某个逻辑条件满足时才终止的循环中使用等待的 Java 编码实践成为必需品。
每条线程必须决定导致它从等待集合中移除的顺序。该顺序不必与其他顺序一致,但线程必须表现为这些事件是按该顺序发生的。
例如,如果线程 t 在 m 的等待集合中,然后 t 的中断和 m 的通知同时发生,则在这些事件上必须有一个顺序。如果中断被视为先发生,则 t 最终会通过抛出 InterruptedException 而从 wait 中返回,m 的等待集合中的某个其他线程(如果通知时存在任何一个)必须接收此通知。如果通知被视为先发生,则 t 最终会带有一个待定的中断从 wait 正常返回。
3. 线程 t 在 m 上执行 n 个锁定操作。
4. 如果线程 t 在步骤 2 中由于中断从 m 的等待集合中移除,则将 t 的中断状态设置为 false,wait 方法抛出 InterruptedException。
在调用方法 notify 和 notifyAll 时发生通知操作。
让线程 t 是正在对象 m 上执行这些方法中的任意一个的线程,让 n 是由 t 在 m 上已经执行的还未匹配解锁操作的锁定操作的数量。发生以下操作之一:
* 如果 n 是零,则抛出 IllegalMoniterStateException。
这是线程 t 还未拥有目标 m 的锁的情况。
* 如果 n 大于零且这是一个 notify 操作,则如果 m 的等待集合不是空的,线程 u,其为 m 的当前等待集合的成员,被选择并从该等待集合中移除。
不保证选择哪一条线程。此等待集合的移除操作使 u 从等待操作中恢复。但是,请注意,u 的锁定操作在恢复执行时无法成功,直到 t 完全解锁 m 的管程之后的某一时刻。
* 如果 n 大于零且这是一个 notifyAll 操作,则所有线程从 m 的等待集合中移除,并因此恢复。
但是,请注意,每次他们中只有一条将锁定在等待恢复期间所需的管程。
中断操作在 Thread.interrupt 调用时发生,以及定义的反过来调用它的方法,例如 ThreadGroup.interrupt。
对于某个线程 u,让 t 是调用 u.interrupt 的线程,其中 t 和 u 可以相同。此操作导致 u 的中断状态被设置为 true。
此外,如果存在等待集合包含 u 的某个对象 m,则将 u 从 m 的等待集合中移除。这使 u 从等待集合中恢复,在这种情况下,此等待将在重新锁定 m 的管程之后抛出 InterruptedExceptiion。
Thread.isInterrupted 的调用可以确定一条线程的中断状态。线程可以通过调用 static 方法 Thread.isInterrupted 来观察并清除其自己的中断状态。
上面的规范允许我们确定几个与等待、通知和中断的交互有关的属性。
如果线程在等待时同时被通知和中断,则它可能是:
* 从 wait 中正常返回,而仍然有一个待定的中断(换言之,Thread.interrupted 的调用将返回 true)
* 通过抛出 InterruptedException 从 wait 中返回
线程不可以重置其中断状态并从 wait 的调用中正常返回。
类似地,无法由于中断而丢失通知。假定,一组线程 s 在对象 m 的等待集合中,另一个线程在 m 上执行了一个 notify。则要么:
* s 中至少一条线程必须从 wait 中正常返回,要么
* s 中所有的线程通过抛出 InterruptedException 离开 wait
请注意,如果一条线程同时被中断和通过 notify 唤醒,该线程通过抛出 InterruptedException 从 wait 中返回,则等待集合中的某条其他线程必须被通知。
Thread.sleep 导致当前执行中的线程休眠(暂时停止执行)指定的时间,受系统计时器和调度器的精度和准确性的影响。线程不丢失任何管程的所有权,执行恢复将取决于调度和执行线程的处理器的可用性。
Thread.sleep 和 Thread.yield 没有任何同步语义。特别地,在调用 Thread.sleep 或 Thread.yield 之前编译器不必将寄存器中的写缓存刷新到共享内存中,编译器也不需要在调用 Thread.sleep 或 Thread.yield 之后重新加载寄存器中缓存的值。
给定一个程序和该程序的一条执行轨迹,内存模型描述了此执行轨迹是否是合法的程序执行。Java 编程语言内存模型通过根据特定的规则检查执行轨迹中的每个读取及检查由该读取观察到的写入是合法的来工作。
内存模型描述了程序可能的行为。实现自由生成它喜欢的任何代码,只要产生的程序执行生成可由内存模型预测的结果。
这为实现者执行无数的代码转换提供了极大的自由,包括操作重排序和删除不必要的同步。
上图第一段话翻译: Java 编程语言的语义允许编译器和微处理器执行优化,这些优化可以以能产生看起来自相矛盾的行为的方式与未正确同步的代码交互。以下示例显示了,未正确同步的代码如何展示令人吃惊的行为。
第五段话翻译: 允许编译器对任一线程中的指令进行重新排序,当这不影响该线程的单独执行时。
内存模型决定在程序的每个点处可以读取什么值。每条线程的独立操作必须表现为受该线程的语义控制,除了每个读取看到的值由内存模型决定之外。当我们提到这个时,我们说程序服从线程内语义。线程内语义是单线程程序的语义,允许基于线程内读取操作看到的值完整预测线程的行为。为了确定执行中线程 t 的操作是否是合法的,我们只需简单地评估线程 t 的实现,就像在单线程上下文中执行它一样,如本规范其余部分所定义的那样。
每次线程 t 的评估都生成一个线程间操作,它必须匹配 t 的下一个以程序顺序到来的线程间操作 a。如果 a 是一个读取,则 t 的进一步评估使用如内存模型所决定的由 a 看到的值。
本节提供了 Java 编程语言内存模型规范,除了 17.5 中描述的处理 final 字段的问题之外。
此处指定的内存模型从根本上不是基于 Java 编程语言的面向对象特性。在示例中,为了简洁明了,我们经常展示没有类或方法定义的代码片段,或显式非关联化。大多数示例由两个或更多个包含局部变量、共享全局变量或对象的实例字段的访问的语句的线程组成。我们通常使用 r1 或 r2 之类的变量名来表示方法或线程的局部变量。其他线程无法此类变量。
线程之间可以共享的内存被称为共享内存或堆内存。
所有实例字段、static 字段和数组元素都存储在堆内存上。在本章,我们使用术语变量来表示字段和数组元素。
局部变量(14.4)、形式方法参数(8.4.1)和异常处理器参数(14.20)从不在线程之间共享,且不受内存模型的影响。
两个对同一变量的访问(读取或写入)被称为是冲突的,如果访问中的至少一个是写入。
线程间操作是由一条线程执行的可被另一条线程检测到货直接影响到的操作。有几种程序可以执行的线程间操作:
* 读取(标准,或非 volatile)。读取一个变量。
* 写入(标准,或非 volatile)。写入到一个变量。
* 同步操作,其是:
volatile 读取。变量的 volatile 读取。
volatile 写入。变量的 volatile 写入。
锁定。锁定一个管程。
解锁。解锁一个管程。
线程的第一个和最后一个操作(合成的)。
启动一条线程或检测一条线程是否已终止(17.4.4)的操作。
* 外部操作。外部操作是在执行之外可观察到的操作,并且基于执行外部的环境产生一个结果。
* 线程发散操作(17.4.9)。线程发散操作仅由一条在不执行内存、同步或外部操作的无限循环中的线程执行。如果一条线程执行一个线程发散操作,则它将会后跟无数个线程发散操作。
引入线程发散操作来模拟,一条线程如何导致所有其他线程停顿且无法向下执行。
本规范仅关注线程间操作。我们通常会更简洁地将线程间操作称为操作。
由元组 <t, k, v, u> 来描述操作,包括:
* t - 执行操作的线程
* k - 操作的种类
* v - 操作中涉及的变量或管程。
对于锁定操作,v 是被锁定的管程;对于解锁操作,v 是被解锁的管程。
如果操作是一个(volatile 或非 volatile)读取,则 v 是被读取的变量。
如果操作是一个(volatile 或非 volatile)写入,则 v 是正被写入的变量。
* u - 操作的任意唯一标识符
外部操作元组包含一个额外的组件,其中包含执行操作的线程所感知到的外部操作的结果。这可能是有关操作成功或失败的信息,和由该操作读取的任何值。
外部操作的参数(例如,哪些字节别写入到哪个套接字中)不是外部操作元组的一部分。这些参数由线程中的其他操作设置,且可以通过检查线程内语义确定。不在内存模型中显式地讨论他们。
在非终止执行中,并非所有的外部操作都是可观察的。17.4.9 中讨论了非终止执行和可观察操作。
在由每条线程 t 执行的所有线程间操作中,t 的程序顺序是一个总顺序,它反映了根据 t 的线程内语义执行这些操作的顺序。
一组操作是串行一致的,如果所有操作都以与程序顺序一致的总顺序(执行顺序)出现,此外,变量 v 的每个读取 r 都可以看到由写入 w 到 v 写入的值,这样:
* w 在执行顺序中出现在 r 之前,且
* 在执行顺序中没有其他写入 w',使 w 出现在 w' 之前且 w' 出现在 r 之前。
串行一致行是对程序执行中的可见性和有序性作出的非常强有力的保证。在串行一致执行中,在所有单个操作(例如读取和写入)上有一个与程序顺序一致的总顺序,每个单独的操作都是原子的且对每条线程都是直接可见的。
如果程序没有数据竞争,则程序的所有执行都将看起来是串行一致的。
串行一致行和/或免于数据竞争允许从那些需要被感知原子性而不是原子的操作组产生错误。
如果我们使用串行一致行作为我们的内存模型,则许多我们已经讨论的编译器和处理器优化将都是非法的。例如,在 Table 17.4-C 的轨迹中,只要发生 3 到 p.x 的写入,该位置随后的读取就需要看到该值。
每个执行都有一个同步顺序。同步顺序是一种在执行的所有同步操作上的总顺序。对于每条线程 t,t 中同步操作(17.4.2)的同步顺序与 t 的程序顺序(17.4.3)一致。
同步操作包含操作上的 synchronized-with 关系,定义如下:
* 管程 m 上的解锁操作同步所有随后的在 m 上的锁定操作(其中根据同步顺序定义“随后的”)。
* volatile 变量 v(8.3.1.4)的写入同步所有随后的任何线程对 v 的读取(其中根据同步顺序定义“随后的”)。
* 启动一条线程的操作同步它启动的线程的第一个操作。
* 每个变量默认值(零、false 或 null)的写入同步每条线程中的第一个操作。
虽然在分配包含变量的对象之前向变量写入默认值看起来有点奇怪,但从概念上讲,对象是在程序开始处用其默认初始化的值创建的。
* 线程 T1 中的最后一个操作同步另一条检测到 T1 已经终止的线程 T2 中的任何操作。
T2 可以通过调用 T1.isAlive() 或 T1.join() 来完成这个。
* 如果线程 T1 中断线程 T2,则由 T1 执行的中断同步任何其他线程(包括 T2)确定 T2 已经被中断的任何点(通过抛出一个 InterruptedException 或通过调用 Thread.interrupted 或 Thread.isInterrupted)。
同步边缘的源被称为一个释放,目标被称为一个获取。
可以通过 happens-before 关系将两个操作排序。如果一个操作 happens-before 另一个,则第一个对第二个可见且排在第二在前面。
如果有两个操作 x 和 y,我们用 hb(x, y) 来表示 x happens-before y。
* 如果 x 和 y 是同一条线程的操作且以程序顺序来看 x 在 y 前面,则 hb(x, y)。
* 从一个对象的构造器的结束到该对象的终结器(12.6)的开始有一个 happens-before 边缘。
* 如果操作 x 同步后面的操作 y,则也有 hb(x, y)。
* 如果 hb(x, y) 且 hb(y, z),则 hb(x, z)。
类 Object(17.2.1)的 wait 方法具有与他们相关联的锁定和解锁操作;由这些相关联的操作定义他们的 happens-before 关系。
请注意,两个操作之间 happens-before 关系的存在并不一定意味着他们在实现中并不一定以该顺序发生。如果重排序产生了与合法执行一致的结果,则它不是非法的。
例如,由一条线程构造的一个对象的每个字段的默认值的写入不必在该线程的开头之前发生,只要曾经没有读取观察到这一事实。
更具体地说,如果两个操作共享一个 happens-before 关系,则他们不一定必须好像以他们不共享 happens-before 关系的任何代码的顺序已经发生。例如,与另一条线程中的读取处于数据竞争中的一条线程的写入可能好像以相对于这些读取乱序出现。
happens-before 关系定义了何时发生数据竞争。
一组同步边缘,S,是足够的,如果它是使 S 的传递闭包跟程序顺序可以确定执行中的所有 happens-before 边缘的最小集合。此集合是唯一的:
从上面的定义来看:
* 管程上的一个解锁 happens-before 每个随后在该管程上的锁定。
* volatile 字段(8.3.1.4)的写入 happens-before 该字段每个随后的读取。
* 一条线程上的 start() 调用 happens-before 启动的线程中的任何操作。
* 一条线程中的所有操作 happens-before 从该线程上的 join() 成功返回的任何其他线程。
* 任何对象的默认初始化 happens-before 程序的任何其他操作(除默认写入之外)。
当程序包含两个冲突的没有被 happens-before 关系排序的访问(17.4.1)时,它被称为包含数据竞争。
除线程间操作之外的操作的语义,例如数组长度(10.7)的读取、已检查强制转换(5.5,15.16)的执行和虚方法(15.12)的调用,不直接受数据竞争的影响。
因此,数据竞争无法导致不正确的行为,例如返回数组错误的长度。
程序是正确同步的,当且仅当所有串行一致执行都没有数据竞争。
如果程序是正确同步的,则程序的所有执行都好像是串行一致的(17.4.3)。
这对程序员来说是非常强的保证。程序员不需要思考重排序来确定他们的代码包含数据竞争。因此,在确定他们的代码是否是正确同步的时,他们不需要思考重排序。一旦确定代码是正确同步的,程序员不需要担心重排序会影响的他的代码。
程序必须正确同步,以避免在重排序代码时可以观察到的违反直觉的行为。正确同步的使用并不能确保程序的整体行为是正确的。但是,它的使用确实允许程序员以一种简单的方式思考程序可能的行为;正确同步的程序的行为更少依赖可能的重排序。如果没有正确同步,则可能发生非常奇怪、令人混淆且违反直觉的行为。
在执行轨迹的 happens-before 偏序中,我们说允许变量 v 的读取 r 观察到 v 的写入 w,如果:
* r 没有排在 w 之前(即,它不是 hb(r, w) 这种情况),且
* 没有介于中间的对 v 的写入 w'(即,没有对 v 的写入 w',使 hb(w, w') 且 hb(w', r))。
非正式地,允许读取 r 看到写入 w 的结果,如果没有 happens-before 顺序阻止该读取。
一组操作 A 是 happens-before 一致的,如果对于 A 中的所有读取 r,其中 W(r) 是由 r 看到的写入操作,它不是情况 hb(r, W(r)) 或 A 中存在写入 w,使 w.v = r.v 且 hb(W(r), w) 且 hb(w, r)。
在一组 happens-before 一致的操作中,每个读取都看到一个由 happens-before 顺序允许看到的写入。
执行 E 由元组 <P, A, po, so, W, V, sw, hb> 描述,包括:
* P - 程序
* A - 一组操作
* po - 程序顺序,其对于每条线程 t,是 A 中由 t 执行的所有操作上的总顺序
* so - 同步顺序,其是 A 中所有同步操作上的总顺序
* W - write-seen 函数,其对于 A 中的每个读取 r,给出了 W(r),E 中由 r 看到的写入操作。
* V - value-written 函数,其对于 A 中的每个写入 w,给出了 V(w),E 中由 w 写入的值。
* sw - 同步,同步操作上的偏序
* hb - happens-before,操作上的偏序
请注意,同步和 happens-before 元素是由执行的其他组件和良好形式的执行(17.4.7)的规则唯一确定的。
执行是 happens-before 一致的,如果它的一组操作是 happens-before 一致的(17.4.5)。
我们仅考虑良好形式的执行。一个执行 E=<P, A, po, so, W, V, sw, hb> 是良好形式的,如果以下都为 true:
-
每个读取都看到执行中同一变量的一个写入。
所有 volatile 变量的读取和写入都是 volatile 操作。对于 A 中的所有读取 r,我们在 A 中有 W(r) 和 W(r).v = r.v。变量 r.v 是 volatile,当且仅当 r 是一个 volatile 读取,变量 w.v 是 volatile,当且仅当 w 是一个 volatile 写入。
-
happens-before 顺序是一个偏序。
由同步边缘的传递闭包和程序顺序给定 happens-before 顺序。它必须是一个合法的偏序:自反的、传递的和反对称的。
-
执行遵守线程内一致性。
对于每条线程 t,A 中由 t 执行的操作与由该线程在单独的程序顺序中生成的操作相同,每个写入 w 都写入值 V(w),假定每个读取 r 都看到值 V(W(r))。每个读取看到的值由内存模型决定。给定的程序顺序必须反映根据 P 的线程内语义执行操作的程序顺序。
-
执行是 happens-before 一致的(17.4.6)。
-
执行遵循同步一致性。
对于 A 中所有的 volatile 读取 r,它不是 so(r, W(r)) 这种情况,或 A 中存在一个写入 w,使 w.v = r.v 且 so(W(r), w) 且 so(w, r)。
我们使用 f|d 来表示通过限制 f 到 d 的域所给定的函数。对于 d 中的所有 x,f|d(x) = f(x),对于所有不在 d 中的 x,f|d(x) 是未定义的。
我们使用 p|d 来表示偏序 p 到 d 中元素的限制。对于 d 中的所有 x、y,p(x, y),当且仅当 p|d(x,y)。如果 x 或 y 任一不在 d 中,则它不是情况 p|d(x,y)。
一个形式良好的执行 E=<P, A, po, so, W, V, sw, hb> 通过提交来自 A 的操作是验证通过的。如果可以提交 A 中的所有操作,则执行满足 Java 编程语言内存模型的因果性要求。
从空集合 C0 开始,我们执行一个步骤序列,其中我们采取来自操作集合 A 的操作并将他们添加到已提交操作集合 Ci 以获得一个新的已提交操作集合 Ci+1。为了证明这是合理的,对于每个 Ci,我们需要证明一个包含 Ci 的执行 E 满足某些条件。
正式地,执行 E 满足 Java 编程语言内存模型的因果性要求,当且仅当存在:
* 操作集合 C0, C1, ...,使
C0 是空集合
Ci 是 Ci+1 的一个适当的子集
A = ∪ (C0, C1, ...)
如果 A 是有穷的,则序列 C0, C1, ... 将是有穷的,以集合 Cn = A 结束。
如果 A 是无穷的,则序列 C0, C1, ... 可以是无穷的,它必须是情况此无穷序列的所有元素的联合等于 A。
* 形式良好的执行 E1, ...,其中 Ei = < P, Ai, poi, soi, Wi, Vi, swi, hbi >。
给定操作集合 C0, ... 和执行 E1, ...,Ci 中的每个操作都必须是 Ei 中的操作之一。Ci 中的所有操作必须共享相同的同时在 Ei 和 E 中的相对 happens-before 顺序和同步顺序。形式上:
-
Ci 是 Ai 的一个子集
-
hbi|Ci = hb|Ci
-
soi|Ci = so|Ci
由 Ci 中的写写入的值必须与同时在 Ei 和 E 中的相同。只有 Ci-1 中的读取需要看到与 E 中相同的 Ei 中的写入。形式上:
-
Vi|Ci = V|Ci
-
Wi|Ci-1 = W|Ci-1
在 Ei 中不在 Ci-1 中的所有读取必须看到那些 happens-before 他们的写入。Ci - Ci-1 中每个读取 r 都必须同时在 Ei 和 E 中看到 Ci-1 中的写入,但可以看到一个 Ei 中不同于它在 E 中看到的写入。形式上:
-
对于 Ai - Ci-1 中的任何读取 r,我们有 hbi(Wi(r), r)
-
对于 (Ci - Ci-1) 中的任何读取 r,我们在 Ci-1 中有 Wi(r),在 Ci-1 中有 W(r)
给定一个充分的 Ei 的同步边缘集合,如果有一个 happens-before(17.4.5)你正在提交的操作的 release-acquire 对,则该对必须在所有 Ej 中都存在,其中 j ≥ i。形式上:
-
让 sswi 是 swi 边缘,其也是在 hbi 的可传递性约简中而不是在 po 中。我们称 sswi 为充分的 Ei 同步边缘。如果 sswi(x, y) 且 hbi(y, z) 且 Ci 中的 z,则对于所有 j ≥ i,swj(x, y)。
如果操作 y 是已提交的,则那些 happens-before y 的所有外部操作也都是已提交的。
-
如果 y 在 Ci 中,x 是一个外部操作,hbi(x, y),则 x 在 Ci 中。
对于总是在某个有界的有限时间周期内终止的程序,可以简单地根据他们可允许的执行来理解(非正式地)他们的行为。对于无法在有界的时间内终止程序,会发生更多微妙的问题。
程序可观察到的行为由程序可以执行的外部操作的有穷集合定义。例如,一个简单地打印 "Hello" 的程序永远由一组行为描述,这组行为对于任何非负整数 i,包括打印 "Hello" i 次行为。
终止没有被显式地模拟成一个行为,但是可以很容易地扩展一个程序,以生成当所有线程终止时发生的额外的外部操作 executionTermination。
我们还定义了一个特殊的 hang 操作。如果行为由一组包含 hang 操作的外部操作描述,它表示一个在观察到外部操作之后的行为,程序可以运行无限时间而不需要执行任何额外的外部操作或终止。程序可以挂起,如果所有线程被阻塞或如果程序可以执行无穷数量的操作而不需要执行任何外部操作。
可以在各种情况下阻塞一条线程,例如当它试图获取锁或执行依赖外部数据的外部操作(例如读取)时。
执行可能导致线程被无限期地阻塞,并且该执行未终止。在这种情况下,由被阻塞的线程生成的操作必须由所有由该线程生成的直到并包括导致该线程被阻塞的操作组成,在该操作之后没有任何由该线程生成的操作。
为了推理可观察的行为,我们需要讨论可观察的操作集合。
如果 O 是执行 E 的一组可观察的操作,则集合 O 必须是 E 的操作 A 的一个子集,并且必须仅包含一个有限数量的操作,即使 A 包含无限数量的操作。此外,如果操作 y 在 O 中且 hb(x, y) 或 so(x, y),则 x 在 O 中。
请注意,一组可观察的操作不限于外部操作。相反,只有在可观察操作集合中的外部操作被视为可观察的外部操作。
行为 B 是程序 P 的一个可允许的行为,当且仅当 B 是一个有限的外部操作集合且要么:
* 存在一个 P 的执行 E 和 E 的可观察的操作的集合 O,B 是 O 中的一组外部操作(如果 E 中的任何线程以阻塞状态为结果且 O 包含 E 中的所有操作,则 B 也可能包含一个 hang 操作);要么
存在一个操作集合 O,以致于 B 由一个 hang 操作加上 O 中的所有外部操作组成,并且对于所有 k ≥ | O |,存在 P 的具有操作 A 的执行 E,存在一个操作集合 O',以致于:
* O 和 O' 同时是 A 的满足可观察的操作集合需求的子集。
* O ⊆ O' ⊆ A
* | O' | ≥ k
* O' - O 不包含任何外部操作。
请注意,行为 B 不描述 B 中的外部操作被观察到的顺序,但是在如何生成并执行外部操作上的其他(内部)约束可能会施加此类约束。
声明为 final 的字段仅被初始化一次,但在正常情况下从不改变。final 字段的详细语义是不同于那些标准字段的语义。特别是,编译器有很大的自由度,可以在穿过同步屏障和任意参数数量或未知的方法调用的情况下移动 final 字段的读取。相应地,允许编译器使 final 字段的值保持在寄存器中的缓存且在非 final 字段必须被重新加载的情况下不从内存中重新加载它。
final 字段也允许程序员在不使用同步的情况下实现线程安全的不可变对象。线程安全的不可变对象被所有线程视为不可变的,即使使用数据竞争在线程之间传递不可变对象的引用。这可以提供安全保证,防止错误或恶意代码对不可变类的滥用。必须正确地使用 final 字段来提供不可变性保证。
一个对象被视为完全初始化的,当它的构造器完成时。保证仅可以在对象已经完全初始化之后看到该对象引用的线程可以看到该对象的 final 字段正确初始化的值。
final 字段的使用模型相当简单:在对象的构造器中设置该对象的 final 字段,在对象的构造器完成之前不要将正在被构造的对象的引用写入到另一条线程可以看到它的位置。如果遵循了这个,则当另一条线程看到该对象时,该线程将总是看到该对象的 final 字段被正确构造的版本。它还将看到至少和 final 字段一样最新的由那些 final 字段引用的任何对象或数组的版本。
让 o 是一个对象,c 是 o 的一个构造器,其中一个 final 字段 f 被写入。当 c 正常地或异常地离开时,o 的 final 字段 f 上的一个冻结操作发生。
请注意,如果一个构造器调用了另一个构造器,并且被调用的构造器设置了一个 final 字段,此 final 字段的冻结在被调用的构造器的结尾处发生。
对于每个执行,读取的行为受两个额外的偏序影响,解引用链 dereferences() 和内存链 mc(),其被视为执行的一部分(因此,为任何特定的执行而固定)。这些偏序必须满足一下约束(其不需要有唯一的解决方案):
* 解引用链:如果操作 a 是由未初始化对象 o 的线程 t 执行的 o 的字段或元素的读取或写入,则必须存在某个由线程 t 执行的看到 o 的地址的读取 r,这样 r dereferences(r, a)。
* 内存链:在内存链顺序上有几个约束:
如果 r 是一个看到一个写入 w 的读取,则它必须是情况 mc(w, r)。
如果 r 和 a 是满足 dereferences(r, a) 的操作,则它必须是情况 mc(r, a)。
如果 w 是一个由未初始化对象 o 的线程 t 执行的 o 的地址的写入,则必须存在某个由看到 o 的地址的线程 t 执行的读取 r,这样 mc(r, w)。
给定一个写入 w,一个冻结 f,一个操作 a(其不是 final 字段的读取),一个由 f 冻结的 final 字段的读取 r1,和一个读取 r2,这样 hb(w, f)、hb(f, a)、mc(a, r1) 和 deferences(r1, r2),则当确定 r2 可以看到哪个值时,我们认为 hb(w, r2)。(此 happens-before 顺序不传递性地接近其他 happens-before 顺序。)
请注意,解引用顺序是自反的,r1 可以与 r2 相同。
对于 final 字段的读取,仅有的被视为在 final 字段的读取之前到来的写入是通过 final 字段的语义衍生的那些。
构造对象的线程内的此对象的 final 字段的读取按照通常的构造器中该字段初始化的 happens-before 规则进行排序。如果此读取发生在构造器中设置该字段之后,则它将看到已被赋值的 final 字段的值,否则它将看到默认值。
在某些情况下,例如反序列化,系统需要在构造对象之后更改对象的 final 字段的值。可以通过反射和其他实现依赖的方法更改 final 字段。唯一具有合理语义的模式是,构造一个对象,然后更新此对象的 final 字段。此对象不应该对其他线程可见,也不应该读取 final 字段,直到对象的 final 字段的所有更新完成之前。final 字段的冻结同时发生在设置此 final 字段的构造器的末尾,和直接通过反射或其他特殊机制执行的 final 字段的每个修改之后。
即使这样,也有许多困难。如果一个 final 字段在字段声明中被初始化为常量表达式(15.28),此 final 字段的更改不可能被观察到,因为该 final 字段的使用在编译时被常量表达式的值替代了。
另一个问题是,规范允许激进的 final 字段优化。在一条线程中,允许对 final 字段的读取和那些不在构造器中发生的 final 字段的修改进行重排序。
实现可能提供在 final-field-safe 上下文中执行代码块的方法。如果在 final-field-safe 上下文中构造一个对象,则该对象 final 字段的读取将不会与在 final-field-safe 上下文中出现的该 final 字段的修改重排序。
fina-field-safe 上下文具有额外的保护。如果一条线程已经看到一个未正确发布的允许此线程看到 final 字段默认值的对象的引用,然后在 final-field-safe 上下文中读取一个该对象被正确发布的引用,它将被保证看到 final 字段的正确值。在形式主义中,在 final-field-safe 上下文中执行的代码被视为单独的线程(仅用于 final 字段的语义)。
在实现中,编译器不应该将 final 字段的访问移进或移出 final-field-safe 上下文(但是可以在这种上下文执行的周围移动它,只要对象不是在该上下文中构造的)。
适合使用 final-field-safe 上下文的地方是执行器或线程池。通过在单独的 final-field-safe 上下文中执行每个 Runnable,执行器可以保证,由一个 Runnable 到一个对象 o 的不正确访问不会移除同一执行器处理的其他 Runnables 的 final 字段保证。
正常情况下,final static 字段不能被修改。但是,System.in、System.out 和 System.err 是由于遗留原因而必须允许通过方法 System.setIn、System.setOut 和 System.setErr 修改的 static final 字段。我们将这些字段表示为受写保护,以区分普通的 final 字段。
编译器需要对这些字段进行不同于其他 final 字段的处理。例如,普通 final 字段的读取是“免疫”同步影响的:锁或 volatile 读取中涉及的屏障不需要影响从 final 字段读取的什么值。由于写保护字段的值可以被看到更改,同步事件应该在他们上面起作用。因此,语义表示,这些被视为正常字段的字段无法通过用户代码更改,除非用户代码在 System 类中。
Java 虚拟机实现的一个考虑是,每个字段和数组元素都被视为不同的,一个字段或元素的更新不能与任何其他字段或元素的读取或更新相互影响。特别是,两个分别更新一个字节数组的相邻元素的线程不能互相干扰或相互作用,且不需要同步来确保串行一致性。
某些处理器不提供写单个字节功能。在这种处理器上通过简单地读取整个字,更新合适的字节,然后将整个字写回内存来实现字节数组的更新是非法的。这一问题有时被称为字撕裂,在无法轻易地单独更新单个字节的处理器上将需要某个其他方法。
为了实现 Java 内存模型,非 volatile long 或 double 值的单个写入被视为两个单独的写入:一个对应每个 32-bit 一半。这可能产生一种情况,其中一条线程看到来自一个写入的 64-bit 值的前 32 bits,和来自另一个写入的第二个 32 bits。
volatile long 和 double 值的读取和写入总是原子的。
引用的读取和写入总是原子的,不管他们是实现为 32-bit 还是 64-bit。
某些实现可能会发现,将 64-bit long 或 double 值的单个写操作分割为两个在相邻 32-bit 值上写操作,很方便。为了提高效率,这种行为是与实现相关的;Java 虚拟机的实现可以自由地以原子方式或分为两部分方式执行 long 和 double 值的写入。
鼓励 Java 虚拟机的实现在可能的情况下避免拆分 64-bit 值。鼓励程序员将共享的 64-bit 值声明为 volatile 或正确地同步他们的程序,以避免可能的并发问题。