如果synchronized创建了一个before-before关系并阻止重新排序为什么DCL需要volatile?

时间:2017-02-07 22:22:46

标签: java multithreading synchronization

我试图理解双重检查锁定中volatile的必要性(我知道有比DCL更好的方法)我已经阅读了一些类似于我的SO问题但似乎没有解释我在寻找什么。我甚至在SO上找到了一些赞成的答案,表示不需要挥发性(即使对象是可变的)但是,我读过的所有内容都说不然。

我想知道的是,如果同步创建happens-before relationshipprevents reordering,DCL需要使用volatile吗?

以下是我对DCL如何工作的理解以及example

// Does not work
class Foo {
  private Helper helper = null; // 1
  public Helper getHelper() { // 2
    if (helper == null) { // 3
      synchronized(this) { // 4
        if (helper == null) { // 5
          helper = new Helper(); // 6
        } // 7
      } // 8
    } // 9
  return helper; // 10
}

这不起作用,因为Helper对象不是immutablevolatile而且我们知道这一点 volatile会将每个写入刷新到内存,并使每次读取都来自内存。这很重要,这样任何线程都不会看到过时的对象。

因此,在我列出的示例中,Thread A可以在第6行处开始初始化新的Helper对象。然后Thread B出现并在第3行处看到一半初始化对象。 Thread B然后跳转到第10行并返回一半初始化的Helper对象。

添加volatilehappens before关系修复此问题,JIT编译器无法重新排序。所以Helper对象在完全构造之前不能写入辅助引用(?,至少这是我认为它告诉我的......)。

然而,在阅读JSR-133 documentation之后,我变得有点困惑。它声明

  

同步确保线程在之前或之前写入内存   在同步块期间以可预测的方式可见   到同一监视器上同步的其他线程。我们退出后   同步块,我们释放监视器,具有的效果   将缓存刷新到主内存,以便由此线程进行写入   其他线程可以看到。在我们进入同步之前   阻止,我们获取监视器,它具有无效的效果   本地处理器缓存,以便从main重新加载变量   记忆。然后,我们将能够看到所有可见的写入   上一版本。

因此,Java中的synchronized会产生内存障碍,并且会在关系之前发生。

因此,操作被刷新到内存中,所以它让我怀疑为什么变量需要volatile

文档还说明了

  

这意味着线程可见的任何内存操作   在退出同步块之前,任何线程都可以看到它   因为所有,所以进入受同一监视器保护的同步块   内存操作发生在发布和发布之前   在获得之前发生。

我猜我们为什么需要volatile关键字以及为什么synchronize是不够的,因为在Thread A退出synchronized块之前,内存操作对其他线程不可见Thread B在同一个锁上输入相同的块。

Thread A可能正在初始化第6行处的对象,而Thread B第3行处出现之前有{ {1}}在第8行。

然而,this SO answer似乎与此相矛盾,因为同步块阻止了“从同步块内部到其外部”的重新排序

2 个答案:

答案 0 :(得分:3)

如果帮助器不为null,那么什么可以确保代码能够看到辅助器构造的所有效果?没有volatile,什么都不会。

考虑:

  synchronized(this) { // 4
    if (helper == null) { // 5
      helper = new Helper(); // 6
    } // 7

假设在内部将其实现为首先将helper设置为非空值,然后调用构造函数以创建有效的Helper对象。没有规则可以阻止这种情况。

另一个线程可能会将helper看作非空,但构造函数甚至还没有运行,更不用说它的效果对另一个线程可见了。

至关重要的是,在我们能够保证构造函数的所有后果对该线程可见之前,不允许任何其他线程将helper设置为非空值。

顺便说一句,获得这样的代码是非常困难的。更糟糕的是,它似乎可以在100%的时间内正常工作,然后突然在不同的JVM,CPU,库,平台或其他任何东西上中断。通常建议避免编写此类代码,除非证明需要满足性能要求。这种代码难以编写,难以理解,难以维护,难以正确使用。

答案 1 :(得分:1)

@David Schwartz的回答非常好,但有一件事我不确定陈述得好。

  

我猜测为什么我们需要volatile关键字以及为什么同步是不够的,因为在线程A退出同步块并且线程B在同一个锁上进入同一个块之前,内存操作对其他线程不可见。

实际上不是同一个锁而是任何锁,因为锁具有内存屏障。 volatile不是关于锁定,而是围绕内存障碍,而synchronized块是锁和内存障碍。您需要volatile,因为即使线程A已正确初始化Helper实例并将其发布到helper字段,线程B也需要跨越内存屏障确保它看到Helper的所有更新。

  

因此,在我列出的示例中,线程A可以在第6行开始初始化一个新的Helper对象。然后线程B出现并在第3行看到一个半初始化对象。然后线程B跳转到第10行并返回一半初始化的Helper对象。

右。线程A可能会初始化Helper并在它到达synchronized块结束之前将其发布到。没有什么可以阻止它发生。并且因为允许JVM重新排序来自Helper构造函数的指令,直到稍后,它可以发布到helper字段但不会被填充初始化。即使线程A确实到达synchronized块的末尾并且Helper然后完全初始化,仍然没有任何东西可以确保线程B看到所有更新的内存。

  

但是,这个SO答案似乎与同步块相反,因为同步块阻止了“从同步块内部到其外部”的重新排序

不,这个答案并不矛盾。你混淆了线程A会发生什么,以及其他线程会发生什么。就线程A(和中央内存)而言,退出synchronized块可确保Helper的构造函数已完全完成并发布到helper字段。但是,在线程B(或其他线程)跨越内存屏障之前,这意味着什么。然后它们也将使本地内存缓存无效并查看所有更新。

这就是volatile是必要的原因。