易失性读取冲突

Volatile reads clash

假设我们正在使用双重检查锁定来实例化一个单例:

public static Instance getInstance() {
    if (this.instance == null) {
        synchronized(Instance.class) {
            if (this.instance == null) {
                this.instance = new Instance();
            }
        }
    }
    return this.instance;
}

如果 instance 变量将 volatile 并且将删除双重检查锁定,则问题在于程序的语义。

private volatile static Instance instance;

public static Instance getInstance() {
    if (this.instance == null) {
        this.instance = new Instance();
    }
    return this.instance;
}

class 只会实例化一次吗?或者,换句话说,易失性读取是否会以这样的方式发生冲突,即两个线程将看到引用的 null 值并执行双重实例化?

我知道 volatile 写和 volatile 读之间的 happens-before 关系,而且 volatile 禁止缓存(所以所有的读写都将在主内存中执行,而不是在处理器的缓存中),但在并发易失性读取的情况下尚不清楚。

P.S.: 问题不在于单例模式的应用(这只是一个问题很明显的例子),它只是关于双重检查锁定是否可以用 volatile read 代替- 没有程序语义变化的易失性写入,仅此而已。

没有同步,你的代码肯定是坏的,因为2个线程可能会看到实例的值为null,并且都将执行初始化(考虑在每一行进行上下文切换,看看会发生什么。

除此之外,在过去 Java 中,即使使用同步双重检查锁定 (DCL) 也被认为是损坏的,因为当 运行 未同步时,第二个线程可能会以不同的顺序经历初始化操作. 您可以通过添加一个局部变量来修复您的代码,并在您想要阅读它时将 volatile 加载到其中:

public static Instance getInstance() {
    Instance tmp = instance;
    if (tmp == null) {
        synchronized(Instance.class) {
            Instance tmp = instance;
            if (tmp == null) {
                instance = new Instance();
            }
        }
    }
    return instance;
}

但更安全的解决方案是使用 ClassLoader 作为您的同步机制,并且还允许您在每次访问单例时停止使用慢速易失性访问:

public class Instance {

    private static class Lazy {
        private static Instance INSTANCE = new Instance();    
    }

    public static Instance getInstance() {
        return Lazy.INSTANCE;
    }
}

INSTANCE只有第一个线程进入getInstance()

才会初始化

是的,的确如此:易失性读取可能会以这样的方式发生冲突,即两个线程将看到引用的空值,并且将执行双重实例化。

您还需要双括号初始化和 volatile。 那是因为当 instance 变为非 null 时,您不会在读取任何内容之前同步其他线程 - 首先 if 只会使它们进一步达到 return unsynchronized 值(即使初始化线程还没有逃脱同步块),这可能会导致后续线程读取未初始化的变量,因为缺少同步。正常工作的同步需要由访问由它管理的数据的每个线程执行,DCL 在初始化后省略了同步,这是错误的。这就是为什么您需要额外的 volatile 才能使 DCL 工作,然后 volatile 将确保您读取初始化值。

不存在处理器缓存分离这样的东西,读取和写入立即可见,但是有指令重排,因此有利于优化处理器可以在不需要它们的结果时及时调用一些指令马上。同步和 volatile 的全部要点是不重新排列访问它们的线程的指令顺序。那样的话,如果某件事是同步的,并且在代码中被声明为完成,那么它就真的完成了,其他线程可以安全地访问它。这就是保证之前发生的全部要点。

总结一下:如果没有适当的同步处理器可以将对 instance 的引用初始化为非空,但是 instance 可能没有在内部完全初始化,因此后续读取它的线程可能会读取未初始化的反对并因此而做出错误的行为。

考虑这段代码。

private volatile static Instance instance;

public static Instance getInstance() {
    if (this.instance == null) {
        this.instance = new Instance();
    }
    return this.instance;
}

根据你的问题:

Will the class get instantiated only once? Can volatile reads clash in such way that two threads will see null value of the reference and double instantiation will be performed?

出于 JMM 的保证,易失性读取不能以这种方式发生冲突。但是,如果多个线程在 if 之后但在开始实例化 volatile 变量之前交换,您仍然可以得到两个实例。

if (this.instance == null) {
    // if threads swap out here you get multiple instances
    this.instance = new Instance();
}

为了保证上述情况不会发生,你必须使用双重检查锁定

if (this.instance == null) {
    // threads can swap out here (after first if but before synchronized)
    synchronized(Instance.class) {
        if (this.instance == null) {
            // but only one thread will get here
            this.instance = new Instance();
        }
    }
}

请注意,这里必须考虑两个方面。

  • 原子性: 我们需要确保第二个 if 和实例化以原子方式发生(这就是为什么我们需要 synchronized 块)。
  • 可见性: 我们需要确保对实例变量的引用不会在不一致的状态下转义(这就是为什么我们需要 volatile 声明实例变量以利用 JMM happens before guarantee)。

如果在 Java 中确实需要单例,请使用枚举。它为您解决了这些问题:

enum MySingleton {
    INSTANCE;

    public static MySingleton getInstance() {
        return INSTANCE;
    }
}

JVM 将在第一次访问 MySingleton 时以线程安全的方式初始化实例。获得多个实例的唯一方法是,如果你有多个类加载器。