首页 > 解决方案 > 对象访问是否可以在 Java 中使用该对象的最终字段访问重新排序?

问题描述

下面的代码示例取自 JLS 17.5 “final Field Semantics”:

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是通过数据竞争发布的,f != null检查是否有可能成功评估,但随后的f.x取消引用会显示fnull

换句话说,是否有可能获得NullPointerException评论为“保证看到3”的在线?

标签: javafinaljava-memory-modelinstruction-reordering

解决方案


好的,这是我自己的看法,基于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 = fi = local.x与解引用链关系相连,这给了我们一开始提到的整个链:

hb(w, f) -> hb(f, a) -> mc(a, r1) -> dereferences(r1, r2)

推荐阅读