单例模式与DCL双重校验锁

6次阅读

共计 2638 个字符,预计需要花费 7 分钟才能阅读完成。

前言

在面试里面, 单例模式是经常被问到的设计模式。今天正好学习完了《Java 并发编程实战》,该书的最后一章讲得就是 JMM(Java 内存模型),其中就提到了以 DCL 方式实现单例模式的优缺点。

单例模式

单例模式的概念就不在这里赘述了。在保证线程安全的前提下,最简单的实现方式是“饿汉式”,即在加载单例类的字节码时,在初始化阶段对静态的 instance 变量进行赋值,代码如下。

//“饿汉式”实现线程安全的单例模式
public class Singleton {private static Singleton instance = new Singleton();
    
    private Singleton() {}
    
    public static Singletion getInstance() {return instance;}
}

如果我们希望延迟初始化这个单例对象,就不能使用上述的“饿汉式”实现,而要使用“懒汉式”的实现。最容易想到的一种实现方式当然是使用 synchronized 关键字对 getInstance() 方法进行修饰。代码如下。

// 使用同步方法实现的单例模式
public class Singleton {
    private static Singleton instance;
    
    private Singleton(){}
    
    public static synchronized getInstance() {if (instance == null) {instance = new Singleton();
        }
        
        return instance;
    }
}

这是最简单的单例模式的延迟初始化实现版本,并且通过 synchonized 锁住了 Singleton 这个类的字节码,保证了线程安全。但是,这种锁字节码的方式粒度太大,同一时间只能有一个线程执行同步方法拿到这个单例,因此,在高并发环境下,吞吐量严重受限。

为了提升并发性能,DCL(double checked lock)实现方式看起来是不错的选择。代码如下。

// 实现双重校验锁实现单例模式
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;
    }
}

DCL 方式将同步方法改成了同步代码块,锁的粒度缩小,并发性能更好。当单例对象已经被创建之后,多个线程可以同时执行第一个 if 条件判断并且拿到单例对象。当单例对象未被创建时,同一时间只有一个线程能进入同步代码块进行第二次 if 条件判断,如果发现此时单例对象仍没有被其他线程所创建,则创建单例对象。

DCL 方式实现的关键点在于 volatile 关键字。volatile关键字有两个作用:(1) 保证共享变量在修改之后对于其他线程的可见性;(2) 禁止 JVM 在执行字节码时发生的指令重排。鄙人认为,volatile关键字在上述代码的真正作用是禁止指令重排而不是保证可见性。为什么这么说呢,因为同步代码块上的 synchronized 关键字本身就有保证可见性和原子性的作用。因此,如果从可见性的角度而言,大可不必使用 volatile。换言之,当代码执行到同步代码块中的第二个 if 条件判断,如果先前已经有线程创建了对象,由于synchronized 关键字能够保证可见性,当前线程的高速缓存中存的 instance 的值 (null) 就会失效,导致当前线程一定会到内存中重新读取 instance 的值进行第二次 if 判断。因此,volatile的真正作用就是防止在 new 这个单例对象时发生的指令重排现象,即防止其他线程访问并拿到未完全初始化的单例对象。问题来了,这种情况是如何发生的呢?下面我尝试从字节码的执行过程来进行分析。

我们单看 instance = new Singleton() 这行代码,其对应的字节码如下:

1 NEW // 在堆内存中分配内存,将指向该区域的引用放入操作数栈
2 DUP // 在操作数栈中复制引用
3 INVOKESPECIAL // 调用 Singleton 类的构造方法
4 PUTSTATIC // 将引用赋值给静态变量 instance

在 JVM 执行以上字节码的时候,如果不加 volatile 关键字,那么可能在 DUP 指令 (指令 2) 执行之后,跳过执行构造方法的指令 (指令 3),而直接执行PUTSTATIC 指令 (指令 4),然后用操作数栈上剩下的引用来执行指令 3。因为在单线程环境下,JVM 认为打乱指令 3、4 的执行顺序并不会影响程序的正确性。但是,在多线程环境下,如果指令 3、4 发生重排,当执行完指令 1、2、4 之后,instance 对象已经不再为null,此时来一个线程调用getInstance 方法,就会拿到一个尚未完全初始化的对象,从而发生对象逃逸。这种现象在单例类的构造函数耗时很大时更加频繁。而 volatile 关键字的存在则告诉 JVM,在处理被 volatile 修饰的变量时,禁止使用指令重排。

但是,volatile的禁止指令重排功能在 Java 5 及之后才有作用,因此,DCL 的实现方式在早前的版本就不起作用了。根据《Java 并发编程实战》的介绍:“DCL 使用方法已经被广泛废弃——促使该模式出现的驱动力 (无竞争同步的执行速度很慢,以及 JVM 启动很慢) 已经不复存在,因为它不是一种高效的优化措施。”

因此,实现单例的更好方式,应该是使用静态内部类的延迟初始化机制。代码如下:

// 通过静态内部类的延迟初始化机制实现单例模式
public class Singleton {private Singleton() { }
    
    private static class SingletonHolder {private static final Singleton INSTANCE = new Singleton();
    }
    
    public static Singleton getInstance() {return SingletonHolder.INSTANCE;}
}

上述代码只有在有线程调用 getInstance 方法时才会完成静态内部类 Singleton.SingletonHolder 的加载过程(类加载、链接、初始化)。

当然,单例模式还可以通过编写 enum 类来实现。代码就不写了吧。

正文完
 0