为啥在双重检查锁定中使用volatile

Posted

技术标签:

【中文标题】为啥在双重检查锁定中使用volatile【英文标题】:Why is volatile used in double checked locking为什么在双重检查锁定中使用volatile 【发布时间】:2011-12-12 22:21:18 【问题描述】:

Head First设计模式一书中,具有双重检查锁定的单例模式已实现如下:

public class Singleton 
    private volatile static Singleton instance;
    private Singleton() 
    public static Singleton getInstance() 
        if (instance == null) 
            synchronized (Singleton.class) 
                if (instance == null) 
                    instance = new Singleton();
                
            
        
        return instance;
    

我不明白为什么要使用 volatilevolatile 的使用不会破坏使用双重检查锁定的目的,即性能吗?

【问题讨论】:

我以为双重检查锁定被破坏了,有人修复了吗? 对于它的价值,我发现 Head First 设计模式是一本可怕的学习书。当我回顾它时,现在我已经在其他地方学习了模式,这是完全有道理的,但是在不知道模式的情况下学习它真的没有达到它的目的。但它非常受欢迎,所以也许只是我太密集了。 :-) @DavidHeffernan 我已经看到这个示例被用作可以信任 jvm 执行 DCL 的一种方式。 FWIW,在 x86 系统上,易失性读取应该导致无操作。事实上,唯一需要为内存一致性设置栅栏的操作是 volatile Write-Read。所以如果你真的只写一次值,那么影响应该很小。我还没有看到有人真正对此进行基准测试并认为结果会很有趣! 查看此链接了解为什么在单例中使用volatile:cs.umd.edu/~pugh/java/memoryModel/DoubleCheckedLocking.html 【参考方案1】:

了解为什么需要volatile 的一个很好的资源来自JCIP 书。***也有该材料的decent explanation。

真正的问题是Thread A 可能会在instance 完成构造instance 之前为其分配内存空间。 Thread B 将看到该分配并尝试使用它。这会导致Thread B 失败,因为它使用的是instance 的部分构造版本。

【讨论】:

好的,看起来 volatile 的新实现解决了 DCL 的内存问题。我仍然没有得到的是在这里使用 volatile 对性能的影响。从我读过的内容来看,volatile 和 synchronized 一样慢,那么为什么不同步整个 getInstance() 方法调用呢? @toc777 volatile 比通常的归档慢。如果您寻求性能,请选择持有人级模式。 volatile 在这里只是为了表明 有一种方法 可以使损坏的模式起作用。这更像是一个编码挑战,而不是一个真正的问题。 @Tim 好吧,XML 中的单例仍然是单例;使用 DI 并不容易理解应用程序的运行时状态。较小的代码单元可能看起来更简单,代价是强制构建所有单元以符合 DI 习惯用法(有些人可能会说这是一件好事)。对单例的指责是不公平的,它把 API 和 impl 混淆了——Foo.getInstance() 只是一个获取 Foo 的表达式,它和@Inject Foo foo 没有什么不同;无论哪种方式,请求 Foo 的站点都不知道返回哪个 Foo 以及如何返回,无论哪种方式,静态和运行时依赖项都是相同的。 @irreputable 你知道有趣的是,在我们进行这次交流时,我从未使用过 Spring,也没有提到 Spring 不稳定的 DI。将 Singleton 用作 static factory 的真正危险在于将其称为深层代码的诱惑,而这些代码不应该知道 getInstance() 方法,而是要求提供一个实例。 The real problem is that Thread A may assign a memory space for instance before it is finished constructing instance. 那么volatile 是如何解决这个问题的呢?【参考方案2】:

正如@irreputable 所引用的,易失性并不昂贵。即使代价高昂,一致性也应优先于性能。

对于 Lazy Singletons,还有一种更简洁优雅的方式。

public final class Singleton 
    private Singleton() 
    public static Singleton getInstance() 
        return LazyHolder.INSTANCE;
    
    private static class LazyHolder 
        private static final Singleton INSTANCE = new Singleton();
    

来源文章:Initialization-on-demand_holder_idiom来自***

在软件工程中,Initialization on Demand Holder(设计模式)习语是延迟加载的单例。在所有 Java 版本中,该习惯用法都可以实现安全、高并发的延迟初始化,并具有良好的性能

由于该类没有任何要初始化的static 变量,因此初始化很容易完成。

其中的静态类定义LazyHolder在JVM确定必须执行LazyHolder之前不会初始化。

静态类LazyHolder仅在类Singleton上调用静态方法getInstance时执行,第一次发生这种情况时,JVM将加载并初始化LazyHolder类。

此解决方案是线程安全的,不需要特殊的语言结构(即volatilesynchronized)。

【讨论】:

【参考方案3】:

嗯,没有双重检查锁定性能。这是一个破碎的模式。

抛开情绪不谈,volatile 就在这里,因为如果没有它,当第二个线程通过 instance == null 时,第一个线程可能还不能构造 new Singleton():没有人保证对象的创​​建 发生之前 为任何线程分配instance,但实际创建对象的线程除外。

volatile 反过来在读写之间建立 happens-before 关系,并修复损坏的模式。

如果您正在寻找性能,请改用 holder 内部静态类。

【讨论】:

hi @alf,根据happens-before的定义,一个线程释放锁后,另一个线程获取锁,那么后者可以看到之前的变化。如果是这样,我认为不需要 volatile 关键字。能详细解释一下吗 但是在第二个线程只命中外部if的情况下没有锁获取,所以没有排序。 嗨@alf,那么您是否试图说明当第一个线程在同步块内创建实例时,该实例对于第二个线程可能仍然为空,因为缓存未命中,如果它再次实例化它实例不是易变的?你能澄清一下吗? @AarishRamesh,不为空;任何状态。有两个操作:将地址分配给instance 变量,以及在该地址实际创建对象。除非有强制同步的东西,比如volatile 访问或显式同步,否则第二个线程可能会使这两个事件发生故障。【参考方案4】:

将变量声明为volatile 可以保证对它的所有访问实际上都是从内存中读取其当前值。

如果没有volatile,编译器可能会优化对变量的内存访问(例如将其值保存在寄存器中),因此只有第一次使用变量时才会读取保存变量的实际内存位置。如果变量在第一次和第二次访问之间被另一个线程修改,这是一个问题;第一个线程只有第一个(修改前)值的副本,因此第二个if 语句测试变量值的旧副本。

【讨论】:

-1 我今天失去了我的声望点 :) 真正的 原因是,有内存缓存,建模为线程的本地内存。本地内存刷新到主内存的顺序是未定义的——也就是说,除非你有 happens-before 关系,例如使用volatile。寄存器与不完整的对象和DCL问题无关。 您对volatile 的定义太窄了——如果这都是 volatile 所做的,那么双重检查锁定在 volatile 引入了一个内存屏障,使得某些重新排序是非法的——没有它,即使我们从未从内存中读取过时的值,它仍然是不安全的。编辑:阿尔夫打败了我,不应该给自己喝点好茶;) @TimBender 如果单例 contains 可变状态,刷新它与对单例本身的引用无关(嗯,有一个间接链接,因为访问 volatlie对单例的引用会使你的线程重新读取主内存——但这是次要影响,而不是问题的原因:)) @alf,你是对的。如果内部状态是可变的,那么实际上使实例 volatile 没有帮助,因为只有在引用本身发生更改时才会发生刷新(例如使数组/列表 volatile 对内容没有任何作用)。把它归结为脑放屁。 根据happens-before的定义,一个线程释放锁后,另一个线程获取锁,那么后者可以看到之前的变化。如果是这样,我认为不需要 volatile 关键字。你能更详细地解释一下吗@Tim Bender【参考方案5】:

如果你没有它,第二个线程可以在第一个设置为 null 之后进入同步块,你的本地缓存仍然会认为它是 null。

第一个不是为了正确(如果你是正确的,那就是自我挫败),而是为了优化。

【讨论】:

根据happens-before的定义,一个线程释放锁后,另一个线程获取锁,那么后者可以看到之前的变化。如果是这样,我认为不需要 volatile 关键字。能详细解释一下吗 @QinDongLiang 如果变量不是易失性的,则第二个线程可能正在使用自己堆栈上的缓存值。不稳定会迫使它回到源头以获得适当的价值。当然,它必须在每次访问时都这样做,因此可能会影响性能(但老实说,除非它处于超级关键循环中,否则它可能不是您系统中最糟糕的事情......)【参考方案6】:

易失性读取本身并不昂贵。

您可以设计一个测试以在紧密循环中调用getInstance(),以观察易失性读取的影响;然而,这种测试是不现实的;在这种情况下,程序员通常会调用一次getInstance(),并在使用期间缓存实例。

另一个实现是使用final 字段(参见***)。这需要额外的读取,这可能比volatile 版本更昂贵。 final 版本在紧密循环中可能会更快,但如前所述,该测试没有实际意义。

【讨论】:

【参考方案7】:

双重检查锁定是一种在多线程环境中调用getInstance方法时防止创建另一个单例实例的技术。

注意

在初始化之前会检查两次单例实例。 为了提高性能,仅在首次检查单例实例后才使用同步临界区。 volatile 实例成员声明中的关键字。这将告诉编译器始终读取和写入主内存而不是 CPU 缓存。使用volatile 变量保证happens-before 关系,所有的写入都将发生在实例变量的任何读取之前。

缺点

由于它需要volatile 关键字才能正常工作,因此它与Java 1.4 及更低版本不兼容。问题是乱序写入可能允许在执行单例构造函数之前返回实例引用。 volatile 变量的拒绝缓存导致性能问题。 在初始化之前会检查两次单例实例。 它非常冗长,使代码难以阅读。

单例模式有多种实现方式,各有优缺点。

渴望加载单例 双重检查锁定单例 Initialization-on-demand 持有者习语 基于枚举的单例

每一个的详细描述都太冗长了,所以我只是放了一篇好文章的链接-All you want to know about Singleton

【讨论】:

以上是关于为啥在双重检查锁定中使用volatile的主要内容,如果未能解决你的问题,请参考以下文章

项目中用的双重检查锁定是怎么回事

在 SQL 中使用双重检查锁定死锁

双重检查锁定

公司发的小师妹夸我好棒,因为我告诉了她项目中用的双重检查锁定是怎么回事

公司发的小师妹夸我好棒,因为我告诉了她项目中用的双重检查锁定是怎么回事

公司发的小师妹夸我好棒,因为我告诉了她项目中用的双重检查锁定是怎么回事