设计模式,是针对软件开发过程中遇到的设计问题,总结出来的解决方案(设计思路)。大部分的设计模式意在解决代码的扩展性问题,通过学习设计模式,我们要了解清楚解决的代码痛点、设计模式应用场景,并且生搬硬套、过度使用都是不可取的。
经典的 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 才会被加载,这个时候才会创建 supporter。supporter 的唯一性、创建过程的线程安全性,都由 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等等。