同步以确保另一个线程将看到对不可变对象的引用

我正在研究这个,以了解新JMM中最终字段的行为(5以后)。 这个概念很清楚:在正确构造对象之后,保证初始化的最终字段对所有线程的可见性。

但是在本节的最后,我读到了这个,这让我感到困惑:

现在,说完所有这些,如果在一个线程构造一个不可变对象(即一个只包含最终字段的对象)之后,你想确保所有其他线程都能正确看到它,你通常还需要使用同步。 例如,没有其他方法可以确保第二个线程可以看到对不可变对象的引用。

这是否意味着虽然单个最终字段(组成不可变对象)没有同步(例如,此处可见性)问题。 但是,在线程中首次创建的不可变对象本身在其他线程中可能不可见(正确创建)?

如果是这样,虽然我们可以跨线程共享初始化的不可变对象而没有任何线程不安全的担忧,但在创建时,他们需要“特别关注”线程安全,就像其他可变项一样?

JLS第17.5节中定义的最终字段的语义保证:

在该对象完全初始化之后只能看到对象引用的线程可以保证看到该对象的最终字段的正确初始化值。

换句话说,它表示如果一个线程看到一个完全初始化的对象, 那么它可以保证看到它的最终字段被正确初始化。

但是,无法保证对象对给定线程可见。 这是一个不同的问题。

如果您不使用某种同步来发布对象的引用,那么另一个线程可能永远无法看到对它的引用。

请考虑以下代码:

final class A { private final int x; A(int x) { this.x = x; } public getX() { return x; } } class Main { static volatile A a1 = null; static A a2 = null; public static void main(String[] args) { new Thread(new Runnable() { void run() { try { while (a1 == null) Thread.sleep(50); System.out.println(a1.getX()); } catch (Throwable t) {} }}).start() new Thread(new Runnable() { void run() { try { while (a2 == null) Thread.sleep(50); System.out.println(a2.getX()); } catch (Throwable t) {} }}).start() a1 = new A(1); a2 = new A(1); } } 

请注意, a1字段是易失性的。 这样可以确保最终对所有读取它的线程都可以看到对该字段的写入。 字段a2不是易失性的(因此,一个线程对该字段的写入可能永远不会被其他线程注意到)。

在这段代码中,我们可以确定线程1将完成执行(也就是说,它会看到a1 != null 。但是,线程2可能会停止,因为它永远不会看到对字段a2的写入,因为它不易变。

你想确保所有其他线程都能正确看到它,你仍然通常需要使用同步。 例如,没有其他方法可以确保第二个线程可以看到对不可变对象的引用。

我会对一个文本略微持怀疑态度,这个文本通常在句子空间中变成其他方式 。 事实上,这是真的取决于“使用同步”究竟是什么意思。

Java语言规范的相关部分是:

可以通过先发生关系来排序两个动作。 如果一个动作发生在另一个动作之前 ,那么第一个动作第二个动作之前是可见的并且在第

更具体地说,如果两个动作共享发生在之前的关系,则它们不一定必须按照该顺序发生在它们不与之共享的任何代码之间。 例如,在具有另一个线程中的读取的数据争用中的一个线程中的写入可能看起来不按顺序发生到那些读取。

可以通过多种方式建立之前发生的事情:

如果我们有两个动作x和y,我们写hb(x,y)来表示x发生在y之前。

  • 如果x和y是同一个线程的动作,并且x在程序顺序中出现在y之前,那么hb(x,y)。
  • 从对象的构造函数的末尾到该对象的终结器(第12.6节)的开始有一个发生前的边缘。
  • 如果动作x与后续动作y同步,那么我们也有hb(x,y)。
  • 如果是hb(x,y)和hb(y,z),那么hb(x,z)。

哪里

同步动作引发与动作的同步关系,定义如下:

  • 监视器m上的解锁操作与m上的所有后续锁定操作同步(其中后续操作根据同步顺序定义)。
  • 对volatile变量(第8.3.1.4节)的写入v与任何线程对v的所有后续读取同步(其中后续根据同步顺序定义)。
  • 启动线程的操作与其启动的线程中的第一个操作同步。
  • 向每个变量写入默认值(零,false或null)与每个线程中的第一个操作同步。 虽然在分配包含变量的对象之前将默认值写入变量似乎有点奇怪,但从概念上讲,每个对象都是在程序开始时使用其默认初始化值创建的。
  • 线程T1中的最终操作与另一个检测到T1已终止的线程T2中的任何操作同步。 T2可以通过调用T1.isAlive()或T1.join()来完成此操作。
  • 如果线程T1中断线程T2,则T1的中断与任何其他线程(包括T2)确定T2已被中断的任何点同步(通过抛出InterruptedException或通过调用Thread.interrupted或Thread.isInterrupted)。

通过使字段成为最终,您可以确保在构造函数完成之前进行分配。 您仍需要确保访问对象之前完成构造函数的完成。 如果该访问发生在另一个线程中,则需要使用上面显示的6种方式中的任何一种建立同步 。 通常使用的是:

  1. 初始化完成后启动读取线程。 实际上,在启动其他线程之前初始化主线程中的对象可以很好地完成此任务。
  2. 声明其他线程用来访问对象volatile的字段。 例如:

     class CacheHolder { private static volatile Cache cache; public static Cache instance() { if (cache == null) { // note that several threads may get here at the same time, // in which case several caches will be constructed. cache = new Cache(); } return cache; } } 
  3. 同步块中的初始分配和字段读取。

     class CacheHolder { private static Cache cache; public synchronized static Cache instance() { if (cache == null) { cache = new Cache(); } return cache; } } 

使所有字段成为final将确保它们正确地发布到其他线程。 该评论可能涉及以下情形:

 private myField; public void createSomething() { myField = new MyImmutableClass(); } 

在这种情况下,您仍然需要围绕对myField任何访问进行适当的同步,否则其他线程可能永远不会看到新创建的对象。

我相信作者提到了非final字段引用不可变对象时的情况。 如果引用本身是final ,则不需要额外的同步。
另外考虑的是,上面仅适用于在对象的构造函数内初始化的对象字段。