42

下面的代码(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都将看到它为 null 或完全构造(感谢 Java 内存模型提供的对 final 字段的强大保证)
  • 但是,没有什么可以阻止指令重新排序:特别是可以对 的两次读取resource进行重新排序(在 the 中读取if一次,在 中读取一次return)。resource因此,线程可以在条件中看到非空值,if但返回空引用 (*)。

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


(*) 为了更好地理解我关于重新排序的观点,Jeremy Manson 的这篇博文解释了如何通过良性数据竞争安全地发布 String 的哈希码,以及如何删除由于可能的重新排序与我上面描述的非常相似,使用局部变量可能会导致哈希码错误地返回 0:

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

4

10 回答 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 个read(Line#0 和 Line#16),中间有 1 个write(Line#13)。我声称无法重新排序,但让我们在下面检查一下。

示例 2

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

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

它的字节码(实际上我是通过编译上面的代码片段产生的):

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 的博客

有趣的是,它是如何a = b = c工作的:对新实例的引用(第 14 行)被复制(第 17 行),然后存储相同的引用,首先到b(resource, (Line#18)) 然后到a(阅读,(第 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 的静态变量resource

现在让我们检查示例 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#20for return 的读取)最能观察到 Line#13 的写入(variable#20来自构造函数的分配),因此将其放在 Line#13 执行的任何执行顺序中都是非法的。因此,无法重新排序

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

因此,在任何情况下,示例 1都不可能返回 null。

结论:

看到上面的例子,例子 1 中看到的字节码将不会产生null示例 2 中的优化字节码会产生,但null示例 3中有更好的优化,它不会null产生。

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


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

如果它可能发生,那么独立Thread2可以用 23 写入资源,因此 14 可以读取null. 我声明这是不可能的

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


不变性

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

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

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

public class Resource {
    public final double foo;

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

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

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

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

于 2013-01-31T15:36:53.963 回答
3

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

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

是的。

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

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

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

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

于 2013-01-31T16:25:10.750 回答
3

在将 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 被允许观察 w 到 v 的写入,如果,在执行跟踪的发生之前的部分顺序中:

  • 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 并resource在第 13 行初始化,线程 2 可以合法地执行如下:

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

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

2月10日更新

回到代码,有效的重新排序将是:

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 字节码的规则,所以......
于 2013-02-01T18:25:17.863 回答
2

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

1.getInstance()方法null会因为重排而返回吗?

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

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

您的示例代码:

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 在他的博客中指出的那样,这很可能不太可能发生。但似乎,纯粹从语言规则的角度来看,它实际上是允许的。

这个例子实际上在 JLS 中有介绍:

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

r2在、r4r5in的值之间观察到的效果等同于上面示例中的、 和 theTable 17.4. Surprising results caused by forward substitution可能发生的情况。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 1并且object 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在这里,在两者都yield的最可能情况下,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;

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

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

于 2013-02-10T04:30:54.633 回答
0

null一旦它是非的,什么都没有设置引用nullnull在另一个线程将其设置为 non- 之后,一个线程可能会看到它,null但我不知道如何可能相反。

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

于 2013-01-31T11:19:43.037 回答
0

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

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

被脱离上下文。该声明确实与使用初始化安全有关:

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

...

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

于 2013-01-31T11:47:20.877 回答
0

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

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

这遵守单线程的约束,但如果多个线程正在调用该方法,则可能导致返回 null 值(第一个赋值tmp获取 null 值,if 块看到非 null 值,tmp返回为 null) .

为了使这个“安全”不安全(假设 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;
    }
}
于 2013-01-31T15:51:27.037 回答
0

现在这是一个很长的回帖,仍然考虑到这个问题讨论了许多有趣的重新排序和并发的工作原理,尽管最近我在这里涉及。

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

于 2016-09-01T13:18:19.917 回答
-1

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

private static final Resource resource = new Resource();

Resource如果类本身是不可变的并且与您使用的实例无关,它也可能被认为是线程安全的。在这种情况下,两个调用可以返回不同的实例而Resource没有问题,除了根据getInstance()同时调用的线程数增加内存消耗)。

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

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

于 2013-01-31T11:25:53.040 回答
-1

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 的写入不在范围内,因为由于线程排序,它发生在第 11 行之前。

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

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

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

只有一次写入 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三次,而是将示例更改为使用三个单独的静态变量,则可能会产生更多输出,因为对重新排序没有限制。

于 2013-02-05T17:05:36.077 回答