'Effective Java'难题:为什么这个并发代码需要volatile?

时间:2013-02-17 23:03:22

标签: java concurrency volatile

我正在努力完成有效Java(第二版)的第71项“明智地使用懒惰初始化”。它建议使用双重检查成语来使用此代码对实例字段进行延迟初始化(第283页):

private volatile FieldType field;
FieldType getField() {
    FieldType result = field;
    if (result == null) {  //First check (no locking)
        synchronized(this) {
            result = field;
            if (result == null)  //Second check (with locking)
                 field = result = computeFieldValue();
        }
     }
     return result;
}

所以,我实际上有几个问题:

  1. 为什么field上的volatile修饰符需要初始化发生在同步块中?本书提供了这个支持文本:“因为如果字段已经初始化,则没有锁定, critical 该字段被声明为volatile”。因此,在初始化字段的情况下,如果缺少其他同步,volatile是field上多个线程一致视图的唯一保证吗?如果是这样,为什么不同步getField()或上述代码提供更好的性能?

  2. 该文本表明,不需要的局部变量result用于“确保field在已经初始化的常见情况下只读取一次”,从而改进性能。如果删除了result,那么在已经初始化field的常见情况下,如何多次阅读{{1}}?

3 个答案:

答案 0 :(得分:16)

  

为什么在字段上需要volatile修饰符,因为初始化发生在同步块中?

volatile是必要的,因为可能需要重新排序对象构造周围的指令。 Java内存模型声明实时编译器可以选择重新排序指令,以便在对象构造函数之外移动字段初始化。

这意味着thread-1可以初始化field内的synchronized,但该线程2可能会看到该对象未完全初始化。在将对象分配给field之前,不必初始化任何非最终字段。 volatile关键字可确保field在访问之前已完全初始化。

这是着名的"double check locking" bug

的一个例子
  

如果删除了结果,在已经初始化的常见情况下,如何多次读取字段?

只要您访问volatile字段,就会导致内存屏障被越过。与访问普通字段相比,这可能是昂贵的。将volatile字段复制到局部变量是一种常见模式,如果要在同一方法中以任何方式多次访问它。

有关线程之间没有内存屏障的共享对象的危险的更多示例,请参阅我的答案:

  

About reference to object before object's constructor is finished

答案 1 :(得分:7)

这是一个相当复杂但现在与编译器可以重新排列的事情有关 基本上Double Checked Locking模式在Java中不起作用,除非变量是volatile

这是因为,在某些情况下,编译器可以将变量分配给null以外的其他内容,然后对变量进行初始化并重新分配。另一个线程会看到变量不为null并尝试读取它 - 这可能会导致各种非常特殊的结果。

查看关于该主题的this其他SO问题。

答案 2 :(得分:3)

好问题。

  

为什么在字段上需要volatile修饰符,因为初始化发生在同步块中?

如果没有同步,并且您分配给该共享全局字段,则不会保证会看到在构造该对象时发生的所有写入。例如,想象FieldType看起来像。

public class FieldType{
   Object obj = new Object();
   Object obj2 = new Object();
   public Object getObject(){return obj;}
   public Object getObject2(){return obj2;}
}

有可能getField()返回非空实例,但实例getObj()getObj2()方法可以返回空值。这是因为没有同步,对这些字段的写入可以与对象的构造竞争。

这是如何用volatile修复的?在发生易失性写入之后,在易失性写入之前发生的所有写入都是可见的。

  

如果删除了结果,在已经初始化的常见情况下,如何多次读取字段?

在本地存储一次并在整个方法中读取可确保一个线程/进程本地存储和所有线程本地读取。你可以争论那些方面的过早优化,但我喜欢这种风格,因为如果你不这样做,你就不会遇到奇怪的重新排序问题。