[翻译]JLS7 §17 线程和锁(Threads and Locks)

Motivation

积累Java多线程词汇,再次“全面”考察多线程环境下编程的概念和奇巧淫技。

Ubuntu 14.04 LTS下可用输入法有限,错别字较多,再待译校吧。

17 线程和锁

前面的章节中讨论仅关注作为单个语句或表达式单次执行代码的行为,即单线程(thread),JVM支持多线程同时执行。这些线程执行操作驻留于共享主存中值和对象的代码。多线程可以通过多硬件处理器、时分的单个硬件处理器或时分的多硬件处理器支持。

线程用类Thread表示。用户创建线程的唯一方式是创建该类的对象;每个线程与一个该类对象关联。当Thread对象的start()方法被调用时,对应线程启动。

不正确同步的线程的行为很令人费解、与直觉相悖。这一章描述多线程程序的语义;包含描述共享内存中被多个线程更新的值对读操作可见的规则。因本规范与多种硬件体系结构中内存模型(memory model)很相似,这些语义又称为Java编程语言内存模型。在不引起歧义的情况下,我们将这些规则简记为内存模型(the memory model)。

这些语义并没有规定多线程程序如何执行,而是描述多线程程序允许表现出的行为。任何生成被允许行为的执行策略都是可以接受的执行策略。

17.1 同步(synchronization)

Java语言提供了多种线程间通信的机制。最基本的方式是用监视器(monitor)实现的同步(synchronization)。Java中每个对象都带有一个监视器,线程可以对监视器加锁(lock)或解锁(unlock)。某一时刻只有一个线程持有一个监视器上的锁。任何其他尝试获取该监视器上锁的线程都被阻塞,直到它们可以获得该监视器上的锁。线程t可能多次锁住某个特定的监视器,每个解锁操作作为加锁操作的逆操作。

synchronized语句(§14.19)计算对象的引用;接着尝试对对象的监视器加锁,在加锁操作成功完成之前不会执行。在加锁动作完成后,synchronized语句体开始执行。该语句体执行完成后,不管是正常结束还是突然中止,对同一监视器的解锁动作自动执行。

synchronized方法(§8.4.3.6)被调用时,自动执行加锁动作;在加锁动作成功完成后方法体开始执行。如果该方法是实例方法,它会锁住该实例对应的监视器(即,方法体执行中this对应的对象)。如果方法是静态的(static),它会锁住方法定义所在类的Class对象的监视器。该方法执行完成后,不管是正常结束还是突然中止,对同一监视器的解锁动作自动执行。

Java语言不阻止也不要求死锁条件检测。使用线程(间接或直接)持有多个对象上锁的程序应该使用常见的死锁避免技术,必要时创建不产生死锁的高级加锁原语。

其他机制,如volatile变量的读写、使用java.util.concurrent包中的类,提供了同步的其他实现方式。

17.2 等待集(wait set)和通知(notification)

每个对象处有一个相应的监视器外,还有一个等待集(wait set)。等待集是线程的集合。

当对象首次创建时,其等待集是空的。向等待集添加和移除线程的基础性操作是原子的。等待集上的操作主要是通过Object.waitObject.notifyObject.notifyAll

等待集操作也受线程中断状态和Thread类中处理中断的方法影响。另外,Thread类中用于休眠和连接其他线程的方法具有这些等待和通知操作推导出的属性。

17.2.1 等待

等待动作在wait()方法调用时出现,或是其他形式的wait()方法:wait(long millisecs)wait(long millisec, int nanosecs)

参数均为0的wait(long millisecs)wait(long millisec, int nanosecs)等价于wait()

线程从等待中正常返回是指等待中没有抛出InterruptedException异常。

令线程t正在对象m上执行wait方法,ntm上执行加锁动作但未执行相应解锁动作的次数,会发生下述动作:

  • 如果n=0(即t还没有持有m的锁),会抛出IllegalMonitorStateException异常。

  • 如果这是一个有超时的等待,nanosecs不在0-999999范围内,或者millisecs为负,会抛出IllegalArgumentException

  • 如果t被中断,会抛出InterruptedException异常,t的中断状态置为false

  • 其他:

(1) t被加入m的等待集,执行对象mn次解锁动作。

(2) t在被移出对象m的等待集前不执行任何指令。因下述动作的发生,线程可能被移出等待集,并随后恢复:
(2.1) mnotify动作被执行,t被选中移出等待集。
(2.2) mnotifyAll动作被执行。
(2.3) tinterrupt动作被执行。
(2.4) 如果这是一个有超时的等待,至少在等待指定的超时时间后,一个内部动作将tm的等待集中移除。
(2.5) 实现特定的内部动作。伪唤醒(spurious wake-ups)实现允许但不被建议,即将线程从等待集中移除后允许不用明确的指令即允许恢复。

注意这一条款约定了使用wait方法的编码实践,wati()只用于线程等待持有的逻辑条件满足时退出循环这一场景。

每个线程必须就可以将其从等待集中移除的事件确定顺序。事件的顺序不一定与事件发生的顺序一致,但线程必须表现出按所确定事件顺序处理的行为。

例如,如果线程t在对象m的等待集中,t上中断和m上通知同时出现,这两个事件必须要有一个顺序。如果需要优先处理中断,则t最终通过抛出InterruptedException异常从wait方法中返回,其他在m等待集中的线程(如果通知发生时存在)必须接收到通知。如果需要优先处理通知,则t最终从wait中正常返回,中断仍在等待返回。

(3) tm上执行n次加锁动作。

(4) 如果t在步骤(2)中因中断而被从m的等待集中移除,则t的中断状态置为falsewait方法抛出InterruptedException异常。

17.2.2 通知

通知动作在notifynotifyAll方法被调用时发生。

令线程t正在对象m上执行这些方法,ntm上执行加锁动作但未执行相应解锁动作的次数,会发生下述动作:

  • 如果n=0,会抛出IllegalMonitorStateException异常。

这种情况是t还没有持有m上的锁。

  • 如果n>0,是notify动作,则如果m的等待集非空,其中线程u被选中从等待集中移除。

等待集中哪个线程被选中没有保证。移除u使其可以继续执行。注意,u自恢复后的加锁动作直到t完全释放m的监视器锁之后才会成功。

  • 如果n>0,是notifyAll动作,则m等待集中所有线程被移除,继续执行。

注意,在从等待中恢复时它们只有一个线程成功获得监视器锁。

17.2.3 中断

中断动作发生在Thread.interrupt方法调用、隐式调用前者的一些方法调用时,例如ThreadGroup.interrupt

令线程t在调用线程uinterrupt方法,这里tu可以是同一线程。这一动作导致u的中断状态置为true

此外,如果对象m的等待集中包含u,则u从该等待集中被移除。这使得u在等待动作中继续(???),在这种情况中,等待动作在重新获得m的监视器锁后抛出InterruptedException异常。

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

17.2.4 等待、通知和中断的关系

上面的阐述允许我们确定等待、通知和中断三者之间关系的属性。

如果线程在等待中同时被通知和中断,将表现出:

  • wait中正常返回,中断仍在等待返回(即调用Thread.interrupted返回true);或者

  • 抛出InterruptedException异常从wait中返回。

线程可能并不重置中断状态,从wait调用中正常返回。

类似的,通知不能因中断而丢失。假设对象m上的等待集s,另一个线程调用mnofify方法,会发生:

  • s中至少一个线程必须从wait中正常返回;或者

  • s中所有线程必须抛出InterruptedException异常从wait中退出。

注意,如果一个线程同时被中断、被notify唤醒,该线程必须抛出InterruptedException异常从wait中返回,等待集中的其他线程必须被通知到。

17.3 Sleep和Yield

Thread.sleep导致当前执行的线程休眠(时间上停止执行)一段时间,这段时间因系统定时器和调度器精度不同不定。休眠的线程没有丢失任何监视器的属主关系,怎样恢复执行依赖于调度和可用的处理器数量。

值得注意的是Thread.sleepThread.yield都没有同步语义。通常,编译器不一定要在调用Thread.sleep或者Thread.yield之前将寄存器中的写缓存刷新到共享内存中,也不一定要在调用Thread.sleep或者Thread.yield之后将值重新加载到寄存器中。

例如,在下面的代码片段中,假设this.done是一个非volatileboolean字段:

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

编译器可以仅读取this.done一次,在循环执行中重用该缓存的值。这意味着循环不会终止,甚至在其他线程修改了this.done的值时也是这样。

17.4 内存模型

内存模型(memory model)描述的是,给定一个程序和该程序的执行轨迹,该执行轨迹是否是合法的程序执行。Java语言的内存模型工作方式是,根据一些规则,确定执行轨迹中每个读动作、判断读动作中可观察的写值是否有效。

内存模型描述了程序可能的行为。实现可以自由的生成代码,只要最终的程序执行结果可以被内存模型预测到。

这给实现者极大的自由做代码转换,包括指令重排序、移除不必要的同步等。

例17.4-1 可能表现奇怪行为的不正确同步的程序
Java编程语言的语义允许编译器和微处理器执行优化,而与不正确同步的代码一起工作会产生自相矛盾的行为。这里有一些不正确同步的程序如何展示出奇怪行为的程序实例。

考虑表17.1中的程序轨迹。该程序使用局部变量r1r2和两个共享变量AB。开始时,A == B == 0

表17.1 语句顺序引起的奇怪结果 - 原始代码

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

可能无法出现执行结果r2 == 2r1 == 1。本质上,指令1或者指令3应该在执行中首先出现。如果先执行指令1,它不应该能够看到指令4中的写操作结果。如果先执行指令3,它不应该能够看到指令2中的写操作结果。

如果有执行结果表现出这种行为,则我们可以获知指令4在指令1前执行,而指令1又在指令2前执行,指令2在指令3前执行,指令3又在指令4前执行。这是不合理的。(该段前提是指令1和指令3都能看到相应的写操作结果、线程中指令按程序编写顺序先后执行)。

然而,在不影响线程在隔离环境中执行条件下,编译器可以对线程中指令重新排序。如果指令1和指令2按表17.2中轨迹重新排序,则很容易看出执行结果r2 == 2r1 == 1是如何出现的。

表17.2 语句顺序引起的奇怪结果 - 有效的编译器转换

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

对某些程序员来谁,这种行为是错误的。然而,应该注意到这段代码没有恰当的同步:

  • 线程中有写操作,
  • 线程读共享变量,
  • 读写操作没有经同步排序。

这种情况是数据竞争(data race, §17.4.5)的一个例子。当代码中包含数据竞争时,违背直觉的结果经常会出现。

一些机制可以产生表17.2中重排序。JVM实现的Just-In-Time编译器或者处理器可能重新安排代码。此外,JVM实现的内存层次体系结构可能使其表现出代码被重排序了。本章中,我们将可以执行代码重排序的工具称为编译器。

另一个例子见表17.3。开始时,p == qp.x == 0。这段程序也没有正确的同步,它在没有指定写操作之间顺序的情况下写共享内存。

表17.3 前向置换引起的奇怪结果

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

一个常见的编译器优化是将从r2读到的值给r5重用:它们都是读r1.x且两条指令之间没有对该变量的写操作。这种情况展示在表17.4中。

表 17.4 前向置换引起的奇怪结果

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

现在考虑这种情况,线程中r6.x = 3;指令在r2 = r1.x;r4 = r3.x;之间执行。如果编译器决定将r2的值在r5上重用,则r2r5均有值为0,r4有值3。从程序员的角度来说,存在p.x的值从0变为3,又变了回来。

内存模型确定程序中每一点什么值可以读。独立线程的动作必须表现出受该线程的语义控制,例外是每个读操作可见的值由内存模型确定。我们将这称为线程内语义(intra-thread semantics)。线程内语义用于单线程程序的,允许基于线程中读操作可见的值完整的预测程序的行为。为确定线程t在执行中的动作是否合法,我们简单的评估在单线程环境中线程t的实现,见下文。

每次线程t的评估产生线程内动作,必须与程序顺序中下一个线程内动作a匹配。如果a是一个读操作,则后续t的评估将使用从a中读到的值,这是由内存模型确定的。

这一节提供除处理final字段问题外(���§17.5),Java语言内存模型的描述。

这里阐述的内存模型不是作为基于Java语言面向对象本质的基础。为保持实例中的精确性和简单性,我们经常展示不包含类、方法定义或显式引用的代码片段。大多数实例由两个或多个包含访问局部变量、共享全局变量或者对象实例字段语句的线程。我们用r1r2作为变量名称表示方法或者线程的局部变量。这些变量不能被其他线程访问。

17.4.1 共享变量

线程间共享内存称为共享的内存(shared memory)或堆内存(heap memory)。

所有实例字段、static字段和数组元素均在堆内存中存储。本章中,我们用术语变量(varibal)表示字段和数组元素。

局部变量(§14.4)、形式方法参数(§8.4.1)、异常处理参数(§14.20)永远不再线程间共享,不受内存模型影响。

对同一变量的两个访问(读或写)是冲突的(conflicting),指至少一个访问是读操作。

17.4.2 动作

线程间动作(inter-thread aciton)是由一个线程执行的动作,这一动作可以被另一个线程检测到或者直接影响。程序中可以执行的线程间动作包括:

  • 读(常规,非volatile)。读变量。
  • 写(常规,非volatile)。写变量。
  • 同步动作,包括
    ++ volatile读,变量易失性读。
    ++ volatile写,变量易失性写。
    ++ 加锁。监视器加锁。
    ++ 解锁。监视器解锁。
    ++ (虚构的)线程的第一个和最后一个动作。
    ++ 启动线程或检测线程已结束的动作(§17.4.4)。
  • 外部动作(external actions)。外部动作是可以从执行外部观察到的动作,产生的结果依赖于执行的外部环境。
  • 线程分离动作(divergence actions, §17.4.9)。线程分离动作只会由在没有内存、同步或外部动作的无限循环中的线程执行。如果线程执行分离动作,后续将有无限数量的线程分离动作。

引入线程分离动作是为了对线程如何导致其他线程停顿和失败建模。

本规范只关注线程见动作。我们不需要考虑线程内部动作(例如,将两个局部变量相加、将结果存在第三个局部变量中)。如前所述,所有线程需要遵守Java程序的线程内部语义。自此,我们将线程间动作简称为动作。

动作a由四元组(t, k, v, u)定义:

  • t - 执行动作的线程
  • k - 动作的类型(kind)
  • v - 动作中涉及的变量或监视器

对于锁操作,v是被锁住的监视器;对于解锁操作,v是被解锁的监视器。

如果是读动作(易失或非易失),v是被读的变量。

如果是写动作(易失或非易失),v是被写的变量。

  • u - 动作的标识符。

外部动作的元组包含额外一个元素,该元素包括执行该动作的线程感知到的外部动作的执行结果。这可以是动作执行成功或失败的信息,或者该动作读取的任何值。

传递给外部动作的参数(例如:写入socket的byte流)不是外部动作元组的一部分。这些参数由线程中其他动作设置,可以通过线程内部语义确定。这些内容不会在内存模型中显式讨论。

在不可终止执行中,不是所有的外部动作可以被观察到的。不可终止执行和可观察的动作将在§17.4.9中讨论。

17.4.3 程序和程序顺序

在所有线程t的线程间动作中,t的程序顺序(program order)是一个全序,反映了根据t的线程内语义动作执行的顺序。

动作集称为顺序一致性(sequentially consistent)是指,动作按全序(执行顺序)发生,与程序顺序一直,并且每个对变量v的读操作r可见写动作wv写入的值:

  • 执行顺序中wr之前发生,且
  • 执行顺序中没有其他写动作w'ww'之前发生,w'在'r'之前发生。

顺序一致性是一类对程序执行中可见性(visibility)和顺序性很强的保证。在顺序一致性执行中,所有独立动作(如读和写)间有一个与程序的顺序一致的全序,并且每个独立动作是原子的、立即对其他线程可见。

如果程序没有数据竞争,则程序的所有执行将表现出顺序一致性。

顺序一致性和/或无数据竞争仍然无法避免错误在一组需要被视为原子性但不是的操作中出现。

如果我们采用了顺序一致性,多种之前讨论的编译器和处理器优化就不是合法的。例如,在表17.3的程序轨迹中,在p.x = 3;写操作后,后续对该位置的读需要见到被写入的值。

17.4.4 同步顺序

每个执行有一个同步顺序(synchronization order)。同步顺序是指一次执行中所有同步动作的一个全序。对每个线程tt中同步动作(§17.4.2)的同步顺序与t的程序顺序(§17.4.3)一致。

同步动作包含动作间的相同步(synchronized-with)关系,定义如下:

  • 监视器m上的解锁动作与随后的m上的加锁动作同步(这里随后的一词由同步顺序定义);
  • 写volatile变量v与任意线程随后的读v同步(这里随后的一词由同步顺序定义);
  • 启动线程动作与该启动线程的第一个动作同步;
  • 写变量为默认值(0, falsenull)与每个线程的第一个动作同步;

尽管在包含变量的对象分配空间前将变量写为默认值看起来很奇怪,但从概念上在程序启动时每个对象创建时使用其默认的初始值。

  • 线程T1的最后一个动作与另一个线程T2检测到T1已经终止(terminated)同步;

T2可以通过调用T1.isAlive()T1.join()判断T1已终止。

  • 如果线程T1中断(interrupt)线程T2T1产生中断与任意时刻其他线程(包括T2)确定T2已被中断同步(抛出InterruptedException异常或通过调用Thread.isterruptedThread.isInterrupted)。

相同步(synchronizes-with)边的起点称为释放(release),终点称为获取(acquire)。

17.4.5 happens-before顺序

两个动作可以按happens-before关系排序。如果一个动作happens-before另一个动作,则第一个动作是可见的且排在第二个动作之前。

如果有两个动作x, y,用hb(x, y)表示x happens-before y。

  • 如果x, y在同一线程中,按程序顺序xy之前,则hb(x, y)
  • 对象的构造器和对象的终止器(finalizer, §12.6)开始之间存在happen-before关系;
  • 如果xy相同步,则有hb(x, y)
  • 如果hb(x, y)hb(y, z),则hb(x, z)

Object(§17.2.1)的wait方法有相应的加锁和解锁动作,这些wait方法之间的happens-before关系由这些动作定义。

值得注意的是动作间happens-before关系的存在并没有强制实现中动作必须按此顺序发生。如果重排序过程(reordering procedures)结果与合法的执行兼容,则并不是非法的。

例如,由线程创建的对象,将其字段写为默认值并不需要happens-before该线程的开始,只要没有读操作观察到这一事实。

更具体的讲,如果两个动作间可以存在happens-before关系,相对于任意一段它们没有happens-before关系的代码,它们并不需要一定按次关系顺序出现(WTF???)。例如,在数据竞争(data race)的一组线程中,一个线程多次写、另一个线程多次读,这些读动作呈现出乱序。

happens-before关系定义了数据竞争何时发生。

相同步关系集合S是充分的(sufficient),仅当它是最小的集合,满足S的程序顺序传递闭包可以确定执行中所有的happens-before关系。该集合是唯一的。

由上述定义有:

  • 监视器上的解锁动作happens-before随后该监视器上的枷锁动作;
  • 写volatile字段(§8.3.1.4)happens-before随后对该字段的读;
  • 调用线程的start()方法happens-before该已启动线程的任意动作;
  • 线程t1调用线程t2join()方法,则t2的所有动作happens-beforet1成功从join()方法返回;
  • 程序中对象的默认初始化动作happens-before任何其他动作。

当程序中有两个冲突访问(§17.4.1),访问动作间没有happens-before关系时,称该程序存在数据竞争(data race)。

除线程间动作外的一些操作的语义,包括读数组长度(§10.7)、受检转型(§5.5,§15.16)、虚拟方法调用(§15.12),不受数据竞争直接影响。

因此,数据竞争不会导致诸如返回错误的数组长度这类不正确的行为。

一个程序是正确同步的(correctly synchronized),当且仅当所有顺序一致的执行均没有数据竞争。

如果程序是正确同步的,则该程序的所有执行将呈现出顺序一致性(§17.4.3)。

这对于程序员来说是很强的保证。程序员不需要考虑重排序来确定其代码包含数据竞争。因此,程序员在确定其代码是正确同步时,不需要考虑重排序。一旦确定代码是正确同步的,程序员不需要担心重排序对代码产生的影响。

程序必须是正确同步的,才可以避免代码被重排序后可以被观察到的违背直觉的行为。使用正确的同步并不能保证程序的整体行为是正确的。然而,使用正确的同步可以帮助程序员以一种简单的方式预测程序可能的行为;正确同步程序的行为更少的以来可能存在的重排序。如果没有正确的同步,奇怪的、令人困惑的和违背直觉的行为可能会发生。

对变量v的读r可以观察到对v的写w,仅当,在执行轨迹的happens-before偏序关系中:

  • r不排在w之前(即不存在hb(r, w)),且
  • 中间不存在对v的写w'(即不存在写w'满足hb(w, w')hb(w', r))。

简单的说,写动作r可以观察到写动作w,仅当不存在happens-before关系阻止动动作的发生。

一个动作集合A是happens-before一致的,仅当,对A中所有读动作rW(r)r可以观察到的写动作,不存在hb(r, W(r)),且A中不存在写动作W满足w.v = r.vhb(W(r), w)hb(w, r)

在happens-before一致的动作集合中,每个读动作可以观察到按happens-before顺序它可以观察到的写动作。

例17.4.5-1 happens-before一致性
在表17.5的轨迹中,开始时 A == B == 0。可以观察到 r2 == 0、r1 == 0时轨迹仍然是happens-before一致的,因为存在执行顺序允许每个读可以观察到恰当的写。

表17.5 被happens-before一致性允许、但不被顺序一致性允许的行为

Thread1             Thread2
1: B = 1;           3: A = 2;
2: r2 = A;          4: r1 = B;

因没有同步,每个读可以观察到初始值写动作或其他线程的写动作。展示这一行为的一个执行顺序是:

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

另一个happens-before一致的执行顺序是:

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

在这一执行中,读动作观察到在执行顺序中随后发生的写动作。这似乎是违背直觉的,但是被happens-before一致性允许的。允许读动作观察到随后的写动作有时会产生不可接受的行为。

17.4.6 执行

执行E由元组(P,A,po,so,W,V,sw,hb)定义:

  • P - 程序
  • A - 动作集合
  • po - 程序顺序(porgram order),对每个线程t,是t执行在A中动作的一个全序
  • so - 同步顺序(synchronization order),是A中所有同步动作的一个全序
  • W - 一个写可见函数,对A中读动作rW(r)Er可见的写动作
  • V - 一个值写函数,对A中读动作wV(w)Ew写入的值
  • sw - 相同步(synchronizes-with),同步动作上的一个偏序
  • hb - happens-before,动作上的一个偏序

注意到相同步(sw)和happens-before(hb)元素是由执行中其他元素和良式执行规则(§17.4.7)唯一确定的。

一个执行是happens-before一致的,仅当,其动作集合是happens-before一致的(§17.4.5)。

17.4.7 良式执行

我们只考虑良式执行。执行E=(P,A,po,so,W,V,sw,hb)是良式的,仅当:

  1. 执行中每个读观察到对同一变量的写。
    volatile变量的读写动作是volatile动作。对A中所有读动作r,有AW(r)满足W(r).v = r.v。变量r.v是volatile的,当且仅当r是volatile读,变量w.v是volatile的,当且仅当w是volatile写。

  2. happens-before顺序是一个偏序。
    happens-before顺序由相同步关系和程序顺序的传递闭包给定。它必须是合法的偏序:自反、传递和反对称。

  3. 执行遵循线程内一致性。
    对每个线程tt执行A中的动作与t隔离独立执行效果一致,每个写动作w写入值V(w),每个读动作r可以观察到值V(W(r))。每个线程可见的值由内存模型确定。给定的程序顺序必须反映出其动作依据P的线程内语义执行。

  4. 执行是happens-before一致的(§17.4.6)。

  5. 执行遵循同步顺序一致性。
    A中所有volatile读动作r,不存在so(r, W(r)),且A中不存在写动作w满足w.v=r.vso(W(r), w)so(w, r)

17.4.8 执行和因果需求

用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中的提交(committing)动作检验。如果A中所有动作可以被提交,则该执行满足Java编程语言内存模型的因果性需求。

从空的提交动作集合C0开始,执行A中的动作并将它们添加提交动作集合中,由Ci产生新的Ci+1。为展示其合理性,对于每个Ci,需要展示包含Ci的执行E满足一些特定的条件。

正式的,执行E满足Java编程语言内存模型的因果性需求,当且仅当:

  • 动作集合C0,C1,...有:
    ++ C0是空集
    ++ Ci是Ci+1的真子集
    ++ A = U(C0,C1,...)
    如果A是有限集,则C0,C1,...序列将是有限的,最终Cn=A
    如果A是无限集,则C0,C1,...序列可以是无限的,该无限集中元素并集必须等于A

  • 良式执行E1,...,Ei=(Pi,Ai,poi,soi,Wi,Vi,swi,hbi)。

给定动作集合C0,...和执行E1,...,Ci中每个动作必须是Ei的动作集合中。Ci中所有动作必须持有Ei和E中相同的happens-before顺序和同步顺序关系。正式定义如下:

  1. Ci是Ai的子集
  2. hbi|Ci = hb|Ci
  3. soi|Ci = so|Ci

Ci中写动作写入的值必须与Ei和E中一致。只有Ci-1中的读动作需要观察到Ei和E中对同一变量的写入。正式定义如下:

  1. Vi|Ci = V|Ci
  2. Wi|Ci-1 = W|Ci-1

Ei中不在Ci-1中的读动作必须观察到happens-before这些读动作的写动作。Ci-Ci-1中每个读动作r必须观察到Ei和E中Ci-1中的写动作,但可能观察到Ei中的写动作与E中的写动作不同。正式定义如下:

  1. 对于Ai-Ci-1中每个读动作r,有hbi(Wi(r), r)
  2. 对于Ci-Ci-1中每个读动作r,Ci-1中有Wi(r)、Ci-1中有W(r)

给定Ei的充分相同步关系集合(ssw),如果存在有释放-获取对happens-before提交动作,则该释放-获取对必须存在于所有的Ej中,这里j>=i。正式定义如下:

  1. sswi是swi中在hbi但不在po的传递归约的关系集合,称sswi为Ei的充分相同步关���集合。如果sswi(x,y), hbi(y,z),z在Ci中,则有swj(x,y),这里j>=i。
    如果动作y已提交,则happens-before y的所有外部动作也已被提交。
  2. 如果y在Ci中,x是一个外部动作,且有hbi(x,y),则x在Ci中。

例17.4.8-1 happens-before一致性不是充分的
happens-before一致性是必要但不是充分的约束。不实施happens-before一致性将产生不可接受的行为,这些行为违背了已为程序建立的需求。例如,happens-before一致性允许允许值凭空出现(out of thin air)。这从表17.6中的程序轨迹可以看出。

表17.6 happens-before一致性不是充分的

Thread1               Thread2
r1 = x;               r2 = y;
if(r1 != 0) y = 1;    if(r2 != 0) x = 1;

表17.6中代码是正确同步的。这看起来很奇怪,它并没有执行任何同步动作。需要记住,程序按顺序一致行为执行且没有数据竞争时,程序是正确同步的。如果这段代码按顺序一致方式执行,每个动作将按程序顺序发生,同时所有写动作不会发生。因没有写动作发生,不可能存在数据竞争,所以该程序是正确同步的。

因这段程序是正确同步的,我们只能观察到顺序一致的行为。但是,该段程序存在一个执行满足happens-before一致性,但不满足顺序一致性:

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

该结果是happens-before一致的:不存在例外的happens-before关系。但是,明显的这是不可以接受的:不存在会产生该结果的顺序一致的执行。允许读动作观察到在执行顺序中随后出现的写动作,有时会导致不可接受的行为。

尽管允许读动作观察到在执行顺序中随后出现的写动作这种方式不可取,但有时确是必要的。正确在表17.5中看到的,允许一些读动作观察到在执行顺序中随后出现的写动作。因读动作在每个线程中首先出现,执行顺序中第一个动作必须是读动作。如果该动作不能观察到随后出现的写动作,则它不能看到除变量初始化外的值。这显然没有反映出所有的行为。

我们将读动作可观察到将来写动作的问题称为因果性(causality),因为该问题会随像在表17.6中的情况出现。这表17.6中,读动作导致写动作发生,写动作导致读动作发生。并没有第一起因。因此,我们的内存模型需要以一种一致的方式确定哪些读动作可以预先观察到写动作。

表17.6中的例子展示该规范就声明读动作是否可以观察到执行中随后出现的写动作必须足够谨慎(记住,如果读动作可以预先观察到随后发生的写动作,这表示写动作实际上先期发生)。

这里内存模型将给定执行、程序作为输入,确定该执行是否是该程序的合法执行。该方法是通过构建反映程序那些动作被执行的一个提交集合完成的。通常,下一被提交的动作会反映按顺序一致执行中可以执行的下一动作。然而,为允许读动作可以预先观察到写动作,我们也允许一些动作在happens-before它们的动作提交之前提交。

显然,一些动作会被先期提交,而另一些不能。如果,表17.6中一个写动作在读同一变量的读动作之前提交,该读动作可见该值,就发生了凭空出现的值。简单的说,如果我们知道一个动作的发生不会导致数据竞争,可以预先提交该动作。在表17.6中,因写动作的发生会让读动作观察到数据竞争,我们不能先执行写动作。

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

对于总是在有界的有限时间内终止的程序,可以简单的用程序允许的执行来理解它们的行为。不能在有界时间内终止的程序会产生一些微妙的问题。

程序可观察的行为(observable behavior)由该程序所执行的有限外部动作集合定义。例如,一个简单的总在打印"Hello"的程序是通过打印"Hello" i次的行为集合,这里i是非负整数。

不将终止(termination)显式表示为行为,但可以很容易的扩展程序,纳入一个额外的外部动作executionTermination,在所有线程终止后程序执行这一动作。

此外,我们也定义了一个特殊的停顿(hang)动作。如果行为由包含停顿动作在内的动作集合定义,表示该行为是在观察到外部动作后,程序可以不执行其他外部动作或终止而运行任意一段时间。在所有线程被阻塞或者程序不执行外部动作而执行任意数量的操作情况下,程序停顿。

线程可在多种环境下被阻塞,如线程尝试获取锁时、执行一个依赖于外部数据的外部动作(如读)。

一个执行可能导致一个线程被永久阻塞,该执行永不终止。在这中情况下,被阻塞线程产生的动作必须由该线程直到被阻塞时所产生的一系列动作、导致该线程被阻塞的动作构成,不包括阻塞后产生的动作。

为阐述可观察行为,我们需要定义可观察行为的集合。

如果O是执行E的一个可观察动作的集合,则O必须是E中动作集合A的子集,且无论A是否是无限的,O总是有限的。更进一步,如果y是O中动作,hb(x,y)或so(x,y),则动作x也在O中。

注意可观察行为集合中的行为并不局限于外部动作。可观察动作集合中的外部动作称为可观察的外部动作。

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

  • 存在P的一个执行E,O是E的可观察动作集合,B是O中的外部动作集合(如果E中任何线程以被阻塞状态结束、O包含E中所有动作,则B中同时还有一个停顿动作);或者
  • 令O是动作集合,B由停顿动作和O中所有外部动作构成,A是P的执行E中动作集合,对k>=|O|,存在动作集合O‘满足:
    ++ O和O‘都是A中满足可观察动作的集合的子集;
    ++ O ⊆ O' ⊆ A;
    ++ |O'| >= k;
    ++ O' - O不包含外部动作。

注意行为B没有描述B中外部动作被观察的顺序,但是另外有一些外部动作如何产生和执行的(内部)约束可能会暗示这些约束。

17.5 final字段的语义

正常情况下,声明为final的字段被初始化一次,随后永不改变。final字段的详细语义与常规字段的不同。特别的,编译器可以自由的将读final字段操作越过同步障碍,调用任意或未知的方法。既而,编译器可以将final字段的值缓存在寄存器中,在非final字段需要重新从内存中加载的情况下不用加载该字段值。

final字段可以帮助程序员实现不需要同步的线程安全不可变对象。线程安全不可变对象对所有线程可见,甚至在使用数据竞争在线程间传递不可变对象的引用时。这提供了避免错误或恶意实现的不可变类误用的保证。final字段必须被正确使用,以提供不可变性的保证。

当对象的构造器结束时,该对象被认为是完全已初始化的(completely initialized)。如果线程只能看到完全已初始化的对象引用,则该线程能够看到该对象中final字段被正确初始化的值。

final字段的用法很简单:在对象的构造器中设置final字段,在对象构造器结束之前,不将该对象的引用写入其他线程可见的地方。这样,当其他线程看到该对象时,总是看到该对象中正确初始化的final字段。同时,也可以看到final字段引用的对象或数组的版本至少与final字段一样新。

例17.5-1 Java内存模型中final字段
下面的程序展示了final字段与常规字段的不同。

class FinalFieldExample {
    final int x;
    int y;
    static FinalFieldExample f;

    public FinalFieldExample() {
        x = 3;
        y = 4;
    }

    static void writer() {
        f = new FinalFieldExample();
    }

    static void reader() {
        if (f != null) {
            int i = f.x; // guaranteed to see 3
            int j = f.y; // could see 0
        }
    }
}

FinalFieldExample中有final int字段x和一个非final int字段y。可能有一个线程执行writer方法,另一个线程执行方法reader

writer方法在对象的构造器结束后写入f,reader方法将保证可见看到f.x被正确初始化的值3.然而,f.y不是final的,reader方法不会保证可看到值4.

例17.5-2 安全中的final字段
final字段可用于设计必要的安全保证。考虑下面的程序,线程1执行:

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

线程2执行:

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

String对象是不可变的,其操作没有同步。尽管String的实现没有数据竞争,但使用String对象的代码可能引入数据竞争,内存模型对有数据竞争的程序只有很弱的保证。特别的,如果String字段不是final的,线程2很可能开始时看到String对象的偏移量的默认值0,依此跟"/tmp"比较。该String对象的随后操作可能看到正确的偏移量4,即"/usr"。Java编程语言的很多安全特征依赖于String对象的不可变性,甚至在恶意代码使用数据竞争在线程间传递String引用时也需要保持不可变。

17.5.1 final字段的语义

o是对象,c是o的构造器,final字段f在c中写入。在c正常或突然结束时,一个o.f上的冻结(freeze)动作会发生。

注意,如果一个构造器调用了另一个构造器,被调用的构造器设置了一个final字段,该final字段上的冻结动作在被调用的构造器结束时发生。

对每个执行,读动作的行为受两个额外的偏序影响,解引用链(dereference chain) dereference()和内存链(memory chain) mc(),它们被视为执行的一部分(因此是任何执行固定的一部分)。这些偏序必须满足下面的约束(这些约束的满足不需要唯一的解决方法):

  • 解引用链 如果线程t没有初始化对象o,a是t中对o的字段或元素的一个读或写动作,则t中必须存在一个读动作r看到o的地址:dereferences(r, a)。
  • 内存链 内存链顺序有一些约束:
    ++ 如果读r观察到写w,则mc(w, r);
    ++ 如果动作r, a有dereferences(r, a),则mc(r,a);
    ++ 如果未初始化对象o的线程中的有一个写入o地址的写动作w,则t中一定存在读r可以看到o的地址: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, r1)。(这里的happens-before顺序不能构成传递闭包。)

注意,dereferences顺序是自反的,r1, r2可以是同一动作。

对于final字段的读动作来说,被视为在这些读动作之前发生的写动作,是由final字段的语义确定的。

17.5.2 构造阶段读final字段

构造对象的线程中读对象的final字段这一动作,是按照常规的happens-before规则,依据构造器中对该字段的初始化方式确定顺序的。如果读动作在构造器中字段设置后发生,它可以看到final字段被赋予的值;否则它看到字段的默认值。

17.5.3 final字段的后继修改

在一些情况下,例如反序列化,系统需要在对象构造后改变final字段。final字段可以通过反射和其他依赖于实现的方法改变。唯一有合理的语义的模式是,对象已构建,对象的final字段被更新;在对final字段的所有更新完成前,该对象对其他线程不可见、final字段不可读;final对象的冻结动作发生在构造器结束时(这里final字段被设置),以及通过反射或其他机制修改final字段后。

还是存在一些复杂的问题。如果final字段被声明编译时常量表达式(§15.28),因final字段在编译时被常量表达式替换,如何变为final的一些动作将不能被观察到。

另一个问题是,规范允许对final字段的激进优化。线程被允许将final字段的读与在构造器之外的修改重排序。

例17.5.3-1 final字段的激进优化

class A {
    final int x;

    A() {
        x = 1;
    }

    int f() {
        return d(this,this);
    }

    int d(A a1, A a2) {
        int i = a1.x;
        g(a1);
        int j = a2.x;
        return j - i;
    }

    static void g(A a) {
    // uses reflection to change a.x to 2
    }
}

在方法d中,编译器被允许自由的就x的读和调用g重排序。因此new A().f()可能返回-1, 0或1。

实现可以提供在final字段安全上下文中执行一段代码的方法。如果对象在final字段安全上下文被构建,对该字段的读和修改不会被重排序。

final字段安全上下文有额外的保护。如果线程已看到未被正确发布的对象引用允许看到该对象final字段的默认值,则在final字段安全上下文中,读该对象被正确发布的引用时将保证看到final字段正确的值。正式的说,在final字段安全上下文中执行打代码被视为一个独立的线程(仅对final字段语义的意图来说)。

在实现中,编译器不应该将对final字段的访问移进或移出final字段安全上下文(只要对象不是在该上下文中构建的,则可以移动)。

合理使用final字段安全上下文的地方是executor或线程池。在独立的final字段安全上下文中执行Runnable,executor可以保证其处理的Runnable中,单个Runnable对对象o的错误访问不会影响final字段保证在其他Runnable中的作用。

17.5.4 写保护字段

通常,声明为final和static的字段不会被修改。然而,与遗留系统兼容原因,System.inSystem.outSystem.err是static final字段,但是可以通过System.setInSystem.setOutSystem.setErr改变。我们将这些字段称为写保护(write-protected)字段,以便与常规final字段区分。

编译器需要特殊对待这些字段。例如,读常规的final字段不受同步的影响:锁中的障碍或volatile读并不需要影响从final字段中读到的值。因写保护字段中的值能够被看到变化,同步事件会对这些字段有所影响。因此,语义强制约束,除System中代码外,这些字段不可改变。

17.6 字裂(word tearing)

JVM实现的一个考虑是每个字段和数组元素是被区别对待的;更新一个字段或数组元素必须不能影响对其他字段或元素的读或更新。特别的,两个线程更新字节数组中的相邻元素必须不能产生干扰或相互影响,同时不需要同步以保证顺序一致性。

一些处理器没有提供写单个字节的功能。在这样的处理器上,简单的读或写整个字(word)来实现字节数组是不合法的。这一问题称为字裂(word tearing),在不能独立的更新单个字节的处理器上需要其他的方法。

例17.6-1 检测词裂
下面的程序可以用于检测词裂:

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 v = 0;
        for (int i = 0; i < ITERS; i++) {
            byte v2 = counts[id];
            if (v != v2) {
                System.err.println("Word-Tearing found: " + "counts[" + id + "] = "+ v2 + ", should be " + v);
                return;
            }
            v++;
            counts[id] = v;
        }
    }

    public static void main(String[] args) {
        for (int i = 0; i < LENGTH; ++i)
            (threads[i] = new WordTearing(i)).start();
    }
}

其思路是写入相邻字节不能覆盖其他字节。

17.7 double和long的非原子对待

按Java编程语言内存模型的目的,单个对非volatile的long或double值的写被视为两个独立的写:每个对应32-bit。这会导致一个线程可能看到由一个线程在高位32-bit中写入的值,另一个线程在低位32-bit中写入的值。

读写volatile的long和double值总是原子的。

不管引用是被作为32-bit还是64-bit值实现的,读写引用总是原子的。

一些实现可能发现将单个对64-bit long或double值的写拆分为两个对相邻32-bit值的写很方便。不考虑效率,这些行为是特定于实现的,JVM的实现可以自由的选择以原子的或两步执行方式实现写long和double值。

建议JVM实现尽可能的避免拆分64-bit的值。建议程序员将共享64-bit值声明为volatile或正确的同步程序以避免复杂性。

你可能感兴趣的:([翻译]JLS7 §17 线程和锁(Threads and Locks))