【问题标题】:Does object construction guarantee in practice that all threads see non-final fields initialized?对象构造在实践中是否保证所有线程都看到初始化的非最终字段?
【发布时间】:2026-01-07 21:55:01
【问题描述】:

Java memory model 保证对象的构造和终结器之间的先发生关系:

从一个构造函数的末尾有一个happens-before边 对象指向该对象的终结器(第 12.6 节)的开头。

以及final字段的构造函数和初始化:

一个对象被认为是完全初始化的,当它 构造函数完成。只能看到对 保证该对象完全初始化后的对象 查看该对象最终的正确初始化值 字段。

还有一个关于 volatile 字段的保证,因为对于这些字段的所有访问,存在发生前的关系:

对 volatile 字段(第 8.3.1.4 节)的写入发生在每个后续 读取该字段。

但是常规的、良好的旧非易失性字段呢?我见过很多多线程代码,它们在使用非易失性字段构造对象后不会创建任何类型的内存屏障。但是我从未见过或听说过任何问题,而且我自己也无法重建这种局部结构。

现代 JVM 是否只是在构建后设置内存屏障?避免围绕施工重新排序?还是我只是幸运?如果是后者,是否可以写出随意重现部分构造的代码?

编辑:

为了澄清,我说的是以下情况。假设我们有一个类:

public class Foo{
    public int bar = 0;

    public Foo(){
        this.bar = 5;
    }
    ...
}

还有一些线程T1 实例化了一个新的Foo 实例:

Foo myFoo = new Foo();

然后将实例传递给其他线程,我们称之为T2

Thread t = new Thread(() -> {
     if (myFoo.bar == 5){
         ....
     }
});
t.start();

T1 执行了两个我们感兴趣的写入操作:

  1. T1 将值 5 写入新实例化的 myFoobar
  2. T1 将对新创建的对象的引用写入myFoo 变量

对于 T1,我们得到一个 guarantee 写 #1 happened-before 写 #2:

线程中的每个动作在该线程中的每个动作之前发生 它在程序的顺序中稍后出现。

但就T2 而言,Java 内存模型不提供这样的保证。没有什么能阻止它以相反的顺序看到写入。所以它可以看到一个完全构建的Foo 对象,但bar 字段等于0。

编辑2:

在编写上面的示例几个月后,我再次查看了它。由于T2 是在T1 写入之后启动的,因此该代码实际上可以保证正常工作。这使它成为我想问的问题的错误示例。修复它以假设在T1 执行写入时 T2 已经在运行。假设T2 在循环中读取myFoo,如下所示:

Foo myFoo = null;
Thread t2 = new Thread(() -> {
     for (;;) {
         if (myFoo != null && myFoo.bar == 5){
             ...
         }
         ...
     }
});
t2.start();
myFoo = new Foo(); //The creation of Foo happens after t2 is already running

【问题讨论】:

  • 我没有注意到任何栅栏或 lock 前缀指令,无论是在一个类仅包含一个最终字段的情况下,还是在一个类仅包含一个非最终字段的情况下。让我看起来有点困惑......
  • 究竟是什么阻止了最终字段初始化的重新排序?
  • 他们对final 字段的保证安全性非常明确。如果相同的规则适用于 all 字段的构造,我认为没有必要。关于难以重现的并发问题,请不要屏住呼吸。正如this question 所表达的那样,即使是记录在案的问题似乎也无法重现。
  • @VinceEmigh 我不认为那篇文档的作者对我们含糊其辞。显然,不能保证常规字段。但轶事证据表明它在实践中不会发生。因此,也许对 JVM 更了解的人可能会对此有所了解。毕竟,这不是吹毛求疵。如果这样的对象构造在实践中实际上是不安全的,则必须修复大量代码。
  • @Malt 这不是要害羞,而是他们不需要添加特定声明以使 JLS 保持有效。看看我的回答。让我知道您是否希望我从规范中编译一些参考资料,这些参考资料解释了应该预期的行为。它不是特定于此,但是此的行为在技术上已记录在案。

标签: java multithreading jvm java-memory-model


【解决方案1】:

以你的例子作为问题本身 - 答案是是的,这是完全可能的。初始化的字段对构造线程可见,就像你引用的那样。这被称为安全发布(但我敢打赌你已经知道了)。

您没有通过实验看到这一事实是 x86 上的 AFAIK(作为强内存模型),无论如何都不会重新排序存储 ,因此除非 JIT 重新排序这些存储T1 做到了——你看不到。但从字面上看,这是在玩火,this question 和后续(几乎相同)here 一个人(不确定是否属实)丢失了 1200 万件设备

JLS 只保证通过几种方法来实现可见性。顺便说一句,这不是相反,JLS 不会说它什么时候会坏,它会说它什么时候会工作

1) final field semantics

注意示例如何显示 each 字段必须为 final - 即使在当前实现下 单个 就足够了,并且有两个内存屏障在构造函数之后插入(当使用 final 时):LoadStoreStoreStore

2) volatile fields(隐含AtomicXXX);我认为这个不需要任何解释,你似乎引用了这个。

3) Static initializers 好吧,IMO 应该很明显

4) Some locking involved - 这也应该很明显,发生在规则之前...

【讨论】:

  • 我的答案是我的孩子,所以删除一个真的很痛。但我想我必须同意你的看法......
  • @VinceEmigh 这实际上取自英特尔软件开发手册;第 3A 卷; 8.2.2Writes to memory are not reordered with other writes... with some exceptions;这就是我理解除StoreLoad(或lock addl)之外的所有障碍在英特尔上免费的原因。
  • javac 从不重新排序内存访问。您可能指的是 JIT 编译器/优化器。
【解决方案2】:

似乎对象构造期间没有同步

JLS 不允许这样做,我也无法在代码中产生任何迹象。但是,有可能产生反对意见。

运行以下代码:

public class Main {
    public static void main(String[] args) throws Exception {
        new Thread(() -> {
            while(true) {
                new Demo(1, 2);
            }
        }).start(); 
    }
}

class Demo {
    int d1, d2;

    Demo(int d1, int d2) {
        this.d1 = d1;   

        new Thread(() -> System.out.println(Demo.this.d1+" "+Demo.this.d2)).start();

        try {
            Thread.sleep(500);
        } catch(InterruptedException e) {
            e.printStackTrace();
        }

        this.d2 = d2;   
    }
}

输出将持续显示1 0,证明创建的线程能够访问部分创建的对象的数据。

但是,如果我们同步这个:

Demo(int d1, int d2) {
    synchronized(Demo.class) {
        this.d1 = d1;   

        new Thread(() -> {
            synchronized(Demo.class) {
                System.out.println(Demo.this.d1+" "+Demo.this.d2);
            }
        }).start();

        try {
            Thread.sleep(500);
        } catch(InterruptedException e) {
            e.printStackTrace();
        }

        this.d2 = d2;   
    }
}

输出为1 2,表明新创建的线程实际上会等待锁,与未同步的示例相反。

相关:Why can't constructors be synchronized?

【讨论】:

  • @GhostCat 非常感谢。 JLS 没有提到构造函数是否默认同步,与方法相同。有关于方法同步的部分,以及表达为什么不能将 synchronized 修饰符应用于构造函数的语句:"There is no practical need for a constructor to be synchronized, because it would lock the object under construction, which is normally not made available to other threads until all constructors for the object have completed their work." - 不正常,但可能
  • @VinceEmigh 这确实是一个很好的答案,但是对于一个稍微不同的问题。它确实证明了可以在构造函数仍在运行时通过从构造函数中泄漏this 来访问部分创建的对象。但是一旦构建完成,它并不能证明或反驳内存屏障的存在。有很多代码依赖于在从另一个线程传递的对象中初始化的常规字段(非最终,非易失性)。在这种情况下,JLS 中的任何内容都不能保证发生之前的关系。
  • @Malt Mind 给出一个代码示例?如果 T1 正在创建对象,那么 T2 在构造过程中如何尝试在不引用该对象的情况下初始化该对象的字段?如果不泄漏引用,就不需要同步,因为没有其他线程可以访问除非您在构造期间泄漏引用:“它会锁定正在构造的对象,通常不提供给其他线程" - 虽然可以同步,但在正常情况下您不需要同步。这也意味着构造不同步。
  • @VinceEmigh 说类 Foo 有名为 barint 成员,该成员在构造函数中初始化为 5。 T1 创建了一个名为myFoofoo 实例,因此可以保证看到myFoo.bar = 5。 T1 将对myFoo 的引用指向另一个线程T2。内存模型中的任何内容都不能保证 T2 会看到 bar=5,尽管对构造对象的引用是有效的,因为对 x 的写入发生在不同线程中的常规字段中。
【解决方案3】:

但轶事证据表明它在实践中不会发生

要查看此问题,您必须避免使用任何内存屏障。例如如果您使用任何类型的线程安全集合或某些System.out.println 可以防止问题发生。

虽然我刚刚为 x64 上的 Java 8 update 161 编写的一个简单测试没有显示这个问题,但我之前已经看到过这个问题。

【讨论】:

  • 有道理。我查看了生成的 C2 代码,没有看到 mfences 或 locked 指令。然而,在非最终字段的情况下,我无法捕捉到重新排序的语句。
  • 谢谢,彼得。在阅读了一些关于记忆模型的额外材料后,我开始怀疑你是对的。主要是运气。得益于多线程代码充满“自然”内存屏障的事实,例如来自 java.util.concurrent 和偶尔出现的 System.out.println 的内容。您是否碰巧回忆起您在何时何地看到此类问题发生的任何细节?什么版本的Java,什么样的CPU?操作系统?
最近更新 更多