使用双重检查锁实现单例时,我们需要使用 volatile 吗?

4
假设我们使用双重检查锁来实现单例模式:
    private static Singleton instance;

    private static Object lock = new Object();

    public static Singleton getInstance() {
        if(instance == null) {
            synchronized (lock) {
                if(instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }

我们需要将变量"instance"设置为"volatile"吗?我听说过这样一句话,我们需要它来禁用重新排序:

当一个对象被创建时,重新排序可能会发生:

address=alloc
instance=someAddress
init(someAddress)

据说如果最后两个步骤被重新排序,我们需要一个易失实例来禁用重新排序,否则其他线程可能会得到一个未完全初始化的对象。

但是由于我们在同步代码块中,我们真的需要易失性吗?或者一般地说,我可以说同步块可以保证共享变量对其他线程是透明的,并且即使它不是易失变量,也不存在重新排序的情况吗?


这些信息的来源在哪里? - Scary Wombat
只是同事之间的讨论。 - aaron.chu
这似乎也是多线程问题。请参见https://docs.oracle.com/javase/specs/jls/se7/html/jls-17.html#jls-17.4.5-110 *如果我们有两个动作x和y,我们写hb(x,y)表示x发生在y之前。如果x和y是同一线程的操作,并且x在程序顺序中出现在y之前,则hb(x,y)。*另请参见https://stackoverflow.com/questions/42079959/explain-how-jit-reordering-works - Scary Wombat
@ElliottFrisch 我已经尝试回答了,但是它非常冗长。 - Eugene
@ScaryWombat 是正确的,但是用了相当多的词来展示。 - Eugene
显示剩余4条评论
1个回答

13
在我开始解释之前,您需要了解编译器执行的一种优化(我的解释非常简化)。假设在您的代码中有如下序列:
 int x = a;
 int y = a;

编译器重新排序这些顺序是完全有效的:

 // reverse the order
 int y = a;
 int x = a;

这里没有人对a进行写入,只有两个读取 a的操作,因此允许这种类型的重新排序。

稍微更复杂的例子是:

// someone, somehow sets this
int a;

public int test() {

    int x = a;

    if(x == 4) {
       int y = a;
       return y;
    }

    int z = a;
    return z;
}

编译器可能会查看这段代码,并注意到如果输入了if(x == 4) { ... },那么这个语句:int z = a; 永远不会执行。但是,与此同时,你可以略微改变一下思路:如果进入了那个if语句,我们并不在乎是否执行int z = a;,因为它不会改变这个事实:

 int y = a;
 return y;

仍会发生这种情况。 因此,让我们将int z = a;变为急切:

public int test() {

   int x = a;
   int z = a; // < --- this jumped in here

   if(x == 4) {
       int y = a;
       return y;
   }

   return z;
}

现在编译器可以进一步重新排序:

// < --- these two have switched places
int z = a;
int x = a;

if(x == 4) { ... }    
有了这些知识,我们现在可以尝试理解正在发生的事情。
让我们来看看你的例子:
 private static Singleton instance; // non-volatile     

 public static Singleton getInstance() {
    if (instance == null) {  // < --- read (1)
        synchronized (lock) {
            if (instance == null) { // < --- read (2)
                instance = new Singleton(); // < --- write 
            }
        }
    }
    return instance; // < --- read (3)
}
instance被读取3次(也叫做load),只被写入1次(也叫做store)。听起来可能很奇怪,但是如果read(1)看到的是一个非空的instance(意味着if(instance == null){...} 没有进入),并不意味着read(3)将返回一个非空的实例,read(3)返回null是完全有效的。这应该会让你感到困惑(我有几次也是这样)。幸运的是,有一种方法可以证明这一点。
编译器可能会为您的代码添加这样一个小优化:
public static Singleton getInstance() {
    if (instance == null) {
        synchronized (lock) {
            if (instance == null) {
                instance = new Singleton();
                // < --- we added this
                return instance;
            }
        }
    }
    return instance;
}

它插入了一个 return instance,从语义上讲,这并不以任何方式改变代码的逻辑。

然后,编译器会进行一些特定的优化,这将对我们有所帮助。我不打算深入讨论细节,但是它会引入一些本地字段(链接中介绍了其好处)来执行所有的读写(存储和加载)操作。

public static Singleton getInstance() {
    Singleton local1 = instance;   // < --- read (1)
    if (local1 == null) {
        synchronized (lock) {
            Singleton local2 = instance; // < --- read (2)
            if (local2 == null) {
                Singleton local3 = new Singleton();
                instance = local3; // < --- write (1)
                return local3;
            }
        }
    }

    Singleton local4 = instance; // < --- read (3)
    return local4;
}
现在一个编译器可能会这样看待这个问题:如果进入了if (local2 == null) {...}Singleton local4 = instance;就永远不会发生(或者如本答案开头的示例所述:实际上是否发生Singleton local4 = instance;并不重要)。但是为了进入if (local2 == null) {...},我们需要先进入if (local1 == null) { ... }。现在让我们全面地思考一下这个问题:
if (local1 == null) { ... } NOT ENTERED => NEED to do : Singleton local4 = instance

if (local1 == null) { ... } ENTERED && if (local2 == null) { ... } NOT ENTERED 
=> MUST DO : Singleton local4 = instance. 

if (local1 == null) { ... } ENTERED && if (local2 == null) { ... } ENTERED
=> CAN DO : Singleton local4 = instance.  (remember it does not matter if I do it or not)

你可以看到,在所有情况下,这样做都没有危害:Singleton local4 = instance在任何if检查之前

经过这一番疯狂之后,你的代码可能会变成:

 public static Singleton getInstance() {

    Singleton local4 = instance; // < --- read (3)
    Singleton local1 = instance;   // < --- read (1)

    if (local1 == null) {
        synchronized (lock) {
            Singleton local2 = instance; // < --- read (2)
            if (local2 == null) {
                Singleton local3 = new Singleton();
                instance = local3; // < --- write (1)
                return local3;
            }
        }
    }

    return local4;
}

这里有两个独立的instance读取:

Singleton local4 = instance; // < --- read (3)
Singleton local1 = instance;   // < --- read (1)

if(local1 == null) {
   ....
}

return local4;

你将instance读入local4(假设它是null),然后你将instance读入local1(假设某个线程已将其更改为非空),如果此时有一个线程正在执行同步块并创建单例实例,那么您的getInstance将返回null而不是Singleton。q.e.d.


结论:这些优化仅在private static Singleton instance;non-volatile时才可能实现,否则大部分优化被禁止,甚至不可能出现这样情况。因此,使用volatile对于此模式的正确运行是必须的。


更可怕的是,如果你看到一个没有完全构造完成的对象。毕竟,如果它不是 volatile 的话,编译器可能会重新排列构造函数中的存储并与 instance 的存储交换位置。 - Johannes Kuhn
@JohannesKuhn 没错,您在谈论“安全发布”,为了使其与 volatile 正确配合工作,读者和写者必须一起在同一个 volatile 上工作。 - Eugene
1
虽然在这个回答中解释的问题可以通过声明字段volatile来解决,但也可以通过将字段读取一次并将其存储到本地变量中来解决,而不是两次读取字段。因此,为什么字段应该是volatile(除非单例是真正不可变的对象)的真正原因还没有被解释。每当有人觉得有必要讨论双重检查锁定时,都应该提到它的无意义,因为类初始化为所有线程安全的惰性初始化提供了“免费”的支持。 - Holger
@Holger同意了。我这样做只是为了练习和出于纯粹的兴趣。 - Eugene

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接