【问题标题】:Can object access be reordered with that object's final field access in Java?对象访问是否可以在 Java 中使用该对象的最终字段访问重新排序?
【发布时间】:2020-09-28 09:34:36
【问题描述】:

以下代码示例取自 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 取消引用将f 视为null?

换句话说,是否有可能在网上得到一个NullPointerException 并带有“保证看到3”的评论?

【问题讨论】:

  • 好吧,您正在使用初始化实例。所以构造函数已经被调用,这就是x 将被初始化的原因
  • 如果writer()和reader()被不同的线程调用怎么办?在没有同步的情况下,f.x 是否可以相对于f != null“重新排序”?
  • 没有。在你的例子中它不会发生
  • @Alex 一些解释会很好 - 为什么在这种情况下不会发生重新排序。最好有一些 JLS 链接。
  • 我不认为应用程序程序员应该求助于 JSR-133 Cookbook,因为它只是一组关于 JVM 如何可能的建议 > 实施。甚至 HotSpot 也不总是遵循说明书,更不用说其他 JVM 实现了。语言规范应该是此类问题的唯一真实来源,如前所述,JMM 中没有任何内容可以阻止这种重新排序。

标签: java final java-memory-model instruction-reordering


【解决方案1】:

好的,这是我自己的看法,基于 Vladimir Sitnikov 给出的关于 final 语义的相当详细的talk(俄语),以及随后对JLS 17.5.1 的重新访问。

最终字段语义

规范规定:

给定一个写入 w、一个冻结 f、一个动作 a(不是读取最终字段)、一个读取r1 的最终字段被 f 冻结,并读取 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 被写入。在 o 的最后一个字段 f 上发生冻结动作 当 c 正常或突然退出时。

由于字段写入在程序顺序中完成构造函数之前,我们可以假设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) 关系仅存在如果字段的读取看到对共享引用的写入
  • 从 JMM 的角度来看,f != null 和 f.x 是两个不同的读取操作
  • 规范中没有任何内容表明 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)

【讨论】:

  • Alexey Shipilev 的另一篇很棒的帖子证实了重新排序是可能的:shipilev.net/blog/2014/safe-public-construction/…。 FinalWrapperFactory 类的示例和相应的注释值得一试:“还请注意,我们只对包装器进行了一次非同步读取。即使这种读取很激烈,我们也会从意外读取“null”中恢复。如果我们在返回之前第二次读取 wrapper,这将使我们有机会再次读取“null”,然后返回它。”
【解决方案2】:

你的分析很漂亮 (1+),如果我可以投票两次 - 我会的。这是“独立读取”here, for example 的同一问题的另一个链接。

我也尝试过解决这个问题in a different answer too。

我认为,如果我们在这里引入相同的概念,事情也可以证明。让我们采用该方法并稍微改变它:

static void reader() {

    FinalFieldExample instance1 = f;

    if (instance1 != null) {

        FinalFieldExample instance2 = f;
        int i = instance2.x;    

        FinalFieldExample instance3 = f;
        int j = instance3.y;  
    } 
}

编译器现在可以进行一些急切的读取(将这些读取移到if statement之前):

static void reader() {

    FinalFieldExample instance1 = f;
    FinalFieldExample instance2 = f;
    FinalFieldExample instance3 = f;

    if (instance1 != null) {
        int i = instance2.x;    
        int j = instance3.y;  
    } 
}

这些读取可以在它们之间进一步重新排序:

static void reader() {

    FinalFieldExample instance2 = f;
    FinalFieldExample instance1 = f;
    FinalFieldExample instance3 = f;

    if (instance1 != null) {
        int i = instance2.x;    
        int j = instance3.y;  
    } 
}

从这里开始,事情应该是微不足道的:ThreadA 将 FinalFieldExample instance2 = f; 读取为 null,之前它执行下一个读取:FinalFieldExample instance1 = f; 一些 ThreadB 调用 writer(如比如f != null) 和部分:

 FinalFieldExample instance1 = f;

解析为non-null。

【讨论】:

  • 谢谢!它确实有助于从不同的角度看待这一点,不仅推理理论上的可能性,而且推理编译器如何以及为什么要进行这种重新排序
  • @NikitaTkachenko 你可能也喜欢this,这个人自己展示了这些东西是多么神奇......恕我直言。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2011-02-03
  • 1970-01-01
  • 2010-11-04
相关资源
最近更新 更多