构造函数和指令重新排序

Kev*_*ede 13 java thread-safety

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

class MyInt {
   private int x;

   public MyInt(int value) {
      x = value;
   }

   public int getValue() {
      return x;
   }
}

MyInt a = new MyInt(42);
Run Code Online (Sandbox Code Playgroud)

这是真的?

编辑:

我认为从线程执行的角度来看,保证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
   }
}
Run Code Online (Sandbox Code Playgroud)

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

Stu*_*rks 8

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

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

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

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

我认为从线程执行的角度来看,保证MyInt a = new MyInt(42)了赋值与赋值的x发生关系a.

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

但是这两个值都可以缓存在寄存器中,并且它们可能不会按照它们最初写入的顺序刷新到主存储器.因此,在没有内存屏障的情况下,另一个线程可以在写入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;
    }
}
Run Code Online (Sandbox Code Playgroud)

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

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

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

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

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

参考文献: