单例模式——只有一个实例。

想在程序中表示某个东西只会存在一个。比如表示程序所运行于那台计算机的类、表示软件系统相关设置的类、表示视窗系统(Window system)的类。

classDiagram
    class Singleton {
        -$singleton
        -Singleton()
        +$getInstance()
    }

这种单例类通常会占用较多的内存,或者示例的初始化过程比较冗长,随意创建这些类的示例会影响系统性能。

我们的目的:

  • 想确保任何情况下都绝对只有 1 个实例,在程序上表现出「只存在一个实例」
  • 处理资源访问冲突

单例模式的实现方法

设计单例时需要考虑的要点:

  1. 构造方法为私有
  2. 注意线程安全问题
  3. 考虑是否延时加载
  4. 考虑获取单例 getInstance() 的性能是否够高
实现方法 基本饿汉式 线程不安全的懒汉式 线程安全但锁粒度大的懒汉式 DCL IoDH 枚举
饿汉/懒汉 饿汉 懒汉 饿汉
JDK 版本 JDK1.5 起 JDK1.5 起
延迟加载 🟥 🟩 🟩 🟩 🟥
线程安全 🟩 🟥 🟩
实现难度 🟩 🟥
应用场景 一般情况下使用 不建议 存在特殊需求时 在要明确实现 lazy loading 效果时 涉及到反序列化创建对象时

基本饿汉模式

基于 classloader 机制避免了多线程的同步问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Singleton {
// 外部第一次调用getInstance函数时instance才初始化
private final static Singleton instance = new Singleton();

// 私有构造方法
private Singleton() {
System.out.println("生成了一个实例。");
}

//从Singleton类外部获取唯一实例
public static Singleton getInstance() {
return instance;
}
}

instance 前的关键字加上了 final 关键字的原因:防止子类不恰当的方法覆盖单例。

构造方法为私有 private 的原因:禁止从 Singleton 类外部调用构造函数。只有自己才能 new 自己。外部只能通过类的静态方法使用。但是不能防止反射创建新的实例。

如果 Singleton 实现了序列化接口,我们还需要增加以下代码防止反序列化破坏单例:

1
2
3
4
5
6
7
public class Singleton implements Serializable {
private final static Singleton instance = new Singleton();
/* ... */
public Object readResolve(){
return instance;
}
}

这样的初始化是可以保证单例对象创建时的线程安全。因为静态成员变量的初始化在类加载阶段完成,类加载阶段由 JVM 保证线程安全。

如果饿汉模式下的单例初始化时间长,占用资源多是否意味着提前初始化是浪费资源的行为?答案是否定的,我们最好不要等用到的时候再初始化这个单例,这反而会降低系统性能。饿汉模式下将耗时的初始化操作提前到程序启动时完成,可以避免程序运行时初始化导致的性能问题。

相关参考:

  • 类的生命周期中解释 instance 实例化的时机。
  • 这种饿汉模式的单例模式实现方法,是静态工厂模式的一种应用。

基本懒汉模式

懒汉模式:没有一上来就创建实例,而是需要用的时候才创建。

代码 1:线程不安全的写法

严格意义上,这不是单例模式。

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Singleton {
private static Singleton instance = null;
private Singleton() {
System.out.println("生成了一个实例。");

}
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}

这个实现适合于单线程程序。然而,当引入多线程时,就必须通过同步来保护 getInstance() 方法。如果不保护 getInstance() 方法,则可能返回 Singleton 对象的两个不同的实例。

代码 2:线程安全的写法(锁粒度较大)

Balking 模式的一种体现。

改造 getInstance 方法:

1
2
3
4
5
6
7
// 代码1容易有竞争问题,如果两个线程 getInstance,可能都误以为都是NULL。所以添加synchronized
public static synchronized Singleton getInstance() {
if (instance == null) { // 1
instance = new Singleton(); // 2
}
return instance; // 3
}

然而,当分析这段代码时,我们发现只有在第一次调用方法时才需要同步。由于只有第一次调用执行了 //2 处的代码,而只有此行代码需要同步,因此就无需对后续调用使用同步。

synchronized 是一个重量级的锁操作,它将全部代码块锁住,会导致较大的性能开销。

否则,后续的每一次对 getInstance 的调用都要付出同步的代价。

代码 3:双重检查锁 DCL

双重检查锁 DCL(double checked locking)

为了避免对除第一次调用外的所有调用都实行同步的昂贵代价。我们将代码 2 中的 //2 包到一个同步块中。

1
2
3
4
5
6
7
8
9
public static Singleton getInstance() {
if(instance==null) {
//可能多个线程同时进入到这一步进行阻塞等待
synchronized(Singleton.class) {
instance = new Singleton();
}
}
return instance;
}

但是还是出现了和代码 1 一样的问题,可能会出现多个线程进入 if 内部。

为处理上面代码中的问题,我们需要对 instance 进行第二次检查。这就是“双重检查锁定”名称的由来。

1
2
3
4
5
6
7
8
9
10
11
// DCL 双重检查锁
public static Singleton getInstance(){
if (instance == null){ //1
synchronized(Singleton.class) { //2
if (instance == null){ //3
instance = new Singleton(); //4
}
}
}
return instance;
}

这个过程让我想起了动物界的精卵结合的过程…第一层 null 判断可快速返回单例,第二层 null 判断可防止重写。

双重检查锁定背后的理论是完美的,但还是会存在问题。双重检查锁定失败的问题并不归咎于 JVM 中的实现 bug,而是归咎于 Java 平台内存模型,这个内存模型允许所谓的“指令重排序”。

指令重排序

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

代码行 //4 中,在 java 中创建一个对象并非是一个原子操作,可以被分解成三行伪代码:

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

而在一些 JIT 编译器中,为提高性能,伪代码的第二步和第三步顺序会交换(变为先赋值,再调用构造函数)。在单线程程序下,重排序不会对最终结果产生影响,但是并发的情况下,可能会导致某些线程访问到未初始化的变量。模拟一个 2 个线程创建单例的场景,如下表:

时间 线程 A 线程 B
t1 A1: 分配对象内存空间
t2 A3:设置 instance 指向内存空间(先赋值)
t3 B1:判断 instance 是否为空
t4 B2:由于 instance 不为 null,线程 B 将访问 instance 引用的对象
t5 A2:初始化对象(再调用构造函数)
t6 A4:访问 instance 引用的对象

按照这样的顺序执行,线程 B 将会获得一个未初始化的对象,并且自始至终,线程 B 无需获取锁!

解决「指令重排序」的方法:

  • (在 JDK1.5 之后)使用 volatile 关键字。private static volatile Singleton instance;
  • 不用双重检查锁,而是使用代码 2 中的方法
  • 改用饿汉模式
volatile 关键字

volatile 保证了”可见性“,它的”可见性“是通过它的内存语义实现的:

  • volatile 修饰的变量时,JMM 会把本地内存中值刷新到主内存
  • volatile 修饰的变量时,JMM 会设置本地内存无效

为了实现可见性内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来防止重排序。

登记式/静态内部类 IoDH

Initialization Demand Holder。

饿汉式单例类不能实现延迟加载,不管将来用不用始终占据内存;懒汉式单例类线程安全控制烦琐,而且性能受影响。

在 IoDH 中,我们在单例类中增加一个静态(static)内部类,在该内部类中创建单例对象,再将该单例对象通过 getInstance() 方法返回给外部使用。

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

private static class HolderClass{
private final static SingletonIoDH instance = new SingletonIoDH();
}
private SingletonIoDH(){
System.out.println("生成了一个实例。");
}

public static SingletonIoDH getInstance() {
return HolderClass.instance;
}

}

和饿汉式方法一样,它同样利用了 classloader 机制来保证初始化 instance 时只有一个线程。不一样的是,饿汉式中,只要 Singleton 类被装载了,那么 instance 就会被实例化,没有达到延迟加载(lazy loading)的效果。IoDH 是 Singleton 类被装载了,instance 不一定被初始化。因为 SingletonHolder 类没有被主动使用,只有通过显式调用 getInstance 方法时,才会显式装载 SingletonHolder 类,从而实例化 instance

这种方式能达到双检锁方式一样的功效,但实现更简单。对静态域使用延迟初始化,应使用这种方式而不是双检锁方式。这种方式只适用于静态域的情况,双检锁方式可在实例域需要延迟初始化时使用。

通过使用 IoDH,我们既可以实现延迟加载,又可以保证线程安全,不影响系统性能,不失为一种最好的 Java 语言单例模式实现方式(其缺点是与编程语言本身的特性相关,很多面向对象语言不支持 IoDH)。

枚举

这种实现方式还没有被广泛采用,但这是实现单例模式的最佳方法。它更简洁,自动支持序列化机制,绝对防止多次实例化。

这种方式是 Effective Java 作者 Josh Bloch 提倡的方式,它不仅能避免多线程同步问题,而且还自动支持序列化机制,防止反序列化重新创建新的对象,绝对防止多次实例化。

枚举单例不能通过反射破坏单例。枚举属于饿汉式单例模式。

1
2
3
4
5
6
7
8
public enum Singleton { 
INSTANCE;
private AtomicLong id = new AtomicLong(0);
// 可以加入一些初始化方法
public long getId(){
return id.incrementAndGet();
}
}

单例模式存在的问题

单例模式对面向对象特性支持不友好

单例模式对面向对象特性中的继承、抽象、多态的支持不友好(注意用词,不是「完全不支持」)。假设现有一单例类 ID 生成器 IdGenerator,调用其 getId() 方法可以生成一个 id 编号。现有如下情景:

1
2
3
4
5
6
7
8
9
10
11
public class Order {
public void create{
long id = IdGenerator.getInstance().getId(); // 硬编码
}
}

public class User {
public void create{
long id = IdGenerator.getInstance().getId(); // 硬编码
}
}

这种场景下,单例类 IdGenerator 违背了基于接口而非实现的设计原则,即违背 OOP 中的抽象特性。如果我们需要替换部分单例类 ID 生成器的实现,我们需要修改所有用到 ID 生成器的代码。

理论上讲,单例类可以被继承或实现多态,但实现方式很奇怪,减少了代码的可读性。所以,一旦选择将某个类设计为单例类,就意味着放弃了继承和多态特性,损失了可以应对未来需求变化的拓展性。

单例会隐藏类之间的依赖关系

单例类我们直接上手用就行了,不会看到平常我们使用类时的创建和传递依赖参数的过程。当代码比较复杂时,单例类的调用关系就会非常隐蔽。在阅读某一个类的代码时,我们需要仔细查看每个函数的代码实现,才能知道这个类到底依赖了哪些单例类。

解决方式为,可以使用依赖注入方式使用单例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 以前调用单例的方式
public demofunction() {
//...
long id = IdGenerator.getInstance().getId();
//...
}

// 使用新的依赖注入方式
public demofunction(IdGenerator idGenerator) {
long id = idGenerator.getId();
}
// 外部调用demofunction()的时候,传入idGenerator
IdGenerator idGenerator = IdGenerator.getInsance();
demofunction(idGenerator);

在新的方式中,我们可以将单例生成的对象当作参数传递给函数,或者某个类的成员变量。这样可以解决单例隐藏类之间的依赖关系。

单例对代码的扩展性和可测试性并不友好

单例类只能有一个实例,如果有一天想改成多个实例,就会对代码产生大的改动。一般数据库连接池、线程池最好不要设计成单例类。

如果单例类依赖比较重的外部资源,比如 DB,当我们在写单元测试的时候希望通过 mock 的方式将它替换掉时,单例的硬编码会导致我们无法实现 mock 替换。

此外,单例类持有的成员变量实际上相当于全局变量,被所有的代码共享。如果全局变量为可被修改的,在测试时还需关注不同测试用例之间的相互影响问题。

单例不支持有参数的构造函数

假设我们要创建一个连接池单例对象,我们无法通过参数指定连接池的大小。

下面介绍两种解决方案。

方法一:增加 init() 方法。

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
26
27
28
29
public class Singleton {
private static Singleton instance = null;
private final int paramA;
private final int paramB;

private Singleton(int paramA, int paramB) {
this.paramA = paramA;
this.paramB = paramB;
}

public static Singleton getInstance() {
if (instance == null) {
throw new RuntimeException("Run init() first.");
}
return instance;
}

public synchronized static Singleton init(int paramA, int paramB) {
if (instance != null){
throw new RuntimeException("Singleton has been created!");
}
instance = new Singleton(paramA, paramB);
return instance;
}
}

// 使用方式
Singleton.init(10, 50); // 先init,再使用
Singleton singleton = Singleton.getInstance();

方法二:将参数放到 getInstance() 方法中,但会有些问题。两次执行 getInstance() 方法时,即使前后传递的参数不一致,得到的单例中的参数总是第一次调用时传递的,这会对调用者产生迷惑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Singleton {
private static Singleton instance = null;
private final int paramA;
private final int paramB;

private Singleton(int paramA, int paramB) {
this.paramA = paramA;
this.paramB = paramB;
}

public synchronized static Singleton getInstance(int paramA, int paramB) {
if (instance == null) {
instance = new Singleton(paramA, paramB);
}
return instance;
}
}

Singleton singleton = Singleton.getInstance(10, 50);

方式三:将参数放置在另一个全局变量 Config 类中。Config 存储的变量可以是静态定义,也可以从配置文件中加载。这种方式是推荐的方式 。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class Config{
public static final int PARAM_A = 123; // 静态定义
public static final int PARAM_B = 456;
}

public class Singleton {
private static Singleton instance = null;
private final int paramA;
private final int paramB;

private Singleton(){
this.paramA = Config.PARAM_A;
this.paramB = Config.PARAM_B;
}

public synchronized static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}

站内文章单例模式的应用——为 Java 程序应用全局配置 文章中,展示了单例模式实现方式一、方式三的初始化方式。

替代单例模式的解决方案

上一节提到了单例模式中的很多问题,但是如果不用单例模式,有什么其他的替代方案保证类的实例全局唯一?

我们可以使用静态方法实现。但是这种方式比单例更不灵活,他无法支持延迟加载。

1
2
3
4
5
6
7
8
9
10
// 静态方法实现方式
public class IdGenerator {
private static AtomicLong id = new AtomicLong(0);

public static long getId() {
return id.incrementAndGet();
}
}
// 使用举例
long id = IdGenerator.getId();

也可以通过工厂模式、IOC 容器保证类的全局唯一性,也可以通过程序员自己来保证不创建两个对象的实例(让程序员为代码做承诺不稀奇,毕竟在 C++ 中,程序员也是要保证内存对象的正确释放的)。

单例模式的唯一性

单例模式是指,一个类只允许创建唯一一个对象。这个唯一指的是进程唯一的。新老进程中的单例对象并不是同一个对象。

单例是进程唯一的,该进程下的线程间也都使用同一个单例。线程唯一指的是在同一个进程上,不同线程使用各自不同的单例。实现线程唯一的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class IdGenerator {
private AtomicLong id = new AtomicLong(0);

private static final ConcurrentHashMap<Long, IdGenerator> instances
= new ConcurrentHashMap<>();

private IdGenerator() {}

public static IdGenerator getInstance() {
Long currentThreadId = Thread.currentThread().getId();
instances.putIfAbsent(currentThreadId, new IdGenerator());
return instances.get(currentThreadId);
}

public long getId() {
return id.incrementAndGet();
}
}

Java 本身提供了 ThreadLocal 工具类,可以更加轻松地实现线程唯一的单例。不过,ThreadLocal 的底层实现原理也是基于上面的 HashMap

集群可以理解为多个进程构成的集合。集群唯一的单例是指,在同一个集群内的进程都共享同一个单例。实现的具体方式为,把这个单例对象序列化并存储到外部共享存储区(如文件),进程间在使用这个单例对象的时候,先从外部共享存储区中将它读到内存,并反序列化为对象然后再使用,使用完成后需要再存储回外部共享存储区。这个过程中,我们需要对对象进行加锁/解锁操作,以及显示从内存中删除已经使用过的单例的操作。

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
26
27
28
29
30
31
public class IdGenerator {
private AtomicLong id = new AtomicLong(0);
private static IdGenerator instance;
private static SharedObjectStorage storage = FileSharedObjectStorage(/*入参省略,比如文件地址,或者这里可以使用 redis 之类的*/);
private static DistributedLock lock = new DistributedLock();

private IdGenerator() {}

public synchronized static IdGenerator getInstance()
if (instance == null) {
lock.lock();
instance = storage.load(IdGenerator.class);
}
return instance;
}

public synchroinzed void freeInstance() {
storage.save(this, IdGeneator.class);
instance = null; //释放对象
lock.unlock();
}

public long getId() {
return id.incrementAndGet();
}
}

// IdGenerator使用举例
IdGenerator idGeneator = IdGenerator.getInstance();
long id = idGenerator.getId();
IdGenerator.freeInstance(); // 显示删除内存

实现一个多例模式

这里的多例模式是指一个类可以创建有数量限制的多个对象。

简易实现如下:

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
26
27
28
public class BackendServer {
private long serverNo;
private String serverAddress;

private static final int SERVER_COUNT = 3;
private static final Map<Long, BackendServer> serverInstances = new HashMap<>();

static {
serverInstances.put(1L, new BackendServer(1L, "192.134.22.138:8080"));
serverInstances.put(2L, new BackendServer(2L, "192.134.22.139:8080"));
serverInstances.put(3L, new BackendServer(3L, "192.134.22.140:8080"));
}

private BackendServer(long serverNo, String serverAddress) {
this.serverNo = serverNo;
this.serverAddress = serverAddress;
}

public BackendServer getInstance(long serverNo) {
return serverInstances.get(serverNo);
}

public BackendServer getRandomInstance() {
Random r = new Random();
int no = r.nextInt(SERVER_COUNT)+1;
return serverInstances.get(no);
}
}

对于多例模式的另一种理解为:同一个类型的只能创建一个对象,不同类型的可以创建多个对象。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Logger {
private static final ConcurrentHashMap<String, Logger> instances
= new ConcurrentHashMap<>();

private Logger() {}

public static Logger getInstance(String loggerName) {
instances.putIfAbsent(loggerName, new Logger());
return instances.get(loggerName);
}

public void log() {
//...
}
}

//l1==l2, l1!=l3
Logger l1 = Logger.getInstance("User.class");
Logger l2 = Logger.getInstance("User.class");
Logger l3 = Logger.getInstance("Order.class");

这种多例模式的理解方式有点类似以下两个设计模式:

  • 工厂模式。与之不同的是,多例模式创建的对象都是同一个类的对象,而工厂模式创建的是不同子类的对象
  • 享元模式。

枚举类型其实也相当于多例模式:一个类型只能对应一个对象,一个类可以创建多个对象。

与单例模式相关的设计模式

在多数情况下,抽象工厂 Abstract Factory、Builder 模式、门面模式 Facade、Prototype 模式只会生成一个单例。

本文 PlantUML 代码存档

1
2
3
4
5
class Singleton{
- {static} singleton
{method} - Singleton
{method} + {static} getInstance
}

本文参考