双重检查锁中的指令重排问题---Java单例模式实现

本文详细介绍了Java中单例模式的实现方式,从线程不安全的懒汉式到线程安全的懒汉式,再到使用双重检查锁(DCL)的优化。文章深入探讨了DCL存在的问题——指令重排序,并通过volatile关键字解决了线程安全问题,确保了单例的正确创建。

单例回顾

首先我们回顾一下最简单的单例模式是怎样的?

/**
*单例模式一:懒汉式(线程安全)
*/
public class Singleton1 {
    private static Singleton1 singleton1;
    private Singleton1() {
    }
    public static Singleton1 getInstance() {
        if (singleton1 == null) {
            singleton1 = new Singleton1();
        }
        return singleton1;
    }
}

这是一个懒汉式的单例实现,众所周知,因为没有相应的锁机制,这个程序是线程不安全的,实现安全的最快捷的方式是添加 synchronized

 /**
 * 单例模式二:懒汉式(线程安全)
 */
public class Singleton2 {
    private static Singleton2 singleton2;
    private Singleton2() {
    }
    public static synchronized Singleton2 getInstance() {
        if (singleton2 == null) {
            singleton2 = new Singleton2();
        }
        return singleton2;
    }
}

使用synchronized之后,可以保证线程安全,但是synchronized将全部代码块锁住,这样会导致较大的性能开销,因此,人们想出了一个“聪明”的技巧:双重检查锁DCL(double checked locking)的机制实现单例。

双重检查锁

一个双重检查锁实现的单例如下所示:

 /**
 * 单例模式三:DCL(double checked locking)双重校验锁
 */
public class Singleton3 {
    private static Singleton3 singleton3;
    private Singleton3() {
    }
    public static Singleton3 getInstance() {
        if (singleton3 == null) {
            synchronized (Singleton3.class) {
                if (singleton3 == null) {
                    singleton3 = new Singleton3();
                }
            }
        }
        return singleton3;
    }
}

如上面代码所示,如果第一次检查instance不为null,那么就不需要执行下面的加锁和初始化操作。因此可以大幅降低synchronized带来的性能开销。上面代码表面上看起来,似乎两全其美:

在多个线程试图在同一时间创建对象时,会通过加锁来保证只有一个线程能创建对象。
在对象创建好之后,执行getInstance()将不需要获取锁,直接返回已创建好的对象。
程序看起来很完美,但是这是一个不完备的优化,在线程执行到第9行代码读取到instance不为null时(第一个if),instance引用的对象有可能还没有完成初始化。

问题的根源

问题出现在创建对象的语句singleton3 = new Singleton3(); 上,在java中创建一个对象并非是一个原子操作,可以被分解成三行伪代码:

//1:分配对象的内存空间
memory = allocate();
//2:初始化对象
ctorInstance(memory);  
//3:设置instance指向刚分配的内存地址
instance = memory;     

上面三行伪代码中的2和3之间,可能会被重排序(在一些JIT编译器中),即编译器或处理器为提高性能改变代码执行顺序,这一部分的内容稍后会详细解释,重排序之后的伪代码是这样的:

//1:分配对象的内存空间
memory = allocate(); 
//3:设置instance指向刚分配的内存地址
instance = memory;
//2:初始化对象
ctorInstance(memory);

在单线程程序下,重排序不会对最终结果产生影响,但是并发的情况下,可能会导致某些线程访问到未初始化的变量。

模拟一个2个线程创建单例的场景,如下表:

时间线程A线程B
t1A1:分配对象内存空间
t2A3:设置instance指向内存空间
t3B1:判断instance是否为空
t4B2:由于instance不为null,线程B将访问instance引用的对象
t5A2:初始化对象
t6A4:访问instance引用的对象
按照这样的顺序执行,线程B将会获得一个未初始化的对象,并且自始至终,线程B无需获取锁!

指令重排序

前面我们已经分析到,导致问题的原因在于“指令重排序”,那么什么是“指令重排序”,它为什么在并发时会影响到程序处理结果? 首先我们看一下“顺序一致性内存模型”概念。

顺序一致性理论内存模型

顺序一致性内存模型是一个被计算机科学家理想化了的理论参考模型,它为程序员提供了极强的内存可见性保证。顺序一致性内存模型有两大特性:

  1. 一个线程中的所有操作必须按照程序的顺序来执行。
  2. (不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。

实际JMM模型

但是,顺序一致性模型只是一个理想化了的模型,在实际的JMM实现中,为了尽量提高程序运行效率,和理想的顺序一致性内存模型有以下差异:

在顺序一致性模型中,所有操作完全按程序的顺序串行执行。在JMM中不保证单线程操作会按程序顺序执行(即指令重排序)。 顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而JMM不保证所有线程能看到一致的操作执行顺序。 顺序一致性模型保证对所有的内存写操作都具有原子性,而JMM不保证对64位的long型和double型变量的读/写操作具有原子性(分为2个32位写操作进行,本文无关不细阐述)

指令重排序

指令重排序是指编译器或处理器为了优化性能而采取的一种手段,在不存在数据依赖性情况下(如写后读,读后写,写后写),调整代码执行顺序。 举个例子:

//A
double pi  = 3.14;
//B
double r   = 1.0;
 //C
double area = pi * r * r;

这段代码C依赖于A,B,但A,B没有依赖关系,所以代码可能有2种执行顺序:

  1. A->B->C
  2. B->A->C 但无论哪种最终结果都一致,这种满足单线程内无论如何重排序不改变最终结果的语义,被称作as-if-serial语义,遵守as-if-serial语义的编译器,runtime 和处理器共同为编写单线程程序的程序员创建了一个幻觉: 单线程程序是按程序的顺序来执行的。
    双重检查锁问题解决方案
    回来看下我们出问题的双重检查锁程序,它是满足as-if-serial语义的吗?是的,单线程下它没有任何问题,但是在多线程下,会因为重排序出现问题。

解决方案就是大名鼎鼎的volatile关键字,对于volatile我们最深的印象是它保证了”可见性“,它的”可见性“是通过它的内存语义实现的:

  • 写volatile修饰的变量时,JMM会把本地内存中值刷新到主内存
  • 读volatile修饰的变量时,JMM会设置本地内存无效
    重点:为了实现可见性内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来防止重排序!

对之前代码加入volatile关键字,即可实现线程安全的单例模式。

 /**
 * 单例模式三:DCL(double checked locking)双重校验锁
 */
public class Singleton3 {
    private static volatile Singleton3 singleton3;
    private Singleton3() {
    }
    public static Singleton3 getInstance() {
        if (singleton3 == null) {
            synchronized (Singleton3.class) {
                if (singleton3 == null) {
                    singleton3 = new Singleton3();
                }
            }
        }
        return singleton3;
    }
}
<think> 首先,用户的问题是基于之前的引用材料。引用[1]、[2]、[3]都讨论了双重检查锁Double-Checked Locking,DCL)在单例模式中使用volatile的原因。关键点是volatile用于防止指令重排序,确保单例对象的正确初始化。 用户的问题是:“我想了解在Java单例模式双重检测锁实现中,单独使用volatile无法确保对象唯一的原因”。用户的意思是:为什么不单独使用volatile就能确保对象唯一?volatile的作用是什么?为什么在双重检查锁中volatile是必要的,但单独使用volatile不足以确保单例的唯一性? 回顾引用: - 引用[1]提到:volatile修饰单例对象,防止双重检查锁失效,失效原理未阐释。 - 引用[2]解释:因为指令重排,创建对象时可能先赋值后调用构造方法,导致其他线程看到部分初始化的对象。 - 引用[3]强调:volatile确保可见性和禁止指令重排序,解决多线程问题。 现在,分析用户的问题:单独使用volatile无法确保对象唯一的原因。用户可能在问为什么volatile本身不能提供完整的单例保证,而不涉及锁机制。 在Java中,单例模式双重检查锁实现通常像这样: ```java public class Singleton { private static volatile Singleton instance; private Singleton() {} public static Singleton getInstance() { if (instance == null) { // 第一次检查 synchronized (Singleton.class) { if (instance == null) { // 第二次检查 instance = new Singleton(); // 创建实例 } } } return instance; } } ``` 这里,volatile修饰instance变量。关键点是volatile防止指令重排序:如果没有volatile,JVM可能重排序对象创建(分配内存、调用构造方法、赋值引用),导致其他线程在instance不为null时看到一个尚未完全初始化的对象。 但用户的问题是:为什么单独使用volatile无法确保对象唯一?意思是,如果我们只声明instance为volatile,而不使用双重检查锁(即不用synchronized块),会发生什么? 单独使用volatile: ```java public class Singleton { private static volatile Singleton instance; private Singleton() {} public static Singleton getInstance() { if (instance == null) { instance = new Singleton(); // 直接创建,没有同步 } return instance; } } ``` 在这种实现中,即使instance是volatile,它也不能保证单例的唯一性。原因是在多线程环境下,多个线程可能同时检查到instance为null,然后都创建新实例。volatile确保可见性(一个线程的修改对其他线程立即可见)和禁止指令重排序,但它不提供原子性或互斥性。创建对象(new Singleton())的操作不是原子的;如果没有同步,多个线程可以同时执行它,导致创建多个实例。 所以,单独使用volatile无法确保对象唯一的原因是没有解决并发创建的问题双重检查锁通过synchronized块提供互斥,确保只有一个线程执行创建操作,而volatile则解决指令重排序问题。 总结原因: 1. **缺乏互斥机制**:volatile保证变量的可见性和禁止重排序,但不提供锁或原子性。在instance为null时,多个线程可以同时进入创建实例的代码,导致多个实例被创建。 2. **双重检查锁的作用**:synchronized块确保只有一个线程创建实例,第一次检查避免不必要的同步,第二次检查在同步块内确保只在null时创建。 因此,在回答中,我需要明确解释这一点。 结构回答: - 先简要回顾双重检查锁实现- 解释volatile的作用:防止指令重排序,确保对象完全初始化。 - 回答核心问题:单独使用volatile无法确保唯一的原因是多线程并发创建实例问题没有被解决,volatile不提供互斥。 - 引用材料支持:引用[2]和[3]提到了指令重排序问题,但用户的问题更侧重于为什么volatile本身不足够。 确保回答符合系统指令- 使用$...$ for inline math, but this is Java-related, so probably not needed. - 独立公式 if any, but likely not. - LaTeX语法正确:use $ for any formulas, but here it's code or descriptions. - 使用中文回答。 - 生成相关问题:end with related questions. - 引用标识:在引用段落末尾添加[^1], [^2], etc. 用户引用了[1],[2],[3],所以我应该引用它们来支持回答。 最后,相关问题生成。 回答大纲: 1. **介绍双重检查锁实现**:简要描述代码结构。 2. **volatile的作用**:解释在双重检查锁中为什么需要volatile(防止指令重排序)。 3. **单独使用volatile的问题**:详细说明为什么仅volatile不能确保唯一性(并发创建实例)。 4. **结论**:强调双重检查锁组合了同步和volatile才能确保线程安全。 5. **相关问题**:生成2-3个相关问题。 相关问题建议: - volatile关键字在Java中还有哪些其他作用? - 除了双重检查锁,还有哪些实现线程安全单例的方法? - 如何避免双重检查锁的潜在问题? </think> ### Java单例模式双重检测锁中单独使用volatile无法确保对象唯一的原因 在Java单例模式的双重检测锁(Double-Checked Locking, DCL)实现中,`volatile`关键字主要用于解决特定多线程问题,但单独使用`volatile`无法确保单例对象的唯一性。原因在于`volatile`只解决了对象初始化的指令重排序和可见性问题,而没有提供互斥机制来防止并发创建多个实例。下面我将逐步解释原因,结合引用材料进行分析。 #### 1. **双重检测锁的标准实现回顾** 在双重检测锁中,单例对象通常被定义为`volatile`,并通过同步块控制并发访问。标准代码示例如下: ```java public class Singleton { private static volatile Singleton instance; // 使用volatile修饰单例对象 private Singleton() {} // 私有化构造器 public static Singleton getInstance() { if (instance == null) { // 第一次检查:避免不必要的同步 synchronized (Singleton.class) { // 同步块 if (instance == null) { // 第二次检查:确保对象只创建一次 instance = new Singleton(); // 创建对象 } } } return instance; } } ``` 在这里: - `synchronized`块提供了互斥机制,确保只有一个线程执行对象的创建(即`new Singleton()`)。 - `volatile`修饰符解决了Java内存模型(JMM)中的指令重排序问题,防止其他线程在对象未完全初始化时看到部分构造的对象[^2][^3]。 #### 2. **volatile的作用和局限性** - **`volatile`的作用**: `volatile`关键字确保变量的可见性和禁止指令重排序。在对象创建时(`instance = new Singleton()`),这个过程涉及多个步骤:分配内存空间、调用构造方法初始化对象、将引用赋值给变量。如果没有`volatile`,JVM可能重排序指令,导致赋值操作(`instance`指向内存地址)发生在构造方法执行之前。此时,其他线程可能看到一个非`null`但未完全初始化的对象,引发不可预测的错误[^2][^3]。`volatile`通过内存屏障(Memory Barrier)禁止这种重排序,保证对象的完整初始化[^3]。 - **`volatile`的局限性**: `volatile`只保证变量的读写操作是原子的(如对一个变量的赋值),但不保证复合操作的原子性或提供互斥性。具体到单例模式- 它无法防止多个线程同时检查`instance == null`并进入创建逻辑。 - 对象的创建(`new Singleton()`)本身不是一个原子操作;它是一个多步过程,涉及内存分配和初始化。 因此,单独使用`volatile`修饰单例对象,而不配合同步机制(如`synchronized`),无法解决并发创建问题。 #### 3. **单独使用volatile无法确保对象唯一的原因** 如果只声明`instance`为`volatile`,而不使用双重检查锁中的同步块,代码可能如下: ```java public class Singleton { private static volatile Singleton instance; // 单独使用volatile private Singleton() {} public static Singleton getInstance() { if (instance == null) { instance = new Singleton(); // 直接创建对象,没有同步控制 } return instance; } } ``` 在这种实现下,**单独使用`volatile`无法确保单例唯一性的主要原因**是: - **缺乏互斥机制**:`volatile`确保对象的初始化和赋值对其他线程可见,但它不提供锁或原子性。在多线程环境中,多个线程可能同时执行`instance == null`检查(因为检查操作本身是非原子的),发现`instance`为`null`,然后并发创建新实例。即使`volatile`防止了指令重排序,每个线程都能创建一个对象,导致多个实例存在。 - **并发创建问题**:对象创建的步骤(检查null、分配内存、初始化、赋值)不是原子操作。没有`synchronized`块,线程A和线程B可能同时通过`instance == null`检查,并各自执行`new Singleton()`。`volatile`只保证了每个线程创建的对象在赋值时是可见的,但不能阻止创建多个对象本身。 - **实际后果**:在高并发场景下,这会导致多个单例对象被实例化,破坏单例模式的核心原则(唯一性)。引用[1]提到,双重检查锁通过同步块“减小了懒汉式的同步开销”,但如果省略同步,volatile无法补偿互斥的缺失[^1]。 #### 4. **为什么双重检查锁需要组合volatile和synchronized** - `synchronized`提供互斥:确保只有一个线程进入创建代码块(第二次检查部分)。 - `volatile`解决初始化问题:防止指令重排序带来的风险。 - 两者结合:第一次检查(非同步)减少同步开销,第二次检查(同步)确保安全创建,`volatile`消除内存模型隐患[^1][^3]。单独移除任何一个元素(如省略`synchronized`只保留`volatile`)都会使模式失效。 #### 总结 在Java单例模式的双重检测锁实现中,单独使用`volatile`无法确保对象唯一性,因为它只解决了可见性和指令重排序问题,但没有提供互斥机制来防止多线程并发创建实例。只有在配合`synchronized`同步块的情况下,`volatile`才能有效发挥作用,确保单例对象的唯一性和线程安全[^1][^2][^3]。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值