好的,这是我自己的看法,基于Vladimir Sitnikov 给出的关于最终语义的非常详细的谈话(俄语) ,以及随后对JLS 17.5.1的重新访问。
最终字段语义
该规范指出:
给定一个写入w、一个冻结f、一个动作a(不是读取最终字段)、读取被 f 冻结的最终字段的r1和读取r2使得 hb(w, f), hb( f, a), mc(a, r1) 和 dereferences(r1, r2),那么在确定 r2 可以看到哪些值时,我们会考虑 hb(w, r2)。
换句话说,如果可以建立以下关系链,我们就可以保证看到对 final 字段的写入:
hb(w, f) -> hb(f, a) -> mc(a, r1) -> dereferences(r1, r2)
1. hb(w, f)
w是对最终字段的写入:x = 3
f是“冻结”操作(退出FinalFieldExample
构造函数):
令 o 为对象,c 为 o 的构造函数,其中写入了 final 字段 f。当 c 正常或突然退出时,会在 o 的最后一个字段 f 上发生冻结动作。
由于字段 write 在按程序顺序完成构造函数之前出现,我们可以假设hb(w, f)
:
如果 x 和 y 是同一线程的操作,并且 x 在程序顺序中位于 y 之前,则 hb(x, y)
2. hb(f, a)
规范中给定a的定义非常模糊(“动作,这不是对最终字段的读取”)
我们可以假设a正在发布对对象 ( f = new FinalFieldExample()
) 的引用,因为这个假设与规范不矛盾(它是一个动作,并且它不是对最终字段的读取)
由于完成构造函数在程序顺序中写入引用之前,因此这两个操作按发生之前的关系排序:hb(f, a)
3. mc(a, r1)
在我们的例子中, r1是“读取被 f 冻结的最终字段”( f.x
)
这就是它开始变得有趣的地方。mc(内存链)是“最终字段的语义”部分中介绍的两个附加部分命令之一:
内存链排序有几个限制:
- 如果 r 是看到写入 w 的读取,那么 mc(w, r) 必须是这种情况。
- 如果 r 和 a 是取消引用 (r, a) 的动作,那么 mc(r, a) 必须是这种情况。
- 如果 w 是未初始化 o 的线程 t 对对象 o 的地址的写入,则必须存在由线程 t 读取的 r,它看到 o 的地址,使得 mc(r, w)。
对于问题中给出的简单示例,我们实际上只对第一点感兴趣,因为需要其他两个来推理更复杂的案例。
以下是实际解释为什么可以获得 NPE 的部分:
- 请注意规范引用中的粗体部分:仅当字段的读取看到对共享引用的写入时,
mc(a, r1)
关系才存在
f != null
并且f.x
从 JMM 的角度来看是两个不同的读取操作
- 规范中没有任何内容表明
mc
关系对于程序顺序或发生之前是可传递的
- 因此,如果
f != null
看到另一个线程完成了写入,则不能保证也f.x
看到它
我不会详细介绍取消引用链约束,因为它们仅用于推理更长的引用链(例如,当最终字段引用一个对象时,该对象又引用另一个对象)。
对于我们的简单示例,只需说 JLS 声明“取消引用顺序是自反的,并且 r1 可以与 r2 相同”(这正是我们的情况)。
处理不安全出版物的安全方法
下面是保证不会抛出 NPE 的代码的修改版本:
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() {
FinalFieldExample local = f;
if (local != null) {
int i = local.x; // guaranteed to see 3
int j = local.y; // could see 0
}
}
}
这里的重要区别是将共享引用读入局部变量。正如 JLS 所说:
局部变量......永远不会在线程之间共享,并且不受内存模型的影响。
因此,从 JMM 的角度来看,只有一次从共享状态读取。
如果该读取碰巧看到另一个线程完成了写入,则意味着这两个操作通过内存链 ( mc
) 关系连接。此外,local = f
和i = local.x
与解引用链关系相连,这给了我们一开始提到的整个链:
hb(w, f) -> hb(f, a) -> mc(a, r1) -> dereferences(r1, r2)