【问题标题】:Not thread-safe Object publishing非线程安全的对象发布
【发布时间】:2010-12-09 22:28:46
【问题描述】:

阅读《Java并发实战》,第3.5节有这部分:

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

除了创建两个Holder 实例存在明显的线程安全隐患之外,该书还声称可能会出现发布问题。

此外,对于 Holder 类,例如

public Holder {
    int n;
    public Holder(int n) { this.n = n };
    public void assertSanity() {
        if(n != n)
             throw new AssertionError("This statement is false.");
    }
}

可以抛出AssertionError

这怎么可能?我能想到的唯一允许这种荒谬行为的方法是,如果 Holder 构造函数不会阻塞,那么将创建对实例的引用,而构造函数代码仍在不同的线程中运行。

这可能吗?

【问题讨论】:

  • 这意味着对象中的所有字段都必须是最终的。无论如何我可以证明自己这可能发生。我试过了请帮忙

标签: java concurrency thread-safety


【解决方案1】:

之所以会这样,是因为 Java 的内存模型很弱。它不保证读取和写入的顺序。

可以使用以下两个代表两个线程的代码 sn-ps 来重现这个特殊问题。

线程 1:

someStaticVariable = new Holder(42);

线程 2:

someStaticVariable.assertSanity(); // can throw

从表面上看,这似乎不可能发生。为了理解为什么会发生这种情况,您必须超越 Java 语法并降低到低得多的水平。如果您查看线程 1 的代码,它基本上可以分解为一系列内存写入和分配:

  1. 将内存分配给指针 1
  2. 将 42 写入指针 1 的偏移量 0
  3. 将指针 1 写入 someStaticVariable

由于Java的内存模型较弱,所以从线程2的角度来看,代码完全可以按如下顺序实际执行:

  1. 将内存分配给指针 1
  2. 将指针 1 写入 someStaticVariable
  3. 将 42 写入指针 1 的偏移量 0

可怕吗?是的,但它可能会发生。

这意味着线程 2 现在可以在 n 获得值 42 之前调用 assertSanity。值 n 可能在 assertSanity 期间被读取两次,一次在操作之前 # 3 完成后一次,因此看到两个不同的值并引发异常。

编辑

According to Jon SkeetAssertionError 可能仍会出现在 Java 8 中,除非该字段是最终字段。

【讨论】:

  • @Jared:我忘记了只保证最终字段。 IIRC,ECMA CLI 规范在这方面也很弱,但 .NET 内存模型使所有写入有效地易失。这只是 IIRC :)
  • @Jon 您在 .Net 和 ECMA 角度上是正确的。不过,在审查 F# 库和代码库的更改时,它会带来有趣的乐趣,因为它们符合 ECMA 模型。
  • 确实很可怕。我从你那里学到了一些我不知道的关于 java 内存模型的问题——这让我很害怕,因为它实际上意味着世界上 90% 的 java 代码都坏了。此外,您的回答速度让我感到惊讶。根据我的计算,您总共花了大约 5 分钟来回答我的问题!非常感谢您的努力。
  • sun 的好人解释了上述“发生在之前”的关系。 java.sun.com/javase/6/docs/api/java/util/concurrent/…(#参见内存一致性属性部分)
  • @JaredPar 您应该删除您的编辑 - 在当前内存模型 (Java 5+) 下,n != n 可能返回 true。
【解决方案2】:

Java 内存模型使用使得对 Holder 引用的赋值可能在赋值给对象中的变量之前变得可见。

然而,从 Java 5 开始生效的最新内存模型使得这不可能,至少对于 final 字段:构造函数中的所有赋值“发生在”对新对象的引用对变量的任何赋值之前。有关更多详细信息,请参阅Java Language Specification section 17.4,但这里是最相关的 sn-p:

一个对象被认为是 完全初始化时 构造函数完成。一个线程 只能看到对对象的引用 在该对象已完全 初始化保证看到 为此正确初始化值 对象的最终字段

因此您的示例仍然可能失败,因为 n 不是最终的,但如果您将 n 设为最终的应该没问题。

当然是:

if (n != n)

假设 JIT 编译器没有优化它,非最终变量肯定会失败 - 如果操作是:

  • 获取 LHS:n
  • 获取 RHS:n
  • 比较 LHS 和 RHS

那么值可能会在两次提取之间发生变化。

【讨论】:

  • 这意味着对象中的所有字段都必须是最终的。无论如何我可以证明自己这可能发生。我试过了请帮忙
  • javap 表明它不会优化它,所以你的假设是正确的:1:getfield #2 4:aload_0 5:getfield #2 8:if_icmpeq 21
  • 你引用的是关于最终语义的。但是对于这条语句:Holder holder = new Holder(),最新的JMM能保证在astore之前执行吗?你能告诉我JLS中的部分吗?
  • @Richard:完全不确定,恐怕。
  • @JonSkeet 这意味着如果没有应用同步,另一个线程仍然可以看到部分初始化的对象
【解决方案3】:

嗯,在书中它说明了第一个代码块:

这里的问题不是持有人 类本身,但持有人是 未正确发布。然而, 持有人可以免受不当 通过声明 n 字段发布 是最终的,这将使 Holder 不可变的;见第 3.5.2 节

对于第二个代码块:

因为没有使用同步 使 Holder 对其他人可见 线程,我们说持有人不是 正确发布。有两件事可以去 错误发布不当 对象。其他线程可以看到 持有人字段的陈旧值,以及 因此看到一个空引用或其他 较旧的值,即使某个值具有 被放置在持有人。但更糟糕的是, 其他线程可以看到最新的 持有人参考的价值,但 状态的陈旧值 持有人。[16]让事情变得更少 可预测的,线程可能会过时 第一次读取字段时的值 然后是更新的值 下一次,这就是为什么 assertSanity 可以抛出 AssertionError。

我认为 JaredPar 在他的评论中已经明确表达了这一点。

(注意:这里不是在寻找选票——答案比 cmets 提供更详细的信息。)

【讨论】:

    【解决方案4】:

    基本问题是,如果没有适当的同步,对内存的写入可能会在不同的线程中表现出来。经典例子:

    a = 1;
    b = 2;
    

    如果您在一个线程上执行此操作,则第二个线程可能会在 a 设置为 1 之前将 b 设置为 2。此外,在第二个线程看到其中一个变量获得之前,可能会有无限的时间更新,另一个变量正在更新。

    【讨论】:

      【解决方案5】:

      从理智的角度来看,如果您认为该声明

      if(n != n)

      是原子的(我认为这是合理的,但我不确定),那么断言异常永远不会被抛出。

      【讨论】:

      • 有人愿意解释一下 -1 吗?
      • 没有投反对票,但您认为n !=n 是错误的。
      【解决方案6】:

      此示例属于“对包含最终字段的对象的引用没有转义构造函数”

      当你用 new 操作符实例化一个新的 Holder 对象时,

      1. Java 虚拟机首先会在堆上分配(至少)足够的空间来保存在 Holder 及其超类中声明的所有实例变量。
      2. 其次,虚拟机会将所有实例变量初始化为其默认初始值。 3.c 第三,虚拟机会调用Holder类中的方法。

      请参考以上:http://www.artima.com/designtechniques/initializationP.html

      假设:第一个线程从上午 10:00 开始,它通过调用 new Holer(42) 来调用固定的 Holder 对象, 1) Java 虚拟机首先会在堆上分配(至少)足够的空间来保存在 Holder 中声明的所有实例变量及其超类。 -- 时间是 10:01 2)其次,虚拟机会将所有实例变量初始化为它们的默认初始值——它会在10:02时间开始 3)第三,虚拟机会调用Holder类中的方法--会在10点04分开始

      现在 Thread2 开始于 --> 10:02:01 时间,它将在 10:03 调用 assertSanity(),到那时 n 已初始化为默认值零,第二个线程读取陈旧数据。

      //不安全的发布 公众持有人;

      如果您公开最终持有人持有人将解决此问题

      私有整数 n;如果您将私有最终 int n;将解决这个问题。

      请参考:http://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html 在新 JMM 下最终字段如何工作?

      【讨论】:

        【解决方案7】:

        这个例子我也很困惑。 我找到了一个可以彻底解释该主题的网站,读者可能会发现它很有用: https://www.securecoding.cert.org/confluence/display/java/TSM03-J.+Do+not+publish+partially+initialized+objects

        编辑: 链接中的相关文字说:

        JMM 允许编译器为新的 Helper 分配内存 对象并将对该内存的引用分配给辅助字段 在初始化新的 Helper 对象之前。换句话说, 编译器可以重新排序对助手实例字段的写入和 编写初始化 Helper 对象(即 this.n = n),以便 前者首先发生。这可能会暴露一个比赛窗口,在此期间 其他线程可以观察到部分初始化的 Helper 对象 实例。

        【讨论】:

          猜你喜欢
          • 2016-05-15
          • 1970-01-01
          • 2014-08-29
          • 2021-05-18
          • 2012-03-26
          • 2013-12-10
          • 1970-01-01
          • 1970-01-01
          • 2011-03-30
          相关资源
          最近更新 更多