42

我最近偶然看到一篇文章,讨论了 Java 中的双重检查锁定模式及其陷阱,现在我想知道我多年来一直使用的该模式的变体是否会遇到任何问题。

我查看了许多关于该主题的帖子和文章,并了解获取对部分构造对象的引用的潜在问题,据我所知,我认为我的实现不受这些问题的影响。以下模式有什么问题吗?

而且,如果没有,为什么人们不使用它?在我看到的围绕这个问题的任何讨论中,我从未见过它被推荐过。

public class Test {
    private static Test instance;
    private static boolean initialized = false;

    public static Test getInstance() {
        if (!initialized) {
            synchronized (Test.class) {
                if (!initialized) {
                    instance = new Test();
                    initialized = true;
                }
            }
        }
        return instance;
    }
}
4

11 回答 11

26

双重检查锁坏了。由于 initialized 是一个原语,它可能不需要它是 volatile 才能工作,但是没有什么可以阻止在实例初始化之前将 initialized 视为非同步代码的真实情况。

编辑:为了澄清上述答案,最初的问题是关于使用布尔值来控制双重检查锁定。如果没有上面链接中的解决方案,它将无法正常工作。您可以仔细检查 lock 是否实际设置了一个布尔值,但在创建类实例时仍然存在指令重新排序的问题。建议的解决方案不起作用,因为在非同步块中看到初始化的布尔值为 true 后,实例可能未初始化。

双重检查锁定的正确解决方案是使用 volatile(在实例字段上)并忘记初始化的布尔值,并确保使用 JDK 1.5 或更高版本,或者在最终字段中对其进行初始化,如链接中所述文章和汤姆的答案,或者只是不使用它。

当然,整个概念似乎是一个巨大的过早优化,除非您知道在获取此 Singleton 时会遇到大量线程争用,或者您已对应用程序进行了概要分析并认为这是一个热点。

于 2009-10-26T14:34:08.247 回答
17

initialized如果是的话,那会起作用volatile。就像我们可以对其他数据所说的那样,与参考无关synchronized的有趣影响。在写入之前强制volatile设置instance字段和对象。当通过短路使用缓存值时,读取发生在读取和通过引用到达的对象之前。拥有一个单独的标志没有显着差异(除了它会导致代码更加复杂)。Testinitializedinitializeinstanceinitialized

(不安全发布的构造函数中的字段规则final略有不同。)

但是,在这种情况下,您应该很少看到该错误。第一次使用时遇到麻烦的机会很小,而且是不重复的比赛。

代码过于复杂。你可以把它写成:

private static final Test instance = new Test();

public static Test getInstance() {
    return instance;
}
于 2009-10-26T14:31:01.950 回答
13

双重检查锁定确实被破坏了,该问题的解决方案实际上比这个习惯用法更简单地实现代码 - 只需使用静态初始化程序。

public class Test {
    private static final Test instance = createInstance();

    private static Test createInstance() {
        // construction logic goes here...
        return new Test();
    }

    public static Test getInstance() {
        return instance;
    }
}

静态初始化程序保证在 JVM 首次加载类时执行,并且在类引用可以返回给任何线程之前执行 - 使其本质上是线程安全的。

于 2009-10-26T14:58:26.573 回答
5

这就是双重检查锁定被破坏的原因。

同步保证,只有一个线程可以进入一段代码。但它不能保证在同步部分中完成的变量修改对其他线程是可见的。只有进入同步块的线程才能保证看到更改。这就是双重检查锁定被破坏的原因 - 它在读者方面不同步。读取线程可能会看到单例不为空,但单例数据可能未完全初始化(可见)。

订购由 提供volatilevolatile保证排序,例如写入 volatile 单例静态字段 保证写入单例对象将在写入 volatile 静态字段之前完成。它不会阻止创建两个对象的单例,这是由同步提供的。

类 final 静态字段不需要是 volatile 的。在 Java 中,JVM负责处理这个问题。

请参阅我的帖子,一个真实世界 Java 应用程序中对单例模式和损坏的双重检查锁定的回答,说明了一个关于双重检查锁定的单例示例,它看起来很聪明但被破坏了。

于 2010-08-18T20:46:11.917 回答
2

双重检查锁定是反模式。

Lazy Initialization Holder Class是您应该关注的模式。

尽管有很多其他答案,但我认为我应该回答,因为仍然没有一个简单的答案可以说明为什么 DCL 在许多情况下会被破坏,为什么它是不必要的以及您应该做什么。因此,我将使用一个引文,Goetz: Java Concurrency In Practice在它关于 Java 内存模型的最后一章中对我来说提供了最简洁的解释。

这是关于变量的安全发布:

DCL 的真正问题是假设在没有同步的情况下读取共享对象引用时可能发生的最糟糕的事情是错误地看到一个陈旧的值(在这种情况下为 null );在这种情况下,DCL 习惯用法通过在持有锁的情况下再次尝试来补偿这种风险。但最坏的情况实际上要糟糕得多——可以看到引用的当前值但对象状态的陈旧值,这意味着可以看到对象处于无效或不正确的状态。

JMM(Java 5.0 及更高版本)中的后续更改使 DCL 能够在资源变为 volatile 时工作,并且这对性能的影响很小,因为 volatile 读取通常仅比非易失性读取贵一点。

然而,这是一个实用性已基本消失的习语——推动它的力量(缓慢的无竞争同步、缓慢的 JVM 启动)不再起作用,使其作为优化的效果降低。惰性初始化持有者习语提供相同的好处并且更容易理解。

清单 16.6。惰性初始化持有者类成语。

public class ResourceFactory
    private static class ResourceHolder {
        public static Resource resource = new Resource();
    }

    public static Resource getResource() {
        return ResourceHolder.resource;
    }
}

这就是这样做的方法。

于 2016-03-06T17:04:19.207 回答
0

如果 "initialized" 为真,则 "instance" 必须完全初始化,就像 1 加 1 等于 2 :)。因此,代码是正确的。该实例仅实例化一次,但该函数可能会被调用一百万次,因此它确实提高了性能,而无需检查一百万减去一倍的同步。

于 2012-10-17T04:38:17.157 回答
0

在某些情况下,可能会使用双重检查。

  1. 首先,如果您真的不需要单例,并且仔细检查仅用于不创建和初始化许多对象。
  2. 在构造函数/初始化块的末尾设置了一个final字段(这导致所有先前初始化的字段都被其他线程看到)。
于 2013-04-23T19:32:44.630 回答
0

您可能应该使用java.util.concurrent.atomic 中的原子数据类型。

于 2009-10-27T23:27:38.910 回答
0

DCL 问题已被破坏,即使它似乎适用于许多 VM。这里有一篇关于这个问题的很好的文章http://www.javaworld.com/article/2075306/java-concurrency/can-double-checked-locking-be-fixed-.html

多线程和内存一致性是比看起来更复杂的主题。[...] 如果您只是使用 Java 提供的工具来实现这个目的——同步,那么您可以忽略所有这些复杂性。如果您同步对可能已写入或可能被另一个线程读取的变量的每次访问,您将不会遇到内存一致性问题。

正确解决此问题的唯一方法是避免延迟初始化(急切地进行)或在同步块内进行单次检查。布尔值的使用initialized等同于对引用本身进行空检查。第二个线程可能认为initialized是真的,但instance可能仍然为空或部分初始化。

于 2015-10-07T07:38:27.773 回答
0

我一直在研究双重检查锁定习语,据我了解,您的代码可能会导致读取部分构造的实例的问题,除非您的 Test 类是不可变的:

Java 内存模型为共享不可变对象提供了初始化安全的特殊保证。

即使不使用同步来发布对象引用,也可以安全地访问它们。

(引自非常可取的《Java Concurrency in Practice》一书)

所以在这种情况下,双重检查锁定习惯用法会起作用。

但是,如果不是这种情况,请注意您在没有同步的情况下返回变量实例,因此实例变量可能没有完全构造(您将看到属性的默认值而不是构造函数中提供的值)。

布尔变量没有添加任何内容来避免问题,因为它可能在 Test 类初始化之前设置为 true (同步关键字并不能完全避免重新排序,某些句子可能会改变顺序)。Java 内存模型中没有发生之前发生的规则来保证这一点。

并且使布尔值 volatile 也不会添加任何内容,因为 32 位变量是在 Java 中以原子方式创建的。双重检查锁定习语也适用于它们。

从 Java 5 开始,您可以通过将实例变量声明为 volatile 来解决该问题。

您可以在这篇非常有趣的文章中阅读有关双重检查习语的更多信息。

最后,我读过的一些建议:

  • 考虑是否应该使用单例模式。许多人认为它是一种反模式。在可能的情况下,依赖注入是首选。检查这个

  • 在实施之前仔细考虑双重检查锁定优化是否真的必要,因为在大多数情况下,这不值得付出努力。另外,请考虑在静态字段中构造 Test 类,因为延迟加载仅在构造类需要大量资源时才有用,并且在大多数情况下并非如此。

如果您仍需要执行此优化,请查看此链接,该链接提供了一些替代方案,可实现与您尝试的效果类似的效果。

于 2015-08-22T20:52:04.440 回答
0

首先,对于单例,您可以使用 Enum,如本问题Implementing Singleton with an Enum (in Java) 中所述

其次,从 Java 1.5 开始,您可以使用带有双重检查锁定的 volatile 变量,如本文末尾所述:https ://www.cs.umd.edu/~pugh/java/memoryModel/DoubleCheckedLocking.html

于 2017-05-19T07:09:48.063 回答