【问题标题】:Immutability and reordering不变性和重新排序
【发布时间】:2013-01-15 11:17:49
【问题描述】:

下面的代码(Java Concurrency in Practice 清单 16.3)显然不是线程安全的:

public class UnsafeLazyInitialization {
    private static Resource resource;

    public static Resource getInstance() {
        if (resource == null)
            resource = new Resource();  // unsafe publication
        return resource;
    }
}

然而,几页之后,在第 16.3 节中,他们指出:

UnsafeLazyInitialization 实际上是安全的 如果 Resource 是不可变的。

我不明白那句话:

  • 如果Resource 是不可变的,任何观察resource 变量的线程都将看到它为空或完全构造(感谢Java 内存模型提供的对最终字段的强大保证)
  • 但是,没有什么能阻止指令重新排序:特别是 resource 的两次读取可以重新排序(if 中的一次读取和return 中的一次读取)。因此,线程可以在 if 条件中看到非空 resource,但返回空引用 (*)。

我认为即使Resource 是不可变的,UnsafeLazyInitialization.getInstance() 也可以返回 null。是这样吗?为什么(或为什么不是)?


(*) 为了更好地理解我关于重新排序的观点,Jeremy Manson 的this blog post 是 JLS 第 17 章关于并发的作者之一,他解释了 String 的哈希码是如何通过良性数据竞争安全发布的,并且由于可能的重新排序与我上面描述的非常相似,删除局部变量的使用如何导致哈希码错误地返回 0:

我在这里所做的是添加一个额外的读取:在返回之前第二次读取哈希。听起来很奇怪,也不太可能发生,第一次读取可以返回正确计算的哈希值,第二次读取可以返回 0!这在内存模型下是允许的,因为该模型允许对操作进行广泛的重新排序。第二次读取实际上可以在您的代码中移动,以便您的处理器在第一次之前执行它!

【问题讨论】:

  • 之所以如此,是因为如果Resource 是不可变的,那么它的所有实例都是等价的,即使两个线程竞争并获得两个不同的Resource 实例,在功能上它们也是相同的。
  • @AbhinavSarkar 我不是在问两个线程是否可以返回不同的资源实例——我是在问一个线程是否可以返回 null。
  • 如果实例是可变的,那么它的状态可以被修改,并且第二个 null seeer 可以初始化一个具有原始状态的新实例。
  • 现在这就是我所说的建设性辩论。
  • 哇...今天学到了一些让我头晕目眩的东西。我不敢相信这是 Java 的工作方式。真棒问题!谢谢!

标签: java multithreading concurrency java-memory-model


【解决方案1】:

我认为你在这里的困惑是作者所说的安全出版的意思。他指的是非空资源的安全发布,但您似乎明白这一点。

您的问题很有趣 - 是否可以返回 null 缓存的资源值?

是的。

允许编译器像这样重新排序操作

public static Resource getInstance(){
   Resource reordered = resource;
   if(resource != null){
       return reordered;
   }
   return (resource = new Resource());
} 

这不违反顺序一致性规则,但可以返回空值。

这是否是最好的实现还有待商榷,但没有规则可以防止这种类型的重新排序。

【讨论】:

  • @assylias 根据您与 Jeremy Manson 的链接更新
  • @SeanOwen 我已经通读了 JLS 规范,但是 by JLS 没有禁止对读取相同变量的两个读取进行重新排序。此外,by JLS 没有禁止重新排序读取和写入(尽管这是无稽之谈)。请不要只是思考并为你不确定的事实争论,而要尝试找到一些证据。
  • @proskor compiler or runtime is allowed to do such things, then it becomes very hard to reason about thread safety. 是的!作为开发人员,当您在单线程环境中执行程序时,您可以保证它按照您编写时的样子执行,但仅此而已。 Assylias 确实指出了这一点,但他的问题是“是否有可能返回 null”。是的,这就是我的观点。
  • @SeanOwen,没有没有正式的指南如何将java源代码编译成字节码。面对缺乏 volatile 的情况,java-source 编译器和 JVM 都可以随意进行任何重新排序,只要顺序执行符合规范,它就是合法的。从技术上讲,您可以在任何地方设置一个繁忙的循环,这将是合法的。您将实施质量与 JLS 混淆了。
  • @proskor,但这不仅仅是重新排序,这些是可以完全掩盖程序的优化 - 晦涩不会出错,JIT 做了很多优化和如果认为合适,则重新排序,CPU 通常会使用乱序执行和分支预测。 Alpha CPU 实际上可以猜测一个值,然后检查猜测是否符合预期……这一切都是公平的游戏。这正是 JMM 存在的原因——必要时禁止此类执行。
【解决方案2】:

将JLS规则应用到这个例子之后,我得出结论getInstance肯定可以返回null。特别是JLS 17.4

内存模型决定了程序中每个点可以读取哪些值。每个单独线程的操作必须按照该线程的语义进行操作,除了每次读取看到的值由内存模型确定

很明显,在没有同步的情况下,null 是该方法的合法结果,因为两个读取中的每一个都可以观察到任何东西。


证明

读写分解

程序可以分解如下(看清楚读写):

                              Some Thread
---------------------------------------------------------------------
 10: resource = null; //default value                                  //write
=====================================================================
           Thread 1               |          Thread 2                
----------------------------------+----------------------------------
 11: a = resource;                | 21: x = resource;                  //read
 12: if (a == null)               | 22: if (x == null)               
 13:   resource = new Resource(); | 23:   resource = new Resource();   //write
 14: b = resource;                | 24: y = resource;                  //read
 15: return b;                    | 25: return y;                    

JLS 怎么说

JLS 17.4.5 给出了允许读取观察写入的规则:

如果在执行跟踪的部分发生之前的顺序中,我们说变量 v 的读取 r 被允许观察到 v 的写入 w:

  • r 不排在 w 之前(即不是 hb(r, w)),并且
  • 没有将 w' 写入 v(即没有将 w' 写入 v 使得 hb(w, w') 和 hb(w', r))。

规则的应用

在我们的示例中,假设线程 1 看到 null 并正确初始化 resource。在线程 2 中,无效执行将是 21 观察 23(由于程序顺序) - 但任何其他写入(10 和 13)都可以通过读取来观察:

  • 10 发生在所有操作之前,因此在 10 之前不会对读取进行排序
  • 21和24与13没有hb关系
  • 13 不会发生在 23 之前(两者之间没有 hb 关系)

因此允许 21 和 24(我们的 2 次读取)观察 10(空)或 13(非空)。

返回null的执行路径

特别是,假设线程 1 在第 11 行看到 null 并在第 13 行初始化 resource,线程 2 可以合法地执行如下:

  • 24: y = null(读写10)
  • 21: x = non null(读写13)
  • 22: false
  • 25: return y

注意:澄清一下,这并不意味着 T2 看到非 null 并且随后看到 null(这将违反因果关系要求) - 这意味着从执行的角度来看,这两个读取已经重新排序并且第二个在第一个之前提交 - 但是根据初始程序顺序,它看起来好像在前面的写入之前已经看到了后面的写入。

2 月 10 日更新

回到代码,valid reordering 将是:

Resource tmp = resource; // null here
if (resource != null) { // resource not null here
    resource = tmp = new Resource();
}
return tmp; // returns null

并且由于该代码是顺序一致的(如果由单个线程执行,它将始终具有与原始代码相同的行为)它表明满足了因果关系要求(存在产生结果的有效执行)。


在并发兴趣列表上发布后,我收到了一些关于重新排序合法性的消息,这证实了null 是合法结果:

  • 转换绝对是合法的,因为单线程执行不会区分。 [请注意] 转换似乎不明智 - 编译器没有充分的理由这样做。但是,考虑到大量的周围代码或编译器优化“错误”,它可能会发生。
  • 关于线程内排序和程序顺序的声明让我质疑事物的有效性,但最终 JMM 与执行的字节码有关。转换可以由 javac 编译器完成,在这种情况下 null 将完全有效。并且没有关于 javac 如何从 Java 源代码转换为 Java 字节码的规则,所以......

【讨论】:

  • @assylias 我对此不满意的唯一原因是(我必须找到证据)你不能“回到过去”并阅读旧值。也就是说,如果资源最初是非空的,那么我认为 JMM 不支持资源将是旧值(即空值)的场景。实际上,您指向的链接特别指出第二次阅读可以在第一次阅读之前完成(这意味着我的示例更多)。
  • 这太疯狂了!假设有两个布尔变量xy,初始设置为true,以及两个线程:线程1:while(x); y = false;和线程2:while(y); x = false;。显然,没有一个线程可以终止,因为 x 和 y 都设置为 true。但是,根据您的推理,这可能的!一个线程只需要看到另一个线程“在未来”的写入,这是允许的,因为它们之间没有 happens-before,因此它们可能会在其他线程中出现乱序。但是我的问题是:哪个线程先终止?
  • 但如果这是你的论点——没有顺序执行会让循环退出,因此它是不允许的——那么它也会使你的证明无效,因为类似地没有顺序执行导致也返回一个空指针。资源被读取为非空后无法返回空。
  • Resource resource 和赛车,我猜 B. Goetz 的位置将非常依赖于 JVM。如果 JVM 决定实际使用已经归零的内存(带写屏障,因此不需要执行任何初始化代码)但仍决定在 clinit 方法中设置“Resource=null”但不带任何屏障,如果另一个线程/代码中的类解析没有陷阱,它可能会这样做。我仍然看不到它如何竞争以及正确实现 JVM。
  • 也许我不会受欢迎,但我仍然不相信这种“重新排序”是有效的。恕我直言,这不是重新排序,而是程序转换,这是不一样的。这里引入了一个新变量,在原代码中不存在。我可以用 10 种不同的方式重写这个函数,它们都会做同样的事情(在单线程模型中,但在多线程模型中表现不同),但我们必须检查原始的那个。我认为发生之前的一致性出现了中断。
【解决方案3】:

2 月 10 日更新

我确信我们应该将两个阶段分开:编译执行

我认为是否允许返回null的决定因素是字节码是什么。我做了3个例子:

示例 1:

原始源代码,直译为字节码:

if (resource == null)
    resource = new Resource();  // unsafe publication
return resource;

字节码:

public static Resource getInstance();
Code:
0:   getstatic       #20; //Field resource:LResource;
3:   ifnonnull       16
6:   new             #22; //class Resource
9:   dup
10:  invokespecial   #24; //Method Resource."<init>":()V
13:  putstatic       #20; //Field resource:LResource;
16:  getstatic       #20; //Field resource:LResource;
19:  areturn

这是最有趣的情况,因为有 2 个reads(第 0 行和第 16 行),中间有 1 个write(第 13 行)。我声称它不可能重新排序,但让我们在下面检查一下。

示例 2

“编译器优化”代码,可以按字面意思重新转换为java,如下所示:

Resource read = resource;
if (resource==null)
    read = resource = new Resource();
return read;

那个字节码(其实我是通过编译上面的代码sn-p产生的):

public static Resource getInstance();
Code:
0:   getstatic       #20; //Field resource:LResource;
3:   astore_0
4:   getstatic       #20; //Field resource:LResource;
7:   ifnonnull       22
10:  new     #22; //class Resource
13:  dup
14:  invokespecial   #24; //Method Resource."<init>":()V
17:  dup
18:  putstatic       #20; //Field resource:LResource;
21:  astore_0
22:  aload_0
23:  areturn

很明显,如果编译器“优化”,并且产生了类似上面的字节码,就会发生空读取(例如,我参考Jeremy Manson's blog

有趣的是a = b = c 是如何工作的:对新实例的引用(第 14 行)是重复的(第 17 行),然后存储了相同的引用,首先到b(资源,(Line#18))然后到a(读取,(Line#21))。

示例 3

让我们做一个更轻微的修改:阅读resource 一次!如果编译器开始优化(并使用寄存器,正如其他人提到的),这是比上面更好的优化,因为这里的第 4 行是“寄存器访问”而不是更昂贵的“静态访问”在示例 2 中。

Resource read = resource;
if (read == null)   // reading the local variable, not the static field
    read = resource = new Resource();
return read;

示例 3 的字节码(也是通过逐字编译上述内容创建的):

public static Resource getInstance();
Code:
0:   getstatic       #20; //Field resource:LResource;
3:   astore_0
4:   aload_0
5:   ifnonnull       20
8:   new     #22; //class Resource
11:  dup
12:  invokespecial   #24; //Method Resource."<init>":()V
15:  dup
16:  putstatic       #20; //Field resource:LResource;
19:  astore_0
20:  aload_0
21:  areturn

也很容易看出,不可能从这个字节码中得到 null,因为它的构造方式与 String.hashcode() 相同,只读取了 1 次 @ 的静态变量987654336@.

现在让我们看看示例 1

0:   getstatic       #20; //Field resource:LResource;
3:   ifnonnull       16
6:   new             #22; //class Resource
9:   dup
10:  invokespecial   #24; //Method Resource."<init>":()V
13:  putstatic       #20; //Field resource:LResource;
16:  getstatic       #20; //Field resource:LResource;
19:  areturn

你可以看到 Line#16(variable#20 的读取用于返回)大部分观察到 Line#13 的写入(variable#20 从构造函数的分配),因此将其放在前面是非法的 在执行第 13 行的任何执行顺序中。因此,不可能重新排序

对于 JVM,可以构建(并利用)一个分支(使用某些额外条件)绕过 Line#13 写入:条件是从 variable#20 读取的内容不能为空。

因此,对于 示例 1 的任何一种情况都不能返回 null。

结论:

查看上面的示例,示例 1 中的字节码不会生成 null示例 2 中的优化字节码 将产生 null,但还有一个更好的优化示例 3不会生产null

因为我们无法为所有编译器的所有可能优化做好准备,我们可以说在某些情况下是可能的,在其他一些情况下不可能@987654344 @,这一切都取决于字节码。此外,我们已经证明这两种情况至少有一个示例


较早的推理:参考 Assylias 的示例:主要问题是:VM 重新排序 11 和 14 读取是否有效(关于所有规范、JMM、JLS),即14 会在 11 之前发生吗?

如果可能发生,那么独立的Thread2可以用23写资源,所以14可以读null。我声明它不可能

实际上,因为有一个可能写入 13,它不会是一个有效的执行顺序。 VM 可以优化执行顺序,排除未执行的分支(仅剩下 2 次读取,没有写入),但要做出此决定,它必须执行第一次读取 (11),并且它必须不读取-null,因此第 14 次读取不能在第 11 次读取之前。所以,不可能返回null


不变性

关于不变性,我认为这个说法不是正确的:

如果 Resource 是不可变的,则 UnsafeLazyInitialization 实际上是安全的。

但是,如果构造函数是不可预测的,可能会出现有趣的结果。想象一下这样的构造函数:

public class Resource {
    public final double foo;

    public Resource() {
        this.foo = Math.random();
    }
}

如果我们有 Threads,则可能导致 2 个线程将收到一个行为不同的对象。所以,完整的声明应该是这样的:

如果 Resource 是不可变的并且它的初始化是一致的,那么 UnsafeLazyInitialization 实际上是安全的。

一致我的意思是调用Resource 的构造函数两次,我们将收到两个行为完全相同的对象(在两者上以相同的顺序调用相同的方法将产生相同的结果)。

【讨论】:

  • 在考虑非同步重新排序时,您确实应该/不能考虑多线程。 Thread-2 在第 23 行所做的写入与 Thread-1 看到的内容没有任何关系,只要 Thread-1 在孤立的情况下 100% 地看到相同的结果。我的回答说明了为什么线程可以看到空值。
  • 就不变性而言(至您的第二点)。如果 Resource 是不可变的,则在构造对象(存储-存储加载)后所有定义的字段都可用,这只能通过最终字段来实现。因此,您不会遇到不合理的字段分配
  • @JohnVint 在单线程虚拟机中,您是对的,但在当前示例中,无法预测 23 次写入是否会影响 14 次读取。这两种情况都是可能的(因为它不同步)。
  • 我应该提到我仍然相信 assylias 的例子在证明方面是不正确的(如果是真的会导致更多的混乱)。我认为不可能出现该执行路径。也就是说,如果resource = null 是发布前发生的默认空写入
  • @GaborSch 我以为这是您得出的结论,但不幸的是它不正确。 Java 中的 final 字段在对象构造期间有特殊的规则。看看docs.oracle.com/javase/specs/jls/se7/html/jls-17.html#jls-17.5when the object is seen by another thread, that thread will always see the correctly constructed version of that object's final fieldsint i = f.x; // guaranteed to see 3 .
【解决方案4】:

您要问的主要有两个问题:

1. getInstance() 方法能否因为重新排序而返回 null

(我认为这是您真正追求的,所以我会先尝试回答)

尽管我认为设计 Java 来实现这一点是完全疯狂的,但您似乎实际上是正确的 getInstance() 可以返回 null。

您的示例代码:

if (resource == null)
    resource = new Resource();  // unsafe publication
return resource;

在逻辑上与您链接到的博客文章中的示例 100% 相同:

if (hash == 0) {
    // calculate local variable h to be non-zero
    hash = h;
}
return hash;

Jeremy Manson 随后描述了他的代码由于重新排序而可能返回 0。起初,我不相信,因为我认为以下“发生在之前”的逻辑必须成立:

   "if (resource == null)" happens before "resource = new Resource();"
                                   and
     "resource = new Resource();" happens before "return resource;"
                                therefore
"if (resource == null)" happens before "return resource;", preventing null

但 Jeremy 在他的博客文章的评论中给出了以下示例,说明编译器如何有效地重写此代码:

read = resource;
if (resource==null)
    read = resource = new Resource();
return read;

这在单线程环境中的行为与原始代码完全相同,但在多线程环境中可能会导致以下执行顺序:

Thread 1                        Thread 2
------------------------------- -------------------------------------------------
read = resource;    // null
                                read = resource;                      // null
                                if (resource==null)                   // true
                                    read = resource = new Resource(); // non-null
                                return read;                          // non-null
if (resource==null) // FALSE!!!
return read;        // NULL!!!

现在,从优化的角度来看,这样做对我来说没有任何意义,因为这些事情的全部意义在于减少对同一位置的多次读取,在这种情况下,编译器没有任何意义不会生成if (read==null),而是防止出现问题。因此,正如 Jeremy 在他的博客中指出的那样,这很可能不太可能发生。但似乎,纯粹从语言规则的角度来看,实际上是允许的。

这个例子实际上已经在 J​​LS 中介绍过:

http://docs.oracle.com/javase/specs/jls/se7/html/jls-17.html#jls-17.4

Table 17.4. Surprising results caused by forward substitutionr2r4r5 的值之间观察到的效果等同于使用 read = resourceif (resource==null)return resource上面的例子。

旁白:为什么我将博客文章作为答案的最终来源?因为写它的人,也是写了 JLS 第 17 章关于并发的人!所以,他最好是对的! :)

2。使Resource 不可变会使getInstance() 方法线程安全吗?

考虑到潜在的null 结果,它可以独立于Resource 是否可变而发生,这个问题的直接简单答案是:(不严格)

如果我们忽略这种极不可能但可能的情况,答案是:取决于

代码的明显线程问题是它可能导致以下执行顺序(无需任何重新排序):

Thread 1                                 Thread 2
---------------------------------------- ----------------------------------------
if (resource==null) // true;  
                                         if (resource==null)          // true
                                             resource=new Resource(); // object 1
                                         return resource;             // object 1
    resource=new Resource(); // object 2
return resource;             // object 2

因此,非线程安全性来自这样一个事实,即您可能会从函数中返回两个不同的对象(即使不重新排序它们都不会是 null)。

现在,这本书可能想说的是:

Java 不可变对象(如字符串和整数)尽量避免为同一内容创建多个对象。因此,如果您在一个位置有"hello" 而在另一个位置有"hello",Java 将为您提供完全相同的对象引用。同样,如果您在一个位置有new Integer(5),在另一个位置有new Integer(5)。如果new Resource() 也是这种情况,您将获得相同的引用,并且上面示例中的object 1object 2 将是完全相同的对象。这确实会导致有效的线程安全函数(忽略重新排序问题)。

但是,如果您自己实现Resource,我认为甚至没有办法让构造函数返回对先前创建的对象的引用而不是创建新对象。因此,您应该不可能使 object 1object 2 成为完全相同的对象。但是,鉴于您正在使用相同的参数调用构造函数(两种情况下都没有),即使您创建的对象不是同一个确切的对象,它们也可能出于所有意图和目的,表现为如果它们是,也有效地使代码线程安全。

不过,不一定非要如此。例如,想象一下Date 的不可变版本。默认构造函数Date() 使用当前系统时间作为日期值。因此,即使对象是不可变的并且使用相同的参数调用构造函数,调用它两次也可能不会产生等效对象。因此getInstance() 方法不是线程安全的。

所以,作为一般性声明,我相信你从书中引用的那句话是完全错误的(至少在这里断章取意)。

补充回复:重新排序

我发现resource==new Resource() 示例有点过于简单,无法帮助我理解为什么允许通过 Java 进行这种重新排序是有意义的。所以让我看看我是否能想出一些真正有助于优化的东西:

System.out.println("Found contact:");
System.out.println(firstname + " " + lastname);
if (firstname==null) firstname = "";
if (lastname ==null) lastname  = "";
return firstname + " " + lastname;

在这里,在ifs 都产生false 的最有可能的情况下,将昂贵的字符串连接firstname + " " + lastname 进行两次是非最佳的,一次用于调试消息,一次用于返回。因此,在这里重新排序代码以执行以下操作确实是有意义的:

System.out.println("Found contact:");
String contact = firstname + " " + lastname;
System.out.println(contact);
if ((firstname==null) || (lastname==null)) {
    if (firstname==null) firstname = "";
    if (lastname ==null) lastname  = "";
    contact = firstname + " " + lastname;
}
return contact;

随着示例变得越来越复杂,并且当您开始考虑编译器跟踪它使用的处理器寄存器中已经加载/计算的内容并智能地跳过重新计算已经存在的结果时,这种效果实际上可能会变得更加更有可能发生。所以,即使我从没想过我昨晚睡觉时会这么说,但我现在确实相信这可能是真正允许代码优化发挥最大作用的必要/好的决定令人印象深刻的魔术。但它仍然让我觉得很危险,因为我认为很多人都没有意识到这一点,即使他们知道,在不同步所有内容的情况下如何正确编写代码也是相当复杂的(这将消除通过更灵活的优化获得的任何性能优势都翻了很多倍)。

我猜如果你不允许这种重新排序,任何对一系列处理步骤的中间结果的缓存和重用都将成为非法,从而取消可能的最强大的编译器优化之一.

【讨论】:

  • 是的,这是一个很好的综合讨论,但它仍然让我感到困惑,因为 JLS 说发生之前的一致性是不够的(17.4.8-1),并建议需要顺序一致性,但没有提到 17.4.7 中的顺序一致性,并表示它在 17.4.3 中会受到限制。但它会禁止乱序读取。
  • PS 我仍然不确定这种重新排序是如何发生的——在一致之前。 17.4.5 明确表示,当一个动作 x 在同一线程中按程序顺序出现在一个动作 y 之前时,x 发生在 y 之前。在这里,读取似乎具有因重新排序而违反的发生之前的关系。
  • @SeanOwen 我仍然想知道顺序一致性,所以我想我不能在那里给你一个好的答案。但是对于您的第二个问题,我认为 17.4.5 中稍低一点的声明回答了它:It should be noted that the presence of a happens-before relationship between two actions does not necessarily imply that they have to take place in that order in an implementation. If the reordering produces results consistent with a legal execution, it is not illegal. 如果您问我,所有这些允许的重新排序有点疯狂,但这是他们选择这样做的方式。跨度>
  • @GaborSch 嗯...我通常将字节码视为完整编译过程中的一个中间步骤,其中包括 javac(或类似)的工作和 JVM 的 JIT。优化可以发生在这条链上的任何地方,而不仅仅是第一步。所以,我不确定仅仅查看字节码而不是原始源代码会有多大帮助。显然,它会告诉您是否在 javac 步骤中已经发生了不好的事情,但它不一定会告诉您在 JIT 步骤中稍后不会发生任何不好的事情。如果允许 javac 引入有问题的优化,那么 JIT 也是如此......
【解决方案5】:

一旦它不是null,就没有设置对null 的引用。在另一个线程将其设置为非null 之后,一个线程可能会看到null,但我看不出如何可能相反。

我不确定指令 re-ordering 是这里的一个因素,但是两个线程的指令交错是一个因素。 if 分支在评估其条件之前无法以某种方式重新排序以执行。

【讨论】:

  • 为什么return resource;if (resource == null)resource的读取不能被重新排序?
  • 书中也有解释,第 16.1.3 章:“程序顺序规则。线程中的每个动作happens-before中的每个动作程序顺序后面出现的那个线程。”当然,resource 的读取可以由多个线程交错,但是线程不可能返回 null,因为在每个单独的线程中,if-part 总是在 return 语句之前进行评估。
  • @proskor 您如何解释我当时链接到的博客文章中给出的示例?此外,我认为您对程序顺序规则的解释不准确:参见示例 16.1.2 和清单 16.1 的解释。一个线程的动作可以从另一个线程的角度重新排序。
  • @proskor Happens before 在 JLS 中具有非常特殊的含义 - in particular如果两个动作共享一个happens-before 关系,它们不一定必须看起来已经发生在那个关系中对它们不共享发生前关系的任何代码进行排序。
  • 我不认为资源的两次读取可以重新排序,因为中间有一次写入。出于同样的原因,我不认为博文中对 hash 的两次读取也可以重新排序。当资源实际上为空时对条件进行映像,那么在评估 if 表达式之前无法执行第二个“return”-read。
【解决方案6】:

如果我错了,我很抱歉(因为我不是以英语为母语的人),但在我看来,上面提到的声明:

如果 Resource 是不可变的,则 UnsafeLazyInitialization 实际上是安全的。

脱离了上下文。此声明确实是关于使用初始化安全

初始化安全的保证允许正确构造的不可变对象在线程间安全共享 不同步

...

初始化安全保证对于正确构造的对象,所有线程都将看到正确的最终字段值 由构造函数设置的

【讨论】:

  • 这确实是有道理的——所以如果我理解得很好,你同意线程可以从 getInstance 返回 null(但由于不可变性,线程不能返回部分构造的对象)。跨度>
  • 是的,我同意。但我不同意,本书作者的意思是,如果Resource 是不可变的,您可以将代码留在清单 16.3 中,不用担心。
【解决方案7】:

仔细阅读您链接的帖子后,您是正确的,您发布的示例可以想象(在当前内存模型下)返回null。相关示例位于帖子的 cmets 中,但实际上,运行时可以这样做:

public class UnsafeLazyInitialization {
    private static Resource resource;

    public static Resource getInstance() {
        Resource tmp = resource;
        if (resource == null)
            tmp = resource = new Resource();  // unsafe publication
        return tmp;
    }
}

这符合单线程的约束,但如果多个线程正在调用该方法,则可能导致返回空值(tmp 的第一个赋值得到空值,if 块看到非空值, tmp 返回为空)。

为了使这个“安全”不安全(假设 Resource 是不可变的),您必须仅显式读取一次 resource(类似于您应该如何处理共享的 volatile 变量:

public class UnsafeLazyInitialization {
    private static Resource resource;

    public static Resource getInstance() {
        Resource cur = resource;
        if (cur == null) {
            cur = new Resource();
            resource = cur;
        }
        return cur;
    }
}

【讨论】:

【解决方案8】:

现在这是一个很长的回帖,但鉴于这个问题讨论了许多有趣的重新排序和并发的工作原理,我最近也参与其中。

暂时,如果我们不涉及并发,多线程情况下的动作和有效的重新排序。
“JVM 能否在单线程上下文中使用缓存值写后操作”。我觉得不行。鉴于在 if 条件下存在写入操作,缓存可以完全发挥作用。
回到这个问题,不变性确保对象在其引用可访问或发布之前已完全或正确创建,因此不变性肯定有帮助。但是这里有一个对象创建后的写操作。因此,第二次读取可以在同一个线程或另一个线程中缓存预写的值。不,一个线程可能不知道其他线程中的写入(假设线程之间不需要立即可见)。 因此返回 false null 的可能性(即在对象创建之后)是否无效。 (有问题的代码打破了单例,但我们不关心这里)

【讨论】:

    【解决方案9】:

    确实安全的是UnsafeLazyInitialization.resource 是不可变的,即该字段被声明为final:

    private static final Resource resource = new Resource();
    

    如果Resource 类本身是不可变的并且与您使用的实例无关,它也可能被认为是线程安全的。在这种情况下,两个调用可能会返回不同的 Resource 实例,但内存消耗会增加,具体取决于同时调用 getInstance() 的线程数)。

    这似乎牵强,我相信有一个错字,真正的句子应该是

    UnsafeLazyInitialization 实际上是安全的,如果 *r*esource 是 不可变。

    【讨论】:

    • 我认为没有错字,因为在您给出的第一个示例中,无需将 resource 设为 final 以确保安全发布。
    • 没有严格要求,但我不会没有最后的:这是一个简单的安全措施,可以防止以后的任何修改。
    • 您会感到惊讶,但有些情况final 字段可能在初始化之前处于状态并包含null 值。
    • @Andremoniy 例如,当您在构造对象时让this 转义时,确实会发生这种情况。
    • @assylias 我知道,我实际上是在谈论这个
    【解决方案10】:

    UnsafeLazyInitialization.getInstance() 永远不能返回 null

    我将使用@assylias 的桌子。

                                  Some Thread
    ---------------------------------------------------------------------
     10: resource = null; //default value                                  //write
    =====================================================================
               Thread 1               |          Thread 2                
    ----------------------------------+----------------------------------
     11: a = resource;                | 21: x = resource;                  //read
     12: if (a == null)               | 22: if (x == null)               
     13:   resource = new Resource(); | 23:   resource = new Resource();   //write
     14: b = resource;                | 24: y = resource;                  //read
     15: return b;                    | 25: return y;    
    

    我将使用线程 1 的行号。线程 1 在 11 读取之前看到 10 的写入,在 14 读取之前看到第 11 行的读取。这些是 线程内发生之前 关系,不要说任何关于线程 2 的内容。第 14 行的读取返回一个由 JMM 定义的值。根据时间的不同,它可能是在第 13 行创建的资源,也可能是线程 2 写入的任何值。但是写入必须发生在第 11 行读取之后。只有一个这样的写入,第 23 行的不安全发布。第 10 行对 null 的写入不在范围内,因为由于 intra-thread ordering,它发生在第 11 行之前。

    Resource 是否不可变并不重要。到目前为止,大多数讨论都集中在与不变性相关的线程间操作上,但是 intra 线程规则禁止允许此方法返回 null 的重新排序。规范的相关部分是JLS 17.4.7

    对于每个线程 t,t 在 A 中执行的动作与 将由该线程以程序顺序单独生成,其中 每次写入 w 写入值 V(w),假设每次读取 r 看到 值 V(W(r))。每次读取看到的值由内存决定 模型。给出的程序顺序必须反映其中的程序顺序 动作将根据线程内语义执行 P.

    这基本上意味着虽然可以重新排序读取和写入,但对 same 变量的读取和写入必须看起来像它们发生的那样,以便执行读取和写入的线程。

    只有一次写入 null(在第 10 行)。任何一个线程都可以看到它自己的资源副本或其他线程的副本,但是它看不到之前写入 null 它读取任一资源之后。

    附带说明,null 的初始化发生在单独的线程中。 JCIP 中关于安全出版的部分指出:

    静态初始化器在类初始化时由 JVM 执行 时间;由于 JVM 中的内部同步,这种机制 保证安全地发布以这种方式初始化的任何对象 [JLS 12.4.2].

    可能值得尝试编写一个让UnsafeLazyInitialization.getInstance() 返回null 的测试,并获得一些建议的等效重写以返回null。您会发现它们并不真正等同。

    编辑

    为了清楚起见,这是一个将读取和写入分开的示例。假设有一个公共静态变量对象。

    public static Object object = new Integer(0);
    

    线程 1 写入该对象:

    object = new Integer(1);
    object = new Integer(2);
    object = new Integer(3);
    

    线程 2 读取该对象:

    System.out.println(object);
    System.out.println(object);
    System.out.println(object);
    

    没有任何形式的同步提供线程间发生前的关系,线程 2 可以打印出许多不同的东西。

    1, 2, 3
    0, 0, 0
    3, 3, 3
    1, 1, 3
    etc.
    

    但它不能打印出像 3、2、1 这样的递减序列。17.4.7 中指定的线程内语义严重限制了此处的重新排序。如果不是使用三次object,而是将示例更改为使用三个单独的静态变量,则可能会产生更多输出,因为对重新排序没有限制。

    【讨论】:

    • 不质疑您回答的信息,我会说测试现在无关紧要,因为那是环境和JVM相关的。
    • 一些 cmets:(i)如果可以(假设)返回 null 的线程确实写了一些东西,那么 17.4.7 引用的第一部分将适用 - 如果它只读取非 null 则空值(因此在两者之间不执行写入)只有第二部分适用“每次读取看到的值由 JMM 确定”。 (ii) null (默认值)被安全发布,但不会在随后的写入中产生可见性问题。 (iii) 你不能通过测试证明它不能返回 null - 如果你使用的 JVM 没有执行“有罪重新排序”,它永远不会发生......
    • 关键部分是Values seen by each read are determined by the memory model. 假设写入发生在另一个线程中,resource 的第一次读取可能是非空的,而第二次读取是空的。解释很简单,一个线程中的写入与另一个线程中的读取之间没有发生之前的关系,因此可以读取任何内容。 (我应该注意到,事后看来,以前的答案和评论讨论很简单)
    • @GaborSch JMM 指定的行为不会因环境和硬件而异。测试并不是绝对必要的,我认为 JMM 文档就足够了。我只是建议测试以适应我的答案,因为它是逆向的。
    • @GaborSch 这是一个有趣的论点。我的回答证明返回 null 是在一致之前发生的 - 但是,我还没有证明它满足 JMM 的因果关系要求(我不知道从哪里开始) - 你的观点可能是答案的开始。跨度>
    猜你喜欢
    • 1970-01-01
    • 2023-03-21
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2010-09-23
    • 2012-07-03
    相关资源
    最近更新 更多