单例模式,你还不懂?

1,297 阅读11分钟

设计模式,是针对软件开发过程中遇到的设计问题,总结出来的解决方案(设计思路)。大部分的设计模式意在解决代码的扩展性问题,通过学习设计模式,我们要了解清楚解决的代码痛点、设计模式应用场景,并且生搬硬套、过度使用都是不可取的。

经典的 GoF 23种设计模式,随着编程语言的发展,有的(e.g. 单例模式 Singleton)过时了,甚至有人认为是反模式,有的内置到编程语言中(e.g. 迭代器模式 Iterator)、流行框架中,同时也诞生了一些新的模式(e.g. MVC、服务定位器模式 Service Locator、委托模式 Delegation)。

GoF 23 种设计模式,可以划分为创建型、结构型、行为型

本文将讲述创建型设计模式,提供创建对象的机制,增加已有代码的灵活性和可复用性。其中:

  • 常用的有:单例模式、工厂模式(工厂方法和抽象工厂)、建造者模式
  • 不常用的有:原型模式

接下来,我们一起看看单例模式(Singleton Design Pattern)

什么是单例模式?

在经典的 GoF 23种设计模式之中,常见的设计模式不多,但是随便在路上捉一个野生的程序员,他肯定知道什么是单例模式。

单例设计模式(Singleton Design Pattern)理解起来非常简单。一个类只允许创建一个对象(或者叫实例),那这个类就是一个单例类,这种设计模式就叫作单例设计模式,简称单例模式。

为什么要使用单例模式?

从业务的角度来分析,如果某些数据在应用中只应该存一份,那么它就比较适合设计为单例。比如,配置类。

再比如,在我们日常的业务中,实体类都拥有流水id(唯一id),我们需要一个生成唯一id的id生成器,而且id生成器只应该存在一个,为什么?因为存在多个id生成器,很大可能会产生重复的id,这不是我们所期待的情况。

public class IDGenerator {
    // AtomicLong 是Java并发类库,提供了线程安全的 long 增减操作
    private AtomicLong id = new AtomicLong();
    
    private static final IDGenerator INSTANCE = new IDGenerator();

    private IDGenerator() {}
    
    public static IDGenerator getInstance() {
        return INSTANCE;
    }
    
    public long allocId() {
        return id.incrementAndGet();
    }
}

...
// 分配一个新的id
IDGenerator.getInstance().allocId();

上面的例子中,实现了生成唯一id的id生成器,但是实现并不优雅,还有些小毛病,至于是什么毛病?下面会有解答。

如何使用单例模式?

单例模式,一个类只允许创建一个对象。

单例模式概念很简单,我们应该怎么去实现呢?我们先看看下面的代码片段:

// 在多线程环境下,表现错误
public class Supporter {

    private static Supporter supporter;
    
    private Supporter() {}
    
    public static Supporter getInstance() {
        if (supporter == null) {// A
            supporter = new Supporter();
        }
        return supporter;
    }

}

这段代码在单线程下使用,是没有问题的。但是如果放到多线程环境下,这段代码就会有问题了。方法 getInstance() 在多线程环境下并行调用时,有几率多个线程执行到代码行A处,皆为true,会造成 Supporter 对象被创建多次,这与单例模式约定相违背了。

于是,为了解决问题,我们尝试引入锁,来保证多线程串行化,这就是懒汉式

// 懒汉式
// 正确,但效率不高
public class Supporter {

    private static Supporter supporter;
    
    private Supporter() {}
    
    public static synchronized Supporter getInstance() {
        if (supporter == null) {
            supporter = new Supporter();
        }
        return supporter;
    }

}

通过 synchronized 对方法进行加锁,保证了多线程下执行串行化,Supporter 只会被创建一次。但是,同时也带来了效率问题,因为并发度只有1,在极端情况下,会使代码的性能降低100倍甚至更高。在创建完 Supporter 实例后,每次进入方法都要获取锁显得没必要。

于是,有人就提出了 "Double-Checked Locking" 的做法:

// 错误
public class Supporter {

    private static Supporter supporter;
    
    private Supporter() {}
    
    public static Supporter getInstance() {
        
        if (supporter == null) {// 检查是否已经创建好实例
        
            synchronized(Supporter.class) {// 加锁,类级别锁
                if (supporter == null) {// 锁内,检查是否已经创建好实例
                    supporter = new Supporter();
                }
            }
        }
        return supporter;
    }
}

从直觉上,上面确实是比较有效的解决方案,在多线程环境下,确实避免了同步加锁,然而,这里却是不起作用。

这个问题,在JSR133 JMM 提出后,表明了规范的目标,同时也根据 JSR133 目标给出了相应解答,解答中提到了这个问题,我摘录部分:

Paul Jakubik found an example of a use of double-checked locking that did not work correctly. [A slightly cleaned up version of that code is available here](http://www.cs.umd.edu/~pugh/java/memoryModel/DoubleCheckTest.java).

When run on a system using the Symantec JIT, it doesn't work. In particular, the Symantec JIT compiles


singletons[i].reference = new Singleton();

to the following (note that the Symantec JIT using a handle-based object allocation system).

0206106A   mov         eax,0F97E78h
0206106F   call        01F6B210                  ; allocate space for
                                                 ; Singleton, return result in eax
02061074   mov         dword ptr [ebp],eax       ; EBP is &singletons[i].reference 
                                                ; store the unconstructed object here.
02061077   mov         ecx,dword ptr [eax]       ; dereference the handle to
                                                 ; get the raw pointer
02061079   mov         dword ptr [ecx],100h      ; Next 4 lines are
0206107F   mov         dword ptr [ecx+4],200h    ; Singleton's inlined constructor
02061086   mov         dword ptr [ecx+8],400h
0206108D   mov         dword ptr [ecx+0Ch],0F84030h

聪明的朋友已经看到问题端倪了,在对初始化 Singleton 的写入和对 Singleton 字段的写入,编译器做了指令重排序,这会导致Singleton 字段指向的内存地址中,存在一个部分构造(未初始化完毕)的 Singleton 实例,其他线程使用一个部分初始化的实例去操作,程序可能会崩溃。这里不做太多的展开,这里的重点在于设计模式。想要了解更多,请点击上面的链接,官方会有解释。

在 JSR133 JMM 的解答中,提出了 "Double-Checked Locking" 的解决方案

// Double-Checked Locking
// 正确,JDK版本低于1.5(不包括1.5) volatile不生效
public class Supporter {

    private static volatile Supporter supporter;
    
    private Supporter() {}
    
    public static Supporter getInstance() {
        
        if (supporter == null) {// 检查是否已经创建好实例
        
            synchronized(Supporter.class) {// 加锁,类级别锁
                if (supporter == null) {// 锁内,检查是否已经创建好实例
                    supporter = new Supporter();
                }
            }
        }
        return supporter;
    }
}

当然,除了懒汉式、"Double-Checked Locking" 的做法,还有没有其他做法呢?

我们试想一下,在类加载的时候, supporter 静态实例就已经创建并初始化好了,supporter 实例的创建过程是线程安全的。这就是饿汉式,示例代码如下:

// 饿汉式
// 正确
public class Supporter {

    private static final Supporter supporter = new Supporter();
    
    private Supporter() {}
    
    public static Supporter getInstance() {
        return supporter;
    }
}

有人觉得这种实现方式并不好,因为不支持延迟加载,如果实例对象占用资源多(如占用内存多)或者初始化耗时长(涉及到耗时比较长的 IO 操作),提前初始化实例是一种浪费资源的行为,最好的方式应该在用到的时候再去初始化。但是,我并不认同此观点。

试想一下,基于实例占用资源多的问题,按照 fail-fast 机制(提早暴露问题),我们希望在程序启动时,就完成实例的初始化。如果资源不够,程序启动就会报错,程序员可以立刻修复,而不用等待程序运行一段时间后,初始化该实例引起突发性的资源不足(如 OOM),导致进程系统崩溃,这样就严重影响可用性。

再试想一下,基于初始化耗时长的问题,我们更加不能在有使用需求时,才执行初始化,这会影响系统的性能,造成卡顿、超时等问题。使用饿汉式,在程序启动时,就完成初始化,可以避免在程序运行时触发初始化导致的性能问题。

我们再来看看一种更为简单的实现方式,利用了 Java 的静态内部类,做到了延迟加载,示例代码如下:

// 静态内部类
// 正确
public class Supporter {
    
    private Supporter() {}
    
    private static class SupporterHolder { 
        private static final Supporter supporter = new Supporter();
    }
    
    public static Supporter getInstance() {
        return SupporterHolder.supporter;
    }
}

SupporterHolder 是一个静态内部类,当外部类 Supporter 被加载的时候,并不会创建 SupporterHolder 实例对象。只有当调用 getInstance() 方法时,SupporterHolder 才会被加载,这个时候才会创建 supportersupporter 的唯一性、创建过程的线程安全性,都由 JVM 来保证。所以,这种实现方法既保证了线程安全,又能做到延迟加载。

最后,我们看一下基于枚举特性的实现方式,通过 Java 枚举类型的特性,保证了实例创建的线程安全性和实例的唯一性。示例代码如下:

// 枚举
// 正确
public enum Supporter {
    
    INSTANCE;
    
    // fields and methods
}

...
Supporter.INSTANCE

单例模式存在什么问题

对 OOP 不友好

OOP 的四大特性是封装、抽象、继承、多态。单例模式对抽象、继承、多态都支持得不好。为什么呢?

public class User {
    public void create(...) {
        ...
        // 如果需求改动,各自模块需要使用本模块的id生成器
        // 需要将下面一行,替换成 long id = XXXIDGenerator.getInstance().allocId();
        long id = IDGenerator.getInstance().allocId();
        ...
    }
}
...

上面的例子,体现了单例模式违反了接口隔离原则,同时也违背了 OOP 的抽象特性。应对需求的改动,程序员需要手动修改用到 IDGenerator 类的所有地方,这样的代码维护成本比较大。

除了对抽象的支持不友好以外,单例对继承、多态特性的支持也同样不友好。 单例类依旧可以被继承,也可以实现多态,但实现起来,代码显得比较诡异,看代码的人会摸不着头脑,觉得莫名其妙。因此,一旦选择了单例模式,就相当于损失了对未来需求变化的扩展性。

对代码扩展性不好

单例类只允许一个对象实例。试想一下,如果当前我们有一个需求,需要在进程中创建多个实例,不同场景的模块使用不同的实例,那么对代码的改动是极大的。

或许你会有疑问,有这样的需求吗?上面提到的 IDGenerator 其实也是一种情况,这里举例子解释一下。在系统设计的初期,业务并不多,我们预期系统中只需要一个线程池即可,为了方便我们控制线程资源的消耗,因此我们把线程池设计成单例。但是,随着业务的迭代增加,我们发现有部分业务执行耗时很长,非常消耗线程资源,导致影响到本可以快速完成的业务,排队阻塞。为了解决这个问题,我们希望将耗时长的业务和耗时短的业务隔离执行,于是,我们需要创建俩个线程池,避免互相影响。但是,我们把线程池设计成单例,显而易见无法满足这个需求变更。因此,在某些情况下,单例类会影响到代码的扩展性、灵活性。

对代码可测试性不好

我们在写单元测试时,都是通过 mock 的方式来替换,但是单例模式这种 hard code 的形式,如果存在依赖比较重的依赖资源(e.g. IDGenerator 的 id 成员变量),无法实现 mock 替换。因为依赖资源,相当于全局变量,被进程内所有线程所共享,因此不同测试用例会互相影响测试结果。

不支持有参数的构造函数

单例模式是不支持构造参数的。在某些情况下,我们希望构造的单例需要支持参数设置(e.g. 线程池的大小)。那么,这种情况下,我们需要怎么处理呢?

方案1:在调用时(getInstance(params))传入。这种方式有明显缺陷,首次调用传入的参数才有效,后续传入的参数是无效的,这很容易误导调用者。

方案2:参数通过配置读取。这种方式比较推荐,如果我们需要修改参数,可以直接在配置中修改即可。配置可以是配置类、xml、text、properties、yaml等等。