Table 17.4-D
现在,我们来考虑一种情况,在线程1第一次读取 r1.x 和 r3.x 之间,线程 2 执行 r6=p; r6.x=3; 编译器进行了 r5复用 r2 结果的优化操作,那么 r2==r5==0,r4 == 3,从程序员的角度来看,p.x 的值由 0 变为 3,然后又变为 0。
我简单整理了一下:
例子结束,回到正题
Java 内存模型定义了在程序的每一步,哪些值是内存可见的。对于隔离的每个线程来说,其操作是由我们线程中的语义来决定的,但是线程中读取到的值是由内存模型来控制的。
当我们提到这点时,我们说程序遵守线程内语义,线程内语义说的是单线程内的语义,它允许我们基于线程内读操作看到的值完全预测线程的行为。如果我们要确定线程 t 中的操作是否是合法的,我们只要评估当线程 t 在单线程环境中运行时是否是合法的就可以,该规范的其余部分也在定义这个问题。
这段话不太好理解,首先记住“线程内语义”这个概念,之后还会用到。我对这段话的理解是,在单线程中,我们是可以通过一行一行看代码来预测执行结果的,只不过,代码中使用到的读取内存的值我们是不能确定的,这取决于在内存模型这个大框架下,我们的程序会读到的值。也许是最新的值,也许是过时的值。
此节描述除了 final 关键字外的java内存模型的规范,final将在之后的17.5节介绍。
这里描述的内存模型并不是基于 Java 编程语言的面向对象。为了简洁起见,我们经常展示没有类或方法定义的代码片段。大多数示例包含两个或多个线程,其中包含局部变量,共享全局变量或对象的实例字段的语句。我们通常使用诸如 r1 或 r2 之类的变量名来表示方法或线程本地的变量。其他线程无法访问此类变量。
17.4.1. 共享变量(Shared Variables)
所有线程都可以访问到的内存称为共享内存或堆内存。
所有的实例属性,静态属性,还有数组的元素都存储在堆内存中。在本章中,我们用术语变量来表示这些元素。
局部变量、方法参数、异常对象,它们不会在线程间共享,也不会受到内存模型定义的任何影响。
两个线程对同一个变量同时进行读-写操作或写-写操作,我们称之为“冲突”。
好,这一节都是废话,愉快地进入到下一节
17.4.2. 操作(Actions)
这一节主要是讲解理论,主要就是严谨地定义操作。
线程间操作是指由一个线程执行的动作,可以被另一个线程检测到或直接影响到。以下是几种可能发生的线程间操作:
读 (普通变量,非 volatile)。读一个变量。
写 (普通变量,非 volatile)。写一个变量。
同步操作,如下:
volatile 读。读一个 volatile 变量
volatile 写。写入一个 volatile 变量
加锁。对一个对象的监视器加锁。
解锁。解除对某个对象的监视器锁。
线程的第一个和最后一个操作。
开启线程操作,或检测一个线程是否已经结束。
外部操作。一个外部操作指的是可能被观察到的在外部执行的操作,同时它的执行结果受外部环境控制。
简单说,外部操作的外部指的是在 JVM 之外,如 native 操作。
线程分歧操作(§17.4.9)。此操作只由处于无限循环的线程执行,在该循环中不执行任何内存操作、同步操作、或外部操作。如果一个线程执行了分歧操作,那么其后将跟着无数的线程分歧操作。
分歧操作的引入是为了用来说明,线程可能会导致其他所有线程停顿而不能继续执行。
此规范仅关心线程间操作,我们不关心线程内部的操作(比如将两个局部变量的值相加存到第三个局部变量中)。如前文所说,所有的线程都需要遵守线程内语义。对于线程间操作,我们经常会简单地称为操作。
我们用元祖< t, k, v, u >来描述一个操作:
t - 执行操作的线程
k - 操作的类型。
v - 操作涉及的变量或监视器
对于加锁操作,v 是被锁住的监视器;对于解锁操作,v 是被解锁的监视器。
如果是一个读操作( volatile 读或非 volatile 读),v 是读操作对应的变量
如果是一个写操作( volatile 写或非 volatile 写),v 是写操作对应的变量
u - 唯一的标识符标识此操作
外部动作元组还包含一个附加组件,其中包含由执行操作的线程感知的外部操作的结果。这可能是关于操作的成败的信息,以及操作中所读的任何值。
外部操作的参数(如哪些字节写入哪个 socket)不是外部操作元祖的一部分。这些参数是通过线程中的其他操作进行设置的,并可以通过检查线程内语义进行确定。它们在内存模型中没有被明确讨论。
在非终结执行中,不是所有的外部操作都是可观察的。17.4.9小节讨论非终结执行和可观察操作。
大家看完这节最懵逼的应该是外部操作和线程分歧操作,我简单解释下。
外部操作大家可以理解为 Java 调用了一个 native 的方法,Java 可以得到这个 native 方法的返回值,但是对于具体的执行其实不感知的,意味着 Java 其实不能对这种语句进行重排序,因为 Java 无法知道方法体会执行哪些指令。
引用 stackoverflow 中的一个例子:
// method()方法中jni()是外部操作,不会和 "foo = 42;" 这条语句进行重排序。 class Externalization { int foo = 0; void method() { jni(); // 外部操作 foo = 42; } native void jni(); /* { assert foo == 0; //我们假设外部操作执行的是这个。 } */ }
在上面这个例子中,显然,jni() 与 foo = 42 之间不能进行重排序。
再来个线程分歧操作的例子:
// 线程分歧操作阻止了重排序,所以 "foo = 42;" 这条语句不会先执行 class ThreadDivergence { int foo = 0; void thread1() { while (true){} // 线程分歧操作 foo = 42; } void thread2() { assert foo == 0; // 这里永远不会失败 } }
17.4.3. 程序和程序顺序(Programs and Program Order)
在每个线程 t 执行的所有线程间动作中,t 的程序顺序是反映 根据 t 的线程内语义执行这些动作的顺序 的总顺序。
如果所有操作的执行顺序 和 代码中的顺序一致,那么一组操作就是连续一致的,并且,对变量 v 的每个读操作 r 会看到写操作 w 写入的值,也就是:
写操作 w 先于 读操作 r 完成,并且
没有其他的写操作 w' 使得 w' 在 w 之后 r 之前发生。
连续一致性对于可见性和程序执行顺序是一个非常强的保证。在这种场景下,所有的单个操作(比如读和写)构成一个统一的执行顺序,这个执行顺序和代码出现的顺序是一致的,同时每个单个操作都是原子的,且对所有线程来说立即可见。
如果程序没有任何的数据竞争,那么程序的所有执行操作将表现为连续一致。连续一致性 和/或 数据竞争的自由仍然允许错误从一组操作中产生。
完全不知道这句话是什么意思
如果我们用连续一致性作为我们的内存模型,那我们讨论的许多关于编译器优化和处理器优化就是非法的。比如在17.4-C中,一旦执行 p.x=3,那么后续对于该位置的读操作应该是立即可以读到最新值的。
连续一致性的核心在于每一步的操作都是原子的,同时对于所有线程都是可见的,而且不存在重排序。所以,Java 语言定义的内存模型肯定不会采用这种策略,因为它直接限制了编译器和 JVM 的各种优化措施。
注意:很多地方所说的顺序一致性就是这里的连续一致性,英文是 Sequential consistency
17.4.4. 同步顺序(Synchronization Order)
每个执行都有一个同步顺序。同步顺序是由执行过程中的每个同步操作组成的顺序。对于每个线程 t,同步操作组成的同步顺序是和线程 t 中的代码顺序一致的。
虽然拗口,但毕竟说的是同步,我们都不陌生。同步操作包括了如下同步关系:
对于监视器 m 的解锁与所有后续操作对于 m 的加锁同步
对 volatile 变量 v 的写入,与所有其他线程后续对 v 的读同步
启动线程的操作与线程中的第一个操作同步。
对于每个属性写入默认值(0, false,null)与每个线程对其进行的操作同步。
尽管在创建对象完成之前对对象属性写入默认值有点奇怪,但从概念上来说,每个对象都是在程序启动时用默认值初始化来创建的。
线程 T1 的最后操作与线程 T2 发现线程 T1 已经结束同步。
线程 T2 可以通过 T1.isAlive() 或 T1.join() 方法来判断 T1 是否已经终结。
如果线程 T1 中断了 T2,那么线程 T1 的中断操作与其他所有线程发现 T2 被中断了同步(通过抛出 InterruptedException 异常,或者调用 Thread.interrupted 或 Thread.isInterrupted )
以上同步顺序可以理解为对于某资源的释放先于其他操作对同一资源的获取。
好,这节相对 easy,说的就是关于 A synchronizes-with B 的一系列规则。
17.4.5. Happens-before顺序(Happens-before Order)
Happens-before 是非常重要的知识,有些地方我没有很理解,我尽量将原文直译过来。想要了解更深的东西,你可能还需要查询更多的其他资料。
两个操作可以用 happens-before 来确定它们的执行顺序,如果一个操作 happens-before 于另一个操作,那么我们说第一个操作对于第二个操作是可见的。
注意:happens-before 强调的是可见性问题
如果我们分别有操作 x 和操作 y,我们写成 hb(x, y) 来表示 x happens-before y。
如果操作 x 和操作 y 是同一个线程的两个操作,并且在代码上操作 x 先于操作 y 出现,那么有 hb(x, y)。请注意,这里不代表不可以重排序,只要没有数据依赖关系,重排序就是可能的。
对象构造方法的最后一行指令 happens-before 于 finalize() 方法的第一行指令。
如果操作 x 与随后的操作 y 构成同步,那么 hb(x, y)。
hb(x, y) 和 hb(y, z),那么可以推断出 hb(x, z)
对象的 wait 方法关联了加锁和解锁的操作,它们的 happens-before 关系即是加锁 happens-before 解锁。
我们应该注意到,两个操作之间的 happens-before 的关系并不一定表示它们在 JVM 的具体实现上必须是这个顺序,如果重排序后的操作结果和合法的执行结果是一致的,那么这种实现就不是非法的。
比如说,在线程中对对象的每个属性写入初始默认值并不需要先于线程的开始,只要这个事实没有被读到就可以了。
我们可以发现,happens-before 规则主要还是上一节 同步顺序 中的规则,加上额外的几条
更具体地说,如果两个操作是 happens-before 的关系,但是在代码中它们并没有这种顺序,那么就没有必要表现出 happens-before 关系。如线程 1 对变量进行写入,线程 2 随后对变量进行读操作,那么这两个操作是没有 happens-before 关系的。
happens-before 关系用于定义当发生数据竞争的时候。将上面所有的规则简化成以下列表:
对一个监视器的解锁操作 happens-before 于后续的对这个监视器的加锁操作。
对 volatile 属性的写操作先于后续对这个属性的读操作。也就是一旦写操作完成,那么后续的读操作一定能读到最新的值
线程的 start() 先于任何在线程中定义的语句。
如果 A 线程中调用了 B.join(),那么 B 线程中的操作先于 A 线程 join() 返回之后的任何语句。因为 join() 本身就是让其他线程先执行完的意思。
对象的默认初始值 happens-before 于程序中对它的其他操作。也就是说不管我们要对这个对象干什么,这个对象即使没有创建完成,它的各个属性也一定有初始零值。
当程序出现两个没有 happens-before 关系的操作对同一数据进行访问时,我们称之为程序中有数据竞争。
除了线程间操作,数据竞争不直接影响其他操作的语义,如读取数组的长度、检查转换的执行、虚拟方法的调用。
因此,数据竞争不会导致错误的行为,例如为数组返回错误的长度。当且仅当所有连续一致的操作都没有数据争用时,程序就是正确同步的。
如果一个程序是正确同步的,那么程序中的所有操作就会表现出连续一致性。
这是一个对于程序员来说强有力的保证,程序员不需要知道重排序的原因,就可以确定他们的代码是否包含数据争用。因此,他们不需要知道重排序的原因,来确定他们的代码是否是正确同步的。一旦确定了代码是正确同步的,程序员也就不需要担心重排序对于代码的影响。
其实就是正确同步的代码不存在数据竞争问题,这个时候程序员不需要关心重排序是否会影响我们的代码,我们的代码执行一定会表现出连续一致。
程序必须正确同步,以避免当出现重排序时,会出现一系列的奇怪的行为。正确同步的使用,不能保证程序的全部行为都是正确的。
但是,它的使用可以让程序员以很简单的方式就能知道可能发生的行为。正确同步的程序表现出来的行为更不会依赖于可能的重排序。没有使用正确同步,非常奇怪、令人疑惑、违反直觉的任何行为都是可能的。
我们说,对变量 v 的读操作 r 能看到对 v 的写操作 w,如果:
读操作 r 不是先于 w 发生(比如不是 hb(r, w) ),同时没有写操作 w' 穿插在 w 和 r 中间(如不存在 hb(w, w') 和 hb(w', r))。非正式地,如果没有 happens-before 关系阻止读操作 r,那么读操作 r 就能看到写操作 w 的结果。
17.5. final 属性的语义(final Field Semantics)
我们经常使用 final,关于它最基础的知识是:用 final 修饰的类不可以被继承,用 final 修饰的方法不可以被覆写,用 final 修饰的属性一旦初始化以后不可以被修改。
当然,这节说的不是这些,这里将阐述 final 关键字的深层次含义。
用 final 声明的属性正常情况下初始化一次后,就不会被改变。final 属性的语义与普通属性的语义有一些不一样。尤其是,对于 final 属性的读操作,compilers 可以自由地去除不必要的同步。相应地,compilers 可以将 final 属性的值缓存在寄存器中,而不用像普通属性一样从内存中重新读取。
final 属性同时也允许程序员不需要使用同步就可以实现线程安全的不可变对象。一个线程安全的不可变对象对于所有线程来说都是不可变的,即使传递这个对象的引用存在数据竞争。
这可以提供安全的保证,即使是错误的或者恶意的对于这个不可变对象的使用。如果需要保证对象不可变,需要正确地使用 final 属性域。
对象只有在构造方法结束了才被认为完全初始化了。如果一个对象完全初始化以后,一个线程持有该对象的引用,那么这个线程一定可以看到正确初始化的 final 属性的值。
这个隐含了,如果属性值不是 final 的,那就不能保证一定可以看到正确初始化的值,可能看到初始零值。
final 属性的使用是非常简单的:在对象的构造方法中设置 final 属性;同时在对象初始化完成前,不要将此对象的引用写入到其他线程可以访问到的地方。如果这个条件满足,当其他线程看到这个对象的时候,那个线程始终可以看到正确初始化后的对象的 final 属性。
这里面说到了一个正确初始化的问题,看过《Java并发编程实战》的可能对这个会有印象,不要在构造方法中将 this 发布出去。
这段代码把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; // 程序一定能得到 3 int j = f.y; // 也许会看到 0 } } }
这个类FinalFieldExample有一个 final 属性 x 和一个普通属性 y。我们假定有一个线程执行 writer() 方法,另一个线程再执行 reader() 方法。
因为 writer() 方法在对象完全构造后将引用写入 f,那么 reader() 方法将一定可以看到初始化后的 f.x : 将读到一个 int 值 3。然而, f.y 不是 final 的,所以程序不能保证可以看到 4,可能会得到 0。
final 属性被设计成用来保障很多操作的安全性。考虑以下代码,线程 1 执行:
Global.s = "/tmp/usr".substring(4);
同时,线程 2 执行:
String myS = Global.s; if (myS.equals("/tmp")) System.out.println(myS);
String 对象是不可变对象,同时 String 操作不需要使用同步。虽然 String 的实现没有任何的数据竞争,但是其他使用到 String 对象的代码可能是存在数据竞争的,内存模型没有对存在数据竞争的代码提供安全性保证。
特别是,如果 String 类中的属性不是 final 的,那么有可能(虽然不太可能)线程 2 会看到这个 string 对象的 offset 为初始值 0,那么就会出现 myS.equals("/tmp")。
之后的一个操作可能会看到这个 String 对象的正确的 offset 值 4,那么会得到 “/usr”。Java 中的许多安全特性都依赖于 String 对象的不可变性,即使是恶意代码在数据竞争的环境中在线程之间传递 String 对象的引用。
大家看这段的时候,如果要看代码,请注意,这里说的是 JDK6 及以前的 String 类:
public final class String implements java.io.Serializable, Comparable<String>, CharSequence { /** The value is used for character storage. */ private final char value[]; /** The offset is the first index of the storage that is used. */ private final int offset; /** The count is the number of characters in the String. */ private final int count; /** Cache the hash code for the string */ private int hash; // Default to 0
因为到 JDK7 和 JDK8 的时候,代码已经变为:
public final class String implements java.io.Serializable, Comparable<String>, CharSequence { /** The value is used for character storage. */ private final char value[]; /** Cache the hash code for the string */ private int hash; // Default to 0 /** use serialVersionUID from JDK 1.0.2 for interoperability */ private static final long serialVersionUID = -6849794470754667710L;
17.5.1. final属性的语义(Semantics of final Fields)
令 o 为一个对象,c 为 o 的构造方法,构造方法中对 final 的属性 f 进行写入值。当构造方法 c 退出的时候,会在final 属性 f 上执行一个 freeze 操作。
注意,如果一个构造方法调用了另一个构造方法,在被调用的构造方法中设置 final 属性,那么对于 final 属性的 freeze 操作发生于被调用的构造方法结束的时候。
对于每一个执行,读操作的行为被其他的两个偏序影响,解引用链 dereferences() 和内存链 mc(),它们被认为是执行的一部分。这些偏序必须满足下面的约束:
17.5.2. 在构造期间读 final 属性(Reading final Fields During Construction)
在构造对象的线程中,对该对象的 final 属性的读操作,遵守正常的 happens-before 规则。如果在构造方法内,读某个 final 属性晚于对这个属性的写操作,那么这个读操作可以看到这个 final 属性已经被定义的值,否则就会看到默认值。
17.5.3. final 属性的修改(Subsequent Modification of final Fields)
在许多场景下,如反序列化,系统需要在对象构造之后改变 final 属性的值。final 属性可以通过反射和其他方法来改变。
唯一的具有合理语义的模式是:对象被构造出来,然后对象中的 final 属性被更新。在这个对象的所有 final 属性更新操作完成之前,此对象不应该对其他线程可见,也不应该对 final 属性进行读操作。
对于 final 属性的 freeze 操作发生于构造方法的结束,这个时候 final 属性已经被设值,还有通过反射或其他方式对于 final 属性的更新之后。
即使是这样,依然存在几个难点。如果一个 final 属性在属性声明的时候初始化为一个常量表达式,对于这个 final 属性值的变化过程也许是不可见的,因为对于这个 final 属性的使用是在编译时用常量表达式来替换的。
另一个问题是,该规范允许 JVM 实现对 final 属性进行强制优化。在一个线程内,允许对于 final 属性的读操作与构造方法之外的对于这个 final 属性的修改进行重排序。
对于 final 属性的强制优化(Aggressive Optimization of final Fields)
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) { // 利用反射将 a.x 的值修改为 2 // uses reflection to change a.x to 2 } }
在方法 d 中,编译器允许对 x 的读操作和方法 g 进行重排序,这样的话,new A().f()可能会返回 -1, 0, 或 1。
我在我的 MBP 上试了好多办法,真的没法重现出来,不过并发问题就是这样,我们不能重现不代表不存在。StackOverflow 上有网友说在 Sparc 上运行,可惜我没有 Sparc 机器。
下文将说到一个比较少见的 final-field-safe context
JVM 实现可以提供一种方式在 final 属性安全上下文(final-field-safe context)中执行代码块。如果一个对象是在 final 属性安全上下文中构造出来的,那么在这个 final 属性安全上下文 中对于 final 属性的读操作不会和相应的对于 final 属性的修改进行重排序。
final 属性安全上下文还提供了额外的保障。如果一个线程已经看到一个不正确发布的一个对象的引用,那么此线程可以看到了 final 属性的默认值,然后,在 final 属性安全上下文中读取该对象的正确发布的引用,这可以保证看到正确的 final 属性的值。在形式上,在final 属性安全上下文中执行的代码被认为是一个独立的线程(仅用于满足 final 属性的语义)。
在实现中,compiler 不应该将对 final 属性的访问移入或移出final 属性安全上下文(尽管它可以在这个执行上下文的周边移动,只要这个对象没有在这个上下文中进行构造)。
对于 final 属性安全上下文的使用,一个恰当的地方是执行器或者线程池。在每个独立的 final 属性安全上下文中执行每一个 Runnable,执行器可以保证在一个 Runnable 中对对象 o 的不正确的访问不会影响同一执行器内的其他 Runnable 中的 final 带来的安全保障。
17.5.4. 写保护属性(Write-Protected Fields)
通常,如果一个属性是 final 的和 static 的,那么这个属性是不会被改变的。但是, System.in, System.out, 和 System.err 是 static final 的,出于遗留的历史原因,它们必须允许被 System.setIn, System.setOut, 和 System.setErr 这几个方法改变。我们称这些属性是写保护的,用以区分普通的 final 属性。
public final static InputStream in = null; public final static PrintStream out = null; public final static PrintStream err = null;
编译器需要将这些属性与 final 属性区别对待。例如,普通 final 属性的读操作对于同步是“免疫的”:锁或 volatile 读操作中的内存屏障并不会影响到对于 final 属性的读操作读到的值。因为写保护属性的值是可以被改变的,所以同步事件应该对它们有影响。因此,语义规定这些属性被当做普通属性,不能被用户的代码改变,除非是 System类中的代码。
17.6. 字分裂(Word Tearing)
实现 Java 虚拟机需要考虑的一件事情是,每个对象属性以及数组元素之间是独立的,更新一个属性或元素不能影响其他属性或元素的读取与更新。尤其是,两个线程在分别更新 byte 数组相邻的元素时,不能互相影响与干扰,且不需要同步来保证连续一致性。
一些处理器不提供写入单个字节的能力。通过简单地读取整个字,更新相应的字节,然后将整个字写入内存,用这种方式在这种处理器上实现字节数组更新是非法的。这个问题有时被称为字分裂(word tearing),在这种不能单独更新单个字节的处理器上,将需要寻求其他的方法。
请注意,对于大部分处理器来说,都没有这个问题
Example 17.6-1. Detection of Word Tearing
以下程序用于测试是否存在字分裂:
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; } System.out.println("done"); } public static void main(String[] args) { for (int i = 0; i < LENGTH; ++i) (threads[i] = new WordTearing(i)).start(); } }
这表明写入字节时不得覆写相邻的字节。
17.7. double 和 long 的非原子处理 (Non-Atomic Treatment of double and long)
在Java内存模型中,对于 non-volatile 的 long 或 double 值的写入是通过两个单独的写操作完成的:long 和 double 是 64 位的,被分为两个 32 位来进行写入。那么可能就会导致一个线程看到了某个操作的低 32 位的写入和另一个操作的高 32 位的写入。
写入或者读取 volatile 的 long 和 double 值是原子的。
写入和读取对象引用一定是原子的,不管具体实现是32位还是64位。
将一个 64 位的 long 或 double 值的写入分为相邻的两个 32 位的写入对于 JVM 的实现来说是很方便的。为了性能上的考虑,JVM 的实现是可以决定采用原子写入还是分为两个部分写入的。
如果可能的话,我们鼓励 JVM 的实现避开将 64 位值的写入分拆成两个操作。我们也希望程序员将共享的 64 位值操作设置为 volatile 或者使用正确的同步,这样可以提供更好的兼容性。
目前来看,64 位虚拟机对于 long 和 double 的写入都是原子的,没必要加 volatile 来保证原子性。
来源:https://javadoop.com/post/Threads-And-Locks-md\