构造函数和指令重新排序

时间:2014-07-16 19:41:35

标签: java thread-safety

我刚刚遇到an article,提出了我以前从未听过的声明,但在其他任何地方找不到。声明是从另一个线程的角度来看,构造函数返回的值的赋值可以相对于构造函数内的指令重新排序。换句话说,声明是在下面的代码中,另一个线程可以读取a的非空值,其中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的赋值有关系。但是这两个值都可以缓存在寄存器中,并且它们可能不会按照它们最初写入的顺序刷新到主存储器。因此,在没有内存屏障的情况下,另一个线程可以在写入a的值之前读取x的值。正确的吗?

基于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
   }
}

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

3 个答案:

答案 0 :(得分:8)

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

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

  

构造函数返回的值的赋值可以根据构造函数内的指令重新排序。

几乎是的......它不是指令,而是可以重新排序的内存操作(读取和写入)。线程可以按特定顺序执行两个写指令,但数据到达内存,从而可以以不同的顺序发生对其他线程的写入的可见性。

  

我认为从执行MyInt a = new MyInt(42)的线程的角度来看,x的赋值与a的赋值之间存在关系。< / p>

再一次,几乎。确实,在程序顺序中,x的分配发生在分配给a之前。但是,发生之前是一个适用于所有线程的全局属性,所以谈论特定线程之前发生的事情是没有意义的。

  

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

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

因此,要根据JMM重新访问此示例,我们会看到对字段x的写入以及按程序顺序写入字段a。该程序中没有任何限制重新排序,即没有同步,没有对挥发物的操作,没有写入最终字段。这些写入之间没有发生过关系,因此可以重新排序。

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

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

另一种方法是使用同步。假设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()开始时锁定获取之间发生的关系。这意味着在x调用开始后,对aget()的写入无法重新排序。因此,读者线程将看到ax的有效值,并且永远找不到非空a和未初始化的x

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

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

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

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

参考文献:

答案 1 :(得分:4)

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

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

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

例如,这导致了Java内存模型中Double checked locking singleton implementation中众所周知的缺陷。

答案 2 :(得分:1)

  

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

简短的回答是肯定的。

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

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

相关问题