构造函数和指令重新排序

我刚碰到一篇文章 ,提出了我以前从未听说过的声明,在其他任何地方找不到。 声明是从另一个线程的角度来看,构造函数返回的值的赋值可以相对于构造函数内的指令重新排序。 换句话说,声明是在下面的代码中,另一个线程可以读取尚未设置x的值的非空值。

 class MyInt { private int x; public MyInt(int value) { x = value; } public int getValue() { return x; } } MyInt a = new MyInt(42); 

这是真的?

编辑:

我认为从线程执行MyInt a = new MyInt(42)的角度来看, x的赋值与a的赋值有一个先发生的关系。 但是这两个值都可以缓存在寄存器中,并且它们可能不会按照它们最初写入的顺序刷新到主存储器。 因此,在没有内存屏障的情况下,另一个线程可以在写入x的值之前读取a的值。 正确?

因此,基于axtavt的答案和随后的评论,这些线程安全评估是否正确?

 // thread-safe class Foo() { final int[] x; public Foo() { int[] tmp = new int[1]; tmp[0] = 42; x = tmp; // memory barrier here } } // not thread-safe class Bar() { final int[] x = new int[1]; // memory barrier here public Bar() { x[0] = 42; // assignment may not be seen by other threads } } 

如果这是正确的……哇,这真的很微妙。

您引用的文章在概念上是正确的。 它的术语和用法有点不精确,正如你的问题一样,这会导致潜在的错误传达和误解。 看起来我在这里的术语很难,但Java内存模型非常微妙,如果术语不精确,那么一个人的理解就会受到影响。

我将从您的问题(以及评论)中摘录点,并提供对它们的回复。

构造函数返回的值的赋值可以相对于构造函数内的指令重新排序。

差不多是……它不是指令,而是可以重新排序的内存操作 (读取和写入)。 线程可以以特定顺序执行两个写入指令,但是数据到达存储器中,并且因此这些写入到其他线程的可见性可以以不同的顺序发生。

我认为从线程执行MyInt a = new MyInt(42)的角度来看, x的赋值与a的赋值有一个先发生的关系。

差不多了。 确实,在程序顺序中, x的赋值发生在赋值给a 。 但是, before-before是一个适用于所有线程的全局属性,因此在讨论特定线程之前发生之前没有意义。

但是这两个值都可以缓存在寄存器中,并且它们可能不会按照它们最初写入的顺序刷新到主存储器。 因此,在没有内存屏障的情况下,另一个线程可以在写入x的值之前读取a的值。

然而,差不多。 值可以缓存在寄存器中,但是部分内存硬件(如高速缓存或写缓冲区)也可能导致重新排序。 硬件可以使用各种机制来改变排序,例如缓存刷新或内存屏障(通常不会导致刷新,但仅防止某些重新排序)。 然而,在硬件方面考虑这个问题的困难在于,真实系统非常复杂并且具有不同的行为。 例如,大多数CPU都有几种不同的内存屏障。 如果你想推理JMM,你应该考虑模型的元素:内存操作和通过建立先发生关系来约束重新排序的同步。

因此,为了在JMM方面重新考虑这个例子,我们看到对字段x的写入和对程序顺序的字段a的写入。 该程序中没有任何约束重新排序,即没有同步,没有对挥发物的操作,没有写入最终字段。 这些写入之间没有发生之前的关系,因此可以重新排序。

有几种方法可以防止这些重新排序。

一种方法是使x最终。 这是有效的,因为JMM表示在构造函数返回之前,在构造函数返回之后发生的操作之前写入最终字段。 由于在构造函数返回后写入a ,因此在写入a之前,最终字段x的初始化发生,并且不允许重新排序。

另一种方法是使用同步。 假设MyInt实例在另一个类中使用,如下所示:

 class OtherObj { MyInt a; synchronized void set() { a = new MyInt(42); } synchronized int get() { return (a != null) ? a.getValue() : -1; } } 

set()调用结束时的解锁发生在写入xa字段之后。 如果另一个线程调用get() ,它会在调用开始时锁定。 这建立了set()结束时锁定释放与get()开头锁定获取之间的先发生关系。 这意味着在get()调用开始后,对xa的写入无法重新排序。 因此,读者线程将看到ax有效值,并且永远不会找到非null a和未初始化的x

当然,如果读者线程先前调用get() ,它可能会看到as为null,但这里没有内存模型问题。

您的FooBar示例很有趣,您的评估基本上是正确的。 写入到分配给最终数组字段之前出现的数组元素之后无法重新排序。 在分配给最终数组字段之后发生的数组元素的写入可以相对于稍后发生的其他存储器操作重新排序,因此其他线程可能确实看到过时的值。

在评论中,您询问这是否是String的问题,因为它有一个包含其字符的最终字段数组。 是的,这是一个问题,但是如果你看一下String.java构造函数,他们都非常小心地在构造函数的最末端进行最终字段的赋值。 这确保了arrays内容的正确可见性。

是的,这很微妙。 :-)但问题只有在你试图变得聪明时才会真正发生,比如试图避免使用同步或volatile变量。 大多数时候这样做是不值得的。 如果遵循“安全发布”实践,包括在构造函数调用期间不泄漏this ,并使用同步存储对构造对象的引用(例如上面的我的OtherObj示例),事情将完全按照您的预期工作。

参考文献:

在Java内存模型意义上 – 是的。 但这并不意味着你会在实践中观察它。

从以下角度来看:可能导致可见重新排序的优化不仅可能发生在编译器中,也可能发生在CPU中。 但CPU对对象及其构造函数一无所知,对于处理器而言,它只是一对可以在CPU的内存模型允许时重新排序的赋值。

当然,编译器和JVM可能会指示CPU不要通过在生成的代码中放置内存障碍来重新排序这些分配,但是对所有对象执行此操作将破坏可能严重依赖于这种积极优化的CPU的性能。 这就是为什么Java Memory Model不为这种情况提供任何特殊保证的原因。

例如,这导致Java内存模型下的双重检查锁定单例实现中的众所周知的缺陷。

换句话说,声明是在下面的代码中,另一个线程可以读取尚未设置x的值的非空值。

简短的回答是肯定的。

答案很长:支持另一个线程读取非空的a ,其值为x但尚未设置 – 不是严格的指令重新排序,而是处理器缓存其寄存器 (和L1缓存)中的值而不是读取这些值来自主存。 这可能间接意味着重新排序,但没有必要。

虽然CPU寄存器中的值的缓存有助于加快处理速度,但它引入了在不同CPU上运行的不同线程之间的值可见性问题。 如果始终从主程序区域读取值,则所有线程将始终看到相同的值(因为该值的一个副本 )。 在您的示例代码中,如果成员字段x值缓存到由thread-1访问的CPU1的寄存器中,而另一个在CPU-2上运行的线程Thread-2现在从主内存读取该值并更新它,从程序的角度来看,在CPU-1(由Thread-1处理)中缓存的此值现在无效,但Java规范本身允许虚拟机将其视为有效方案。