【问题标题】:Concurrent access to a public field. Why is it possible to observe inconsistent state?对公共字段的并发访问。为什么可以观察到不一致的状态?
【发布时间】:2015-12-30 07:31:33
【问题描述】:

我正在阅读 B. Goetz Java Concurrency In practice,现在我在 section 3.5 关于安全发布。他说:

// Unsafe publication
public Holder holder;
public void initialize() {
    holder = new Holder(42);
}

这个不正当的发布可能允许另一个线程观察到 部分构造的对象。

我不明白为什么可以观察到部分构造的子对象。假设构造函数Holder(int) 不允许this 逃逸。因此,构造的引用只能由调用者观察。现在,正如JLS 17.7 所说:

对引用的写入和读取始终是原子的,无论 它们是作为 32 位还是 64 位值实现的。

线程不可能观察到部分构造的对象。

我哪里错了?

【问题讨论】:

  • 顺便说一句,我刚刚点击了您提供的 JCIP 链接,我注意到这是本书的全文! Afaik,JCIP 仍受版权保护,该副本不应该在那里。这是github的人要监控和处理的事情,但是我这里分享链接是不合适的。
  • @yshavit 是的,但我想既然它是在 github 上发布的,那还不如在这里发布……我想 Brian Goetz 知道这件事……
  • “它在 github”与“我可以下载它”一样,不能保证合法分发。在其网站jcip.net 上没有该书的免费许可副本。这几乎可以肯定是盗版链接,您应该删除它。

标签: java multithreading


【解决方案1】:

所以,构造的引用只能被调用者观察到。

这就是你的逻辑中断的地方,尽管这似乎是一个完全合理的说法。

首先要做的事情:17.7 中提到的原子性只是说,当您阅读参考资料时,您将看到所有先前的值(从其默认值 null 开始)或所有后续值。您将永远不会获得一些位对应于值 1 和一些位对应于值 2 的引用,这实际上会使它成为对 JVM 堆中随机位置的引用——这将是可怕的!基本上他们是在说,“引用本身要么为空,要么指向内存中的有效位置。”但那段记忆里有什么,这就是事情变得奇怪的地方。

设置一个简单的例子

我假设这个简单的持有人:

public class Holder {
    int value; // NOT final!
    public Holder(int value) { this.value = value; }
}

鉴于此,当您执行holder = new Holder(42) 时会发生什么?

  1. JVM 为新的 Holder 对象分配一些空间,并为其所有字段使用默认值(即,value = 0
  2. JVM 调用 Holder 构造函数
    • JVM 将 <new instance>.value 设置为传入值 (42)。
    • 构造函数完成
  3. JVM 返回对我们刚刚分配的那个对象的引用,并将Holder.holder 设置为这个新的引用

重新排序让生活变得艰难(但它也让程序变得更快!)

问题是另一个线程可以以任何顺序查看这些事件,因为它们之间没有同步点。那是因为构造函数没有任何特殊的同步或发生前的语义(这是轻微的谎言,但稍后会更多)。您可以在JLS 17.4.4 看到“同步”操作的完整列表;请注意,这里没有关于构造函数的内容。

因此,另一个线程可能会将这些操作按 (1, 3, 2) 排序。这意味着,如果在事件 1 和 3 之间排序了其他事件——例如,如果有人将Holder.holder.value 读入本地变量——那么他们将看到新分配的对象,但在构造函数运行之前具有它的值:你会看到Holder.holder.value == 0。这被称为部分构造的对象,它可能会让人很困惑。

如果构造函数有多个步骤(设置多个字段,或设置然后更改字段),那么您可以看到这些步骤的任何顺序。几乎所有的赌注都没有了。哎呀!

构造函数和final 字段

我在上面提到,当我断言构造函数没有任何特殊的同步语义时,我撒了谎。假设您没有泄漏this,则有一个例外:任何final 字段保证被视为在构造函数的末尾(请参阅JLS 17.5)。

您可以将其视为第 2 步和第 3 步之间的一种同步点,但它适用于final 字段。

  • 不适用于非最终字段
  • 它不适用于其他同步点。
  • 但是,它确实扩展到您通过final 字段访问的任何状态。所以,如果你有一个final List<String>,并且你的构造函数初始化它然后添加一些值,那么所有线程都可以保证看到这个列表,至少它在构造函数末尾的状态,包括那些add来电。 (如果你在构造函数之后修改列表,没有同步,那么所有的赌注都会再次关闭。)

这就是为什么在上面的示例中value 不是最终的很重要。如果是,那么您将无法看到Holder.holder.value == 0

【讨论】:

  • Reordering makes life hard (but it also makes programs fast!) 准确地说,我忘记了这个优化。因此,JVM 可以对语句重新排序,以便仅使用默认值发布对象...
  • If you modify the list after the constructor, without synchronization, then all bets are off again. 我对此有疑问。我认为这取决于列表的实现。例如,如果列表的内部状态声明为volatile,我们会观察到所有的状态变化,对吧?
  • 是的,这是真的。如果列表本身是线程安全的,那就没问题了。我应该更清楚这一点。 :) 我的意思是发生之前的同步,这将包括对易失性字段的写入/读取。
【解决方案2】:

Holder的构造大致分为三部分:

  1. 分配内存
  2. 运行初始化程序
  3. 赋值字段holder

但是,出于性能原因,这些已重新排序,并且可能会按如下方式运行:

  1. 分配内存
  2. 赋值字段holder
  3. 运行初始化程序

因此,可能已经将部分构造的 Object 分配给了一个字段。从单线程的角度来看,这没有任何问题。但从多线程的角度来看,这会导致明显的问题。

【讨论】:

    【解决方案3】:

    现在,正如 JLS 17.7 所述,“对引用的写入和读取始终是原子的......”

    这可能比你想象的要少。这意味着,分配给引用变量的值永远不会被撕裂

    如果某个线程更新了最初引用对象 A 的非易失性引用变量,将其更改为引用对象 B,则其他线程在检查变量时将看到 A 引用或 B 引用。没有线程会看到由旧值中的一些位和新值中的一些位组成的无效引用。

    特别是,所有引用读取和写入都是“原子”的要求确实不是意味着所有引用读取和写入都具有volatile 语义。他们没有;如果一个线程更新一个非易失性引用变量以指向一个新构造的对象,那么另一个线程在检查该变量时可以获得新的引用,但会看到对象本身处于部分初始化或未初始化的状态。

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 2013-12-13
      • 2017-02-02
      • 1970-01-01
      • 2012-12-26
      • 2011-11-07
      • 2016-05-08
      • 2020-02-11
      • 1970-01-01
      相关资源
      最近更新 更多