单例模式


从本周开始,新增一个博文模块 Random Post,用于不定期的学习与整理,原本的每周博文 Weekly Post 仍是主要更新计划(名为每周实则随缘hhh),当每周时间有余,或者不屑于将学习内容作为周计划时,在此更新。

本篇是该模块的第一篇文章,学习单例模式。我在随缘学习单例模式时,发现这种设计模式的水很深,不是 Java 初学人员能够完全理解的,涉及到并发基础、类加载过程、枚举设计等内容,因此来写篇文章整理一下。

本文主要参考自博文《设计模式也可以这么简单》的单例模式部分。


单例模式,GOF 分类下(最常见的那种分类)的 23 种设计模式之一,创建型模式中的一种,面试常备题目之一。

单例模式的意思是,类必须保证只有一个实例存在,整个系统,所有线程,可以有并且最多只能有一个该类的对象,这是一个针对于整个程序而言的全局对象。通常的实现思路是,该类提供一个静态的获取实例对象的方法(方法名通常为 getInstance),并且将构造函数定义为私有方法(这样就不能 new 对象出来了)。

单例模式有多种实现方式,常见的有四种,分别是饿汉模式、懒汉模式、嵌套类、枚举。

饿汉模式

饿汉模式(Eager Initialization)和懒汉模式(Lazy Initialization),这两个名字的翻译,让我觉得早期的程序员都是上海电影译制厂的配音演员退休转业来的,硬是拉大了本就已经存在的文化代沟,垃圾翻译。

  • 饿汉模式的意思是,不管是否使用都抢着在第一时间加载。

  • 懒汉模式的意思是,什么时候要用了,什么时候再加载。

厘清两汉区别之后,我们先来看饿汉模式。

1
2
3
4
5
6
7
8
9
10
11
12
public class Singleton {

// 首先,将构造函数定义为私有方法,将 new Singleton() 堵死
private Singleton() {};

// 创建私有静态实例,意味着这个类第一次使用的时候就会进行创建
private static Singleton instance = new Singleton();

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

(以下内容基本来源于《深入理解 Java 虚拟机》第七章,但可能因为我自身理解不足导致有错误)

饿汉模式创建对象的时机,是在类加载过程中,当类加载完成之后,无论是否需要,此时单例对象都已经被创建了。饿汉模式实现单例模式要靠 static 关键字,而理解 static 关键字需要了解类加载机制。

类的生命周期

一个类的生命周期有七个阶段,如上图,其中前五个阶段是类加载的过程,分别是:

  • 加载(Loading)
  • 验证(Verification)
  • 准备(Preparation)
  • 解析(Resolution)
  • 初始化(Initialization)

中间三个阶段又可以统称为连接(Linking)阶段。

以上五个类加载阶段基本上交由 Java 虚拟机主导控制,而不是由业务程序去控制。

对于小白而言,一个很反直觉的地方在于,类加载过程(即上面的五个阶段),并不是在编译期执行完成的,而是在程序的运行期间完成的,如果类没有被用到,那么类就不会被加载,如果用到了就在程序运行过程中现场加载。这种设计为 Java 提供了极高的扩展性和动态连接性,当程序运行之后,仍然可以在不停机的情况下输入新的类,比如从网络上加载一段二进制流作为程序的一部分。

Java 虚拟机规范中,对于类加载过程的约束是相当宽容的,这使得不同的 Java 虚拟机可以按照编写人员自己的想法来设计。比如对于不同的 Java 虚拟机而言,最开始的加载阶段需要在什么时候完成,随便你,各个阶段能否交叉混合执行,随便你,各个阶段具体要做什么事情,(在保证一定前提下)随便你。当然还是有一些限制,在这里需要指出的有两点:

  1. 加载、验证、准备、初始化这四个阶段(即除了解析之外的类加载阶段),开始执行的顺序必须如图依次开始(但是允许上一个阶段没执行完,下一个阶段就已经开始)。

  2. 明确地说明了,初始化阶段的执行时机,并且是有且仅有这些场景下,Java 虚拟机需要立即执行初始化过程。场景还是挺多的,就不一一列举了,其中包括:

    • 使用 new 关键字实例化对象的时候
    • 调用一个类型的静态方法的时候

    这也隐性地规定了,在这些场景之下,加载、验证、准备这三个阶段也已经开始执行(因为要按顺序开始)。

因此,当 new 一个对象,或者调用一个对象的静态方法时(正如饿汉模式示例代码中的 getInstance() 方法),类加载过程一定会被执行,且执行完毕。


简单地描述一下,各个阶段分别在做什么事情:

  • 加载:获取类的二进制字节流,并存储在方法区中(存储格式由 JVM 自行决定)
  • 验证:确保二进制字节流格式等合规
  • 准备:为静态变量(被 static 修饰的变量)分配空间并设置初始值(请注意是初始值,比如 int 类型设为 0)
  • 解析:(没懂)将常量池内的符号引用替换为直接引用
  • 初始化:执行类构造器 <clinit>() 方法,该方法执行类变量的赋值动作、并执行 static 块中的内容。该方法在多线程环境下会被加锁同步执行,确保多线程环境下类只会被加载一次。

总结一下,饿汉模式创建对象实例,会在准备阶段分配出空间,并在初始化阶段进行真正的赋值,这一切都发生在类加载过程中,当类加载阶段结束之后,饿汉模式的单例就已经被创建出来了。


如果要给饿汉模式一个缺点,那么如果该类里面有一个静态方法,执行该类的静态方法,就会触发初始化阶段,从而导致该类的单例对象也会被一同创建,浪费了性能和空间。(但是我觉得,生产中似乎没有这种可能吧,通过饿汉模式写了一个类,不要该类的实例对象,反而只调用该类的一个静态方法,这么任性吗?)


懒汉模式

懒汉模式(也有叫饱汉模式的,都是垃圾翻译)有多种实现代码,这主要是因为懒汉模式的创建逻辑是懒加载,当程序需要单例对象时才创建,而创建的过程中有并发风险,为了处理并发风险,就出现了多种处理方式。

我在这里不循序渐进地写多套代码,直接把最好的一种复制如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class Singleton {

// 首先,也是先堵死 new Singleton() 这条路
private Singleton() {}

// 和饿汉模式相比,这边不需要先实例化出来,注意这里的 volatile,它是必须的
private static volatile Singleton instance = null;

public static Singleton getInstance() {
if (instance == null) {
// 加锁
synchronized (Singleton.class) {
// 这一次判断也是必须的,不然会有并发问题
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}

懒汉模式需要对并发、Java 的内存模型有一定认识,主要是要理解 volatile 关键字,这里只解释 volatile 的作用。

volatile 关键字主要实现两方面的作用:内存可见性、禁止重排序,懒汉模式使用 volatile 修饰单例对象,是用到了第二个特性,禁止指令重排序。但我们还是按顺序说,先讲内存可见性。

  1. 内存可见性

    内存可见性的问题,指的是如果多条线程同时操作资源,这边改变了数据,另一边能否及时知道数据发生了改变。

    共享变量的可见性问题,不是由于多核导致的,而是由于多缓存导致的,所有的共享变量都存储在主内存中,每个线程也有自己独自的本地内存,这种设计能够加快运算速度,但导致了内存可见性的问题。

    学习内存可见性应当去学习 Java 内存模型,这里分享一篇博文《Java 并发 - Java 内存模型》

  2. 禁止重排序

    为了运行更快,编译器、CPU、内存系统都进行了重排序,下列三行代码:

    1
    2
    3
    int i = 10;      // 1
    int j = 20; // 2
    int sum = i + j; // 3

    从代码上看,执行顺序应该是 1 2 3,但是实际的执行顺序有可能是 2 1 3。

    我原来一直不理解重排序有什么影响,后来我才明白:对于单线程而言确实是没有影响,即使重排序了,实际的执行逻辑也不会有变化,但是多线程情况下就不一定了,当存在数据竞争的情况时,重排序可能会导致违反直觉的现象发生。

    这里就以懒汉模式为例,说明如果发生重排序,可能会发生什么问题:

    在 synchronized 代码块中,instance = new Singleton(); 这行实例化对象的代码,可以拆分成三个指令(可参考《Java 并发编程的艺术》):

    1
    2
    3
    memory = allocate();  // 1. 分配内存 相当于c的malloc
    ctorInstanc(memory); // 2. 初始化对象
    s = memory; // 3. 设置s指向刚分配的地址

    new 对象的过程可以拆解为:

    • 首先 JVM 将申请一块空间(1)
    • 然后执行构造方法为属性赋值(2)
    • 最后让对象指向刚刚分配好的地址(3)。

    如果发生指令重排序,那么 new 对象的过程就有可能变成:

    • 首先 JVM 将申请一块空间(1)
    • 让对象指向刚刚分配好的地址(3)
    • 执行构造方法为属性赋值(2)

    如果只有一条线程则没问题,但出现了另一条线程,执行 getInstance() 方法时,先检查单例对象 instance 是否为空(if (instance == null)),完全有可能检查到 instance 不为空(因为已经指向了刚刚分配好的地址),但是 instance 还没有执行完构造方法,最终拿到了一个没真正创建完毕的对象。


    volatile 关键字实现的第二个作用,就是禁止指令重排序。(顺便一提,不光是禁止两个被 volatile 修饰的属性,在操作上不能重排序,而且被 volatile 修饰的属性,和它周围的普通属性的相关操作都不能重排序)。

    被 volatile 修饰的单例对象,将严格按照指令顺序被创建,因此不存在问题。



嵌套类方式

先看代码吧,然后再解释一下嵌套类。

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Singleton {

// 首先,还是先堵死 new Singleton() 这条路
private Singleton() {}

// 主要是使用了 嵌套类可以访问外部类的静态属性和静态方法 的特性
private static class Holder {
private static Singleton instance = new Singleton();
}
public static Singleton getInstance() {
return Holder.instance;
}
}

先解释一下嵌套类是什么。

Java 中,类的内部也可以有类,这种情况被称为嵌套类(nested class),嵌套类分为两种,分别是静态嵌套类(static nested class)和内部类(inner class)。

我认为,这里要从文法的角度上琢磨一下语义,请注意,Java 中把类中有类的现象称为嵌套类,嵌套是一种位置性的描述词语,只是在描述两个类的位置关系,并没有从属关系。也就是说,两个类是嵌套关系,并不能认为其中一个类归属于另一个类,而只是从位置上来讲,一个类出现在了另一个类的里面,但是是否有从属关系,这个不好说。嵌套类中有一种情况是内部类,内部(inner)就有一定的归属关系了,如果一个类是另一个类的内部类,就说明这个类是归属于另一个类,是任另一个类操纵的。

我认为这种文法上的理解是很有必要的,对于访问权限、类加载时机等等的理解都是有帮助的。

Java 从语言语法上对嵌套类的区分,是判断有没有 static 关键字:

  • 静态嵌套类(static nested class),有 static 修饰

    1
    2
    3
    4
    5
    public class A {
    // 有static -> 静态嵌套类
    public static class B {
    }
    }
  • 内部类(inner classer),没有 static 修饰

    1
    2
    3
    4
    5
    public class A {
    // 没有static -> 内部类
    public class B {
    }
    }

    内部类还可以再细分为三种,主要是在访问权限上的区别,这里为了主干清晰,就不介绍了。

在民间叫法上,往往把这两种情况都称为“内部类”,因为从直观的角度上看,这两种好像都是“类中类”,感觉都是内部的,因此民间往往把带有 static 关键字的称为静态内部类(也就是官方说的静态嵌套类),而把没有 static 关键字的称为内部类。这种叫法非常广泛,也有一定的道理,但我觉得这种叫法会模糊意义,具有迷惑性,最好不要这么称呼。

还有一种民间声音,把静态嵌套类直接称为嵌套类,也就是说”类中类“分为两种,分别是嵌套类和内部类,这种叫法很坚决地表明了区别,我觉得还有点道理。

下图是几种称呼的示意图:

嵌套类的称呼

这里的单例模式,是通过静态嵌套类来实现的,接下来讨论一下原理。

从语法层面,静态嵌套类和内部类的区别,在于类是否被 static 修饰,然后根据 static 访问权限划分出了不同的用法,例如“static 语句块中不能使用 non- static 语句”、“内部类不能有 static 变量”、“static 的类加载时机不同”等等的问题。

通过语法进行解释这两种类型的区别,当然是可以的,但如果揣摩为什么这么设计的原因,应该会更易理解和记忆很多。静态嵌套类和内部类的区别,从 Java 设计的角度上讲,是跟外部类的关系是不一样的:静态嵌套类跟外部类可以说是没什么关系(除了位置特殊了些),但是内部类要依赖于外部类,它可以视为外部类的一个成员。

  • 静态嵌套类:

    1
    2
    3
    4
    public class A {
    public static class B {
    }
    }

    我们可以认为,A 类(外部类)和 B 类(静态嵌套类)是没有关系的,这里的没有关系体现在:

    • 不需要实例化 A 类出来,就可以实例化 B 类
    • B 类不能直接访问 A 类的非静态变量(只能直接访问 A 类的静态变量,即类变量,访问类变量是不需要先获取对象的)
    • A 类和 B 类的类加载时间没有关系,如果完全可以 A 类加载了,但是 B 类还没有加载(这是实现单例模式的核心)

    值得一提的是,静态嵌套类是可以用 private、protected、public 修饰的,指的是访问它的权限,比如如果是 public 的话,那么所有地方都可以访问该静态嵌套类,但是如果是 private 修饰的话,只有外部类可以访问静态嵌套类。

  • 内部类

    1
    2
    3
    4
    public class A {
    public class C {
    }
    }

    这里 A 类(外部类)和 C 类(内部类)之间是有关系的,这种关系大概像是“A 有一个成员变量 C”,具体体现在:

    • A 类没有实例化出来之前,C 类不能创建,就像是一个对象成员变量一样
    • C 类可以访问 A 类的所有变量,无论是否是静态变量
    • C 类可以自定义变量,但是不能有自己的静态变量(因为没有必要,这里可以自己多想一下)

我们来回头看一下这里单例模式的实现。

外部类中套一个静态嵌套类,外部类进行类加载时,静态嵌套类并没有加载,而当调用了外部类的 getInstance() 方法之后,该方法会触发静态嵌套类进行类加载(这里跟饿汉模式是一样的原理),然后实例化出来想要的单例。

因此通过嵌套类实现单例模式,实际上就是饿汉模式的一种特殊使用方法,通过类套类的方式,延时加载了饿汉模式。这是最方便实用的一种单例模式。



枚举方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class Singleton {

// 首先,还是先堵死 new Singleton() 这条路
private Singleton() {}

// 枚举创建单例
private enum SingletonEnum {
INSTANCE;

private final Singleton instance;

SingletonEnum() {
instance = new Singleton();
}

private Singleton getInstance() {
return instance;
}
}

// 对外暴露的获取单例的方法
public static Singleton getInstance() {
return SingletonEnum.INSTANCE.getInstance();
}
}

这种方式怎么说呢,人人都夸,人人都不用hhh

简单的讲,枚举类会在类加载时,初始化所有实例,由 JVM 保证它们不会再被实例化,天生就实现了单例模式。

(以下内容来源自知乎文章《枚举的本质》,建议直接阅读)

在类加载过程中,枚举中定义的枚举值,实际上是一个个的 static final 的变量,并且会被直接赋值,不存在再次被实例化的可能。

下面写一段很简单的枚举代码,定义了一个水果的枚举类,并且规定有一个金额的属性。

1
2
3
4
5
6
7
8
9
public enum Fruit {

APPLE(10);

int money;
Noodle(int money){
this.money = money;
}
}

这段代码经过反编译之后,可以等效为如下代码(经过了删减):

可以看出,枚举值实际上是一个静态的 final 变量,并且会在类加载的过程中就被赋值(static 代码块中的内容会在类加载过程中被执行),因此保证了单例。

1
2
3
4
5
6
7
8
9
10
11
12
public final class Fruit extends Enum {
public static final Fruit APPLE;

private Fruit(String s, int i, int j) {
super(s, i);
code = j;
}

static {
APPLE = new Fruit("APPLE", 0, 10);
}
}

多说一点有关枚举的知识点:

  • 枚举类实际上继承自 Enum 类(参照上面的反编译代码),因此枚举类均不能继承(因为 Java 单继承)
  • 枚举类默认有两个成员变量(可以自行增加更多的),分别是 nameordinal,代表枚举值的名字和顺序,toString() 方法重写为返回 name
  • 枚举值使用 ==equals() 进行比较,作用是相同的,枚举类的 equals() 经过重写,实际上就是 ==,其含义是比较内存地址(由于天生单例,所以通过地址就可以判断是否是同一个)
  • 枚举不允许克隆和反序列化,永远保证单例(因此很安全)
  • 枚举类有两个方法:values() 返回所有枚举值、valueOf() 根据 name 返回枚举值。