单例模式已经是一个老生常谈的话题了,单例模式的思想非常简单,但是要把他写完美却并不是那么容易。这里将对单例模式的简介、结构以及几种写法进行详解,最后再从常见框架源码中进行分析,加深印象。
1. 单例模式简介
在项目开发中,获取一个对象我们通常是通过 new 在内存中进行创建,然后在对其进行引用,当项目逐渐庞大起来后,创建的对象越多对内存资源的占用也将越大,并且很多对象并不需要每次都创建,比如数据库连接池,因此诞生了单例设计模式。单例设计模式是指一个类只有一个实例对象,当我们获取该类对象时始终是同一个。单例模式不仅节省了内存资源,还保证了对象内的数据一致性。
在使用单例模式时,我们需要注意单例模式的几个特点:
- 使用单例模式的类只能有一个实例对象,即外部调用时每次获取到的对象都是同一个
- 外部调用者不能够使用该类构建对象,即不能使用 new 创建对象
- 创建对象的过程应当由使用单例模式的类自己来完成,并且从始至终只能够创建一次
- 单例模式类需要对外提供一个方法,让外部调用者能够从该类中获取到对象,由于外部不能够 new 对象,因此该方法为静态方法
2. 单例模式结构
根据上一节中描述的单例模式的四个特点,我们可以画出单例模式的结构图
同时,我们也能够根据特点和结构图写出伪代码:
public class Singleton {
private static Singleton INSTANCE = null;
private Singleton() {}
public static Singleton getSingleton() {
// ……
return INSTANCE;
}
}
在这里有以下几点需要注意:
- 因为不允许外部手动创建对象,因此该类的构造方法需要定义成 private
- getInstance() 是静态方法,因为该类不能 new ,所以只能通过静态方法获取
- 使用类成员变量 INSTANCE 存放对象,对单例对象的构建和获取均是操作该变量
3. 你真的会写单例模式吗?
单例模式的写法也是笔试、面试常见题目,而流传于世的写法也是多种多样,接下来将介绍及几种常见单例模式写法。
3.1 饿汉式
饿汉式是最常见的单例写法,代码简单并且容易理解。首先根据单例模式的结构定义私有构造方法,用静态成员变量 INSTANCE 定义一个 Singleton 变量并对其实例化,同时将变量标记为 final 使其无法变更。代码如下:
public class Singleton {
private static final Singleton INSTANCE = new Singleton();
private Singleton() {
}
public static Singleton getInstance() {
return INSTANCE;
}
}
现在我们来对这段代码进行分析:
首先我们将 Singleton 构造方法定义为私有,防止外部创建对象
定义类的私有成员变量 INSTANCE ,并用 final 修饰。这样的写法使外部无法直接调用 INSTANCE,并且内部也无法无法对变量进行修改。由于 INSTANCE 声明为 static ,因此 Singleton 实例化只在类初始化时调用一次,只会存在一个对象,确保 INSTANCE 只会指向一个对象
通过静态方法 getInstance() 获取该 Singleton 对象 INSTANCE
饿汉式将类对象的构建放在了类初始化的时候,因此实例化对象的过程只会调用一次保证了单例。通常我们写单例模式用饿汉式已经足够了,但是为了追(ying)求(fu)极(mian)致(shi),只会饿汉式还是不够的。
3.2 懒汉式
懒汉式 1.0 版本
饿汉式的单例模式写法简单、容易理解,但是如果我们不使用该单例对象的话,他依然会在初始化的时候构建对象放入堆中,因此占用内存。于是,我们需要在第一次用到的时候再去构建对象,第二次调用时再从类成员变量中获取,这样就能够追求极致代码,并节省一点内存。那还不简单直接在 getInstance() 方法里面进行判断就可以了:
// 懒汉式 1.0 版本
public class Singleton {
private static Singleton INSTANCE = null;
private Singleton() {
}
public static Singleton getInstance() {
if (INSTANCE == null) {
INSTANCE = new Singleton();
}
return INSTANCE;
}
}
懒汉式 2.0 版本
这种写法思路正确,但是当多线程运行时就会出现问题。当线程 1 和线程 2 同时执行到 INSTANCE == null 判断时,此时两个线程都判断到 INSTANCE 为 null,于是各自往下走进行 new,两个线程各自创建的自己对象,因此将get到两个对象,不满足单例。于是想到在 getInstance() 方法上加上 synchronized 对整个方法加锁,这样确实能够解决并发问题,但是这样直接锁住整个方法会导致并发性能下降,为了追求极致代码,我们还需要进行改进。
// 懒汉式 2.0 版本
public class Singleton {
private static Singleton INSTANCE = null;
private Singleton() {
}
public synchronized static Singleton getInstance() {
if (INSTANCE == null) {
INSTANCE = new Singleton();
}
return INSTANCE;
}
}
懒汉式 3.0 版本
实际上并发问题出现在创建和获取对象上,为了追求更极致的代码,我们需要减小锁粒度,在构建对象的时候上锁,防止其他线程同时构建对象
// 懒汉式 3.0 版本
public class Singleton {
private static Singleton INSTANCE = null;
private Singleton() {
}
public static Singleton getInstance() {
if (INSTANCE == null) {
synchronized (Singleton.class) {
INSTANCE = new Singleton();
}
}
return INSTANCE;
}
}
这里对上面 3.0 的代码进行测试,看看是否在多线程下能够保证拿到同一个对象,测试代码很简单,起 100 个线程同时获取对象并打印,如果打印值完全相等则证实线程安全,若有不一样的,说明还不够完善。测试代码如下:
public static void main(String[] args) {
for (int i = 0; i < 100; i++) {
new Thread(()-> System.out.println(Singleton2.getInstance())).start();
}
}
控制台输出结果:
Singleton@5b8aa392
Singleton@6b7d2669
Singleton@6b7d2669
Singleton@6b7d2669
Singleton@6b7d2669
Singleton@6b7d2669
Singleton@6b7d2669
Singleton@6b7d2669
Singleton@6b7d2669
# ... 后面省略
从结果中可以看到第一条和后面的不一样(需要多尝试几次),说明多线程下还是获取到了不同对象。明明上了锁为什么还是会获取到不同对象呢?其实原因很简单:
- 假设此时有两个线程,线程 1 和线程 2 同时执行到判断 INSTANCE == null 这一步判断,两个线程同时判断到 INSTANCE 为空,两个线程继续往下走
- 接下来线程 1 获取到锁,线程 2 此时在等待线程 1 释放锁。线程 1 继续往下走,实例化 INSTANCE 并释放锁,返回对象 INSTANCE(5b8aa392)
- 线程 1 释放锁之后线程 2 获取到锁,由于线程 2 已经判断 INSTANCE 为空,获取到锁之后继续往下走,再次调用 new Singleton() 创建了一个新的对象(6b7d2669),INSTANCE 将指向后面创建的对象
懒汉式 4.0 版本(DCL 版)
根据上面分析,我们只需要在获取到锁之后再加一层判断是否空便能够解决 3.0 版本的并发问题。代码如下:
// 懒汉式 4.0 版本
public class Singleton {
private volatile static Singleton INSTANCE = null;
private Singleton() {
}
public static Singleton getInstance() {
if (INSTANCE == null) {
synchronized (Singleton.class) {
if (INSTANCE == null) {
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}
这种在同步代码块获取锁的前后判断各判断一次是否为空的方式叫双重检查锁(Double Check Lock,DCL)。注意这段代码中在 INSTANCE 前面加上了 volatile 进行修饰,有人肯定会有疑问,我们在 getInstance() 方法内用了 synchronized 进行同步了,是否还有必要用 volatile 修饰 INSTANCE 呢?答案是有必要的,这就要从 volatile 的作用说起了。volatile 有两个作用:保证线程可见性、禁止指令重排序。这里就涉及到禁止指令重排序这个作用。
当我们调用 new 时,JVM 其实执行了多步操作,下面就是反编译一个 new Object() 对象之后的指令:
Code:
0: new #4 // class java/lang/Object
3: dup
4: invokespecial #1 // Method java/lang/Object."":()V
7: astore_1
8: return
可以看到一个对象创建需要经过三个过程:
new:申请内存空间
invokespecial:调用构造方法初始化对象
astore_1:将变量指向该初始化的内存地址
那么在这样的三步中就有可能出现指令重排的现象,第三步可能跑到第二步前面去,即先申请内存空间,然后让变量指向该内存空间,最后再对对象进行初始化。此时如果线程 1 释放锁之后,线程 2 获取锁之后进入同步代码块,第一步判断 INSTANCE 是否为 null,由于发生了指令重排,此时虽然 INSTANCE 指向了内存区域判断到 INSTANCE 不为 null ,此时将返回指向的内存。但是由于发生了指令重排,此时对象并未初始化,因此返回获取到的对象并不完整,所以为了防止获取到不完整的对象,使用 volatile 修饰对象,阻止指令重排。
3.3 总结
这一章节对单例模式进行了简单介绍,以及讲解了饿汉式和懒汉式的写法,并且将懒汉式从线程不安全到 DCL 的演进过程进行的详细的分析。饿汉式和懒汉式不同之处在于构建对象的实际,饿汉式是在类加载阶段进行构建,因此在获取对象时就不会存在并发问题;懒汉式是在使用阶段对单例对象进行构建,不使用对象时不会创建单例对象,因此会存在并发问题。日常开发以及框架中的单例更常见是使用饿汉式,这一部分将在下一章框架中的单例模式中进行详细讲解。