构造函数和指令重新排序

2022-09-04 21:23:47

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

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)xaax

因此,根据axtaftt的回答和随后的评论,这些对线程安全性的评估是否正确?

// 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
   }
}

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


答案 1

你引用的文章在概念上是正确的。它的术语和用法有些不精确,就像你的问题一样,这会导致潜在的沟通不畅和误解。看起来我在这里喋喋不休地谈论术语,但是Java内存模型非常微妙,如果术语不精确,那么一个人的理解就会受到影响。

我将从您的问题(和评论)中摘录要点,并对其做出回应。

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

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

我认为可以保证,从线程执行的角度来看,的赋值与 的赋值具有发生之前的关系。MyInt a = new MyInt(42)xa

再一次,差不多。的确,在程序顺序中,to 的赋值发生在 赋值给 之前。但是,happens-before 是一个适用于所有线程的全局属性,因此谈论发生在特定线程之前是没有意义的。xa

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

再一次,几乎。值可以缓存在寄存器中,但部分内存硬件(如缓存内存或写入缓冲区)也可能导致重新排序。硬件可以使用各种机制来更改顺序,例如缓存刷新或内存屏障(通常不会导致刷新,而只是阻止某些重新排序)。然而,从硬件的角度考虑这个问题的困难在于,真正的系统非常复杂,并且具有不同的行为。例如,大多数CPU都有几种不同类型的内存屏障。如果要对 JMM 进行推理,则应从模型的元素的角度来考虑:内存操作和同步,这些操作和同步通过建立先发生关系来约束重新排序。

因此,为了从 JMM 的角度重新审视此示例,我们看到按程序顺序写入字段和写入字段。该程序中没有任何内容可以约束重新排序,即没有同步,没有对易失性操作,没有写入最终字段。这些写入之间没有发生之前的关系,因此可以对它们进行重新排序。xa

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

一种方法是最终确定。这是有效的,因为JMM说在构造函数返回之前写入最终字段 - 在构造函数返回之后发生的操作之前。由于 是在构造函数返回之后写入的,因此在写入 之前会进行最终字段的初始化,并且不允许重新排序。xaxa

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

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

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

当然,如果读取器线程之前调用,它可能会被视为 null,但这里没有内存模型问题。get()a

你的和例子很有趣,你的评估基本上是正确的。在赋值到最终数组字段之前发生的对数组元素的写入不能在之后重新排序。在赋值到最终数组字段之后发生的对数组元素的写入可能会相对于稍后发生的其他内存操作重新排序,因此其他线程可能确实会看到过期的值。FooBar

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

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

引用:


答案 2

从Java内存模型的角度来看 - 是的。不过,这并不意味着你会在实践中观察到它。

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

当然,编译器和 JVM 可能会通过在生成的代码中放置内存屏障来指示 CPU 不要对这些赋值进行重新排序,但对所有对象这样做会破坏 CPU 的性能,而 CPU 可能严重依赖于这种主动的优化。这就是为什么Java内存模型没有为这种情况提供任何特殊保证。

例如,这导致了Java内存模型下的Double check锁定单例实现中众所周知的缺陷。


推荐