1、什么是单例模式?
单例设计模式(Singleton Design Pattern)理解起来非常简单。一个类只允许创建一个对象(或者叫实例),那这个类就是一个单例类,这种设计模式就叫作单例设计模式,简称单例模式。
2、为什么要使用单例?
从业务概念上,有些数据在系统中只应该保存一份,就比较适合设计为单例类。比如,系统的配置信息类。除此之外,我们还可以使用单例解决资源访问冲突的问题。
3、例子
有许多人在学习单例模式的时候,学到了它的基本构成,但却忽略了一个比较重要的问题:你写出来的单例模式有没有线程安全问题?
单例比较经典的几种实现:饿汉式、懒汉式、双重检测、静态内部类和枚举。
PS:下面我会以逐渐演进的几个版本来教你,如何正确实现单例模式,上面提到的几种经典实现方案都有。
Version1(饿汉)
饿汉式的实现方式比较简单。在类加载的时候,instance 静态实例就已经创建并初始化好了,所以,instance 实例的创建过程是线程安全的。不过,这样的实现方式不支持延迟加载(在真正用到 IdGenerator 的时候,再创建实例),从名字中我们也可以看出这一点。
PS:单例模式的重点在于实现类,而不是调用的方式,所以下面的版本我会省略掉 main 方法的部分。
public class Main {public static void main(String[] args) {Singleton instance = Singleton.getInstance();}}class Singleton {private static Singleton instance = new Singleton();private Singleton() {}public static Singleton getInstance() {return instance;}}
Version2(懒汉)
有饿汉式,对应的,就有懒汉式。懒汉式相对于饿汉式的优势是支持延迟加载。但 Version2 的实现方式并不是线程安全的。
class Singleton_1 {private static Singleton_1 instance = null;private Singleton_1() {}public static Singleton_1 getInstance() {if (null == instance) {instance = new Singleton_1();}return instance;}}
Version3(懒汉+synchronize)
我们在 Version3 这个版本中给 getInstance() 这个方法加了一把大锁(synchronzed),导致这个函数的并发度很低。
缺点也很明显,量化一下的话,并发度是 1,也就相当于串行操作了。而这个函数是在单例使用期间,一直会被调用。
如果这个单例类偶尔会被用到,那这种实现方式还可以接受。但是,如果频繁地用到,那频繁加锁、释放锁及并发度低等问题,会导致性能瓶颈,这种实现方式就不可取了。
class Singleton_2 {private static Singleton_2 instance = null;private Singleton_2() {}public static synchronized Singleton_2 getInstance() {if (null == instance) {instance = new Singleton_2();}return instance;}}
Version4(Version3 改进)
每次请求获取类对象时,都会通过 getInstance() 方法获取,除了第一次为 null,其它每次请求基本都是不为 null 的。在没有加同步锁之前,是因为 if 判断条件为 null 时,才导致创建了多个实例。基于以上两点,我们可以考虑将同步锁放在 if 条件里面,这样就可以减少同步锁资源竞争。
class Singleton_3 {private static Singleton_3 instance = null;private Singleton_3() {}public static Singleton_3 getInstance() {if (null == instance) {synchronized (Singleton_3.class) {instance = new Singleton_3();}}return instance;}}
但是因为当多个线程进入到 if 判断条件里,虽然有同步锁,但是进入到判断条件里面的线程依然会依次获取到锁创建对象,然后再释放同步锁。
虽然提升了性能,但却造成了线程不安全。
Version5(双重检测)
为了修复 Version4 线程不安全的问题,我们可以在同步锁里面再判断一次。这种实现方式,通常被称为 Double-Check,它可以大大提高支持多线程的懒汉模式的运行性能。但有可能因为 JVM 编译时的重排序而造成线程不安全。
class Singleton_4 {private static Singleton_4 instance = null;private Singleton_4() {}public static Singleton_4 getInstance() {if (null == instance) {synchronized (Singleton_4.class) {if (null == instance) {instance = new Singleton_4();}}}return instance;}}
Version6(双重检测+volatile)
volatile 在 JDK1.5 之后还有一个作用就是阻止局部重排序的发生,也就是说,volatile 变量的操作指令都不会被重排序。所以使用 volatile 修饰 instance 之后,Double-Check 懒汉单例模式就万无一失了。
但需要知道的是:实际上,只有很低版本的 Java 才会有这个问题。我们现在用的高版本的 Java 已经在 JDK 内部实现中解决了这个问题(解决的方法很简单,只要把对象 new 操作和初始化操作设计为原子操作,就自然能禁止重排序)
class Singleton_5 {private volatile static Singleton_5 instance = null;private Singleton_5() {}public static Singleton_5 getInstance() {if (null == instance) {synchronized (Singleton_5.class) {if (null == instance) {instance = new Singleton_5();}}}return instance;}}
Version7(静态内部类)
相比双重检测,利用 Java 的静态内部类,可以更加简单的解决线程安全问题。它有点类似饿汉式,但又能做到了延迟加载。
class Singleton_6 {private static Singleton_5 instance = null;private Singleton_6() {}// 内部类实现public static class InnerSingleton {private static Singleton_6 instance = new Singleton_6();}public static Singleton_6 getInstance() {return InnerSingleton.instance;}}
InnerSingleton 是一个静态内部类,当外部类 Singleton_6 被加载的时候,并不会创建 InnerSingleton 实例对象。只有当调用 getInstance() 方法时,InnerSingleton 才会被加载,这个时候才会创建 instance。instance 的唯一性、创建过程的线程安全性,都由 JVM 来保证。所以,这种实现方法既保证了线程安全,又能做到延迟加载。
Version8(枚举)
最简单的实现方式,基于枚举类型的单例实现。这种实现方式通过 Java 枚举类型本身的特性,保证了实例创建的线程安全性和实例的唯一性。
enum Singleton_7 {
//实例
INSTANCE;
public static Singleton_7 getInstance() {
return INSTANCE;
}
public void doSomething() {
System.out.println("doSomething");
}
}
性能测试
我编写了一个 SingletonTester 类用来测试以上几种单例模式实现的性能。你可以通过注释/取消注释 35~41 行处的代码来测试4线程下的各个实现方式具体性能。
package Singleton;
import java.util.HashSet;
/**
* 单例模式性能测试类
*/
public class Main {
public static void main(String[] args) {
MyThread thread1 = new MyThread();
MyThread thread2 = new MyThread();
MyThread thread3 = new MyThread();
MyThread thread4 = new MyThread();
thread1.setName("thread1");
thread2.setName("thread2");
thread3.setName("thread3");
thread4.setName("thread4");
thread1.start();
thread2.start();
thread3.start();
thread4.start();
}
}
/**
* 线程测试类
*/
class MyThread extends Thread {
@Override
public void run() {
// Singleton();
// Singleton_1();
// Singleton_2();
// Singleton_3();
// Singleton_4();
// Singleton_5();
Singleton_6();
}
/**
* Singleton
*/
public void Singleton() {
long time1 = System.currentTimeMillis();
HashSet map = new HashSet();
for (int i = 0; i < 1000000; i++) {
boolean e = map.add(Singleton.getInstance());
if (e && i != 0) {
System.out.println("Singleton 获取到的对象不相等,非线程安全。");
break;
}
}
long time2 = System.currentTimeMillis();
System.out.println(this.getName() + "线程 Singleton 测试耗时:" + (time2 - time1) + "ms");
}
/**
* Singleton_1
*/
public void Singleton_1() {
long time1 = System.currentTimeMillis();
HashSet map = new HashSet();
for (int i = 0; i < 1000000; i++) {
boolean e = map.add(Singleton_1.getInstance());
if (e && i != 0) {
System.out.println("Singleton_1 获取到的对象不相等,非线程安全。");
break;
}
}
long time2 = System.currentTimeMillis();
System.out.println(this.getName() + "线程 Singleton_1 测试耗时:" + (time2 - time1) + "ms");
}
/**
* Singleton_2
*/
public void Singleton_2() {
long time1 = System.currentTimeMillis();
HashSet map = new HashSet();
for (int i = 0; i < 1000000; i++) {
boolean e = map.add(Singleton_2.getInstance());
if (e && i != 0) {
System.out.println("Singleton_2 获取到的对象不相等,非线程安全。");
break;
}
}
long time2 = System.currentTimeMillis();
System.out.println(this.getName() + "线程 Singleton_2 测试耗时:" + (time2 - time1) + "ms");
}
/**
* Singleton_3
*/
public void Singleton_3() {
long time1 = System.currentTimeMillis();
HashSet map = new HashSet();
for (int i = 0; i < 1000000; i++) {
boolean e = map.add(Singleton_3.getInstance());
if (e && i != 0) {
System.out.println("Singleton_3 获取到的对象不相等,非线程安全。");
break;
}
}
long time2 = System.currentTimeMillis();
System.out.println(this.getName() + "线程 Singleton_3 测试耗时:" + (time2 - time1) + "ms");
}
/**
* Singleton_4
*/
public void Singleton_4() {
long time1 = System.currentTimeMillis();
HashSet map = new HashSet();
for (int i = 0; i < 1000000; i++) {
boolean e = map.add(Singleton_4.getInstance());
if (e && i != 0) {
System.out.println("Singleton_4 获取到的对象不相等,非线程安全。");
break;
}
}
long time2 = System.currentTimeMillis();
System.out.println(this.getName() + "线程 Singleton_4 测试耗时:" + (time2 - time1) + "ms");
}
/**
* Singleton_5
*/
public void Singleton_5() {
long time1 = System.currentTimeMillis();
HashSet map = new HashSet();
for (int i = 0; i < 1000000; i++) {
boolean e = map.add(Singleton_5.getInstance());
if (e && i != 0) {
System.out.println("Singleton_5 获取到的对象不相等,非线程安全。");
break;
}
}
long time2 = System.currentTimeMillis();
System.out.println(this.getName() + "线程 Singleton_5 测试耗时:" + (time2 - time1) + "ms");
}
/**
* Singleton_6
*/
public void Singleton_6() {
long time1 = System.currentTimeMillis();
HashSet map = new HashSet();
for (int i = 0; i < 1000000; i++) {
boolean e = map.add(Singleton_6.getInstance());
if (e && i != 0) {
System.out.println("Singleton_6 获取到的对象不相等,非线程安全。");
break;
}
}
long time2 = System.currentTimeMillis();
System.out.println(this.getName() + "线程 Singleton_6 测试耗时:" + (time2 - time1) + "ms");
}
}
/**
* 饿汉模式、线程安全
* 这种方式实现的单例模式,在类初始化阶段就已经在堆内存中开辟了一块内存,用于存放实例化对象,所以也称为饿汉模式。饿汉模式实现的单例的优点是,可以保证多线程情况下实例的唯一性,而且 getInstance 直接返回唯一实例,性能非常高。
* 然而,在类成员变量比较多,或变量比较大的情况下,这种模式可能会在没有使用类对象的情况下,一直占用堆内存。试想下,如果一个第三方开源框架中的类都是基于饿汉模式实现的单例,这将会初始化所有单例类,无疑是灾难性的。
*/
class Singleton {
private static Singleton instance = new Singleton();
private Singleton() {
}
public static Singleton getInstance() {
return instance;
}
}
/**
* 懒汉模式、线程不安全
* 懒汉模式就是为了避免直接加载类对象时提前创建对象的一种单例设计模式。该模式使用懒加载方式,只有当系统使用到类对象时,才会将实例加载到堆内存中
*/
class Singleton_1 {
private static Singleton_1 instance = null;
private Singleton_1() {
}
public static Singleton_1 getInstance() {
if (null == instance) {
instance = new Singleton_1();
}
return instance;
}
}
/**
* 懒汉模式 + synchronized同步锁、线程安全
* 同步锁会增加锁竞争,带来系统性能开销,从而导致系统性能下降,因此这种方式也会降低单例模式的性能。
*/
class Singleton_2 {
private static Singleton_2 instance = null;
private Singleton_2() {
}
public static synchronized Singleton_2 getInstance() {
if (null == instance) {
instance = new Singleton_2();
}
return instance;
}
}
/**
* 懒汉模式 + synchronized同步锁 、线程不安全
* 每次请求获取类对象时,都会通过 getInstance() 方法获取,除了第一次为 null,其它每次请求基本都是不为 null 的。在没有加同步锁之前,是因为 if 判断条件为 null 时,才导致创建了多个实例。基于以上两点,我们可以考虑将同步锁放在 if 条件里面,这样就可以减少同步锁资源竞争。
*/
class Singleton_3 {
private static Singleton_3 instance = null;
private Singleton_3() {
}
public static Singleton_3 getInstance() {
if (null == instance) {
synchronized (Singleton_3.class) {
instance = new Singleton_3();
}
}
return instance;
}
}
/**
* 懒汉模式 + synchronized同步锁 + double-check 、线程可能不安全
* 这种方式,通常被称为 Double-Check,它可以大大提高支持多线程的懒汉模式的运行性能。但有可能因为 JVM 编译时的重排序而造成线程不安全。
*/
class Singleton_4 {
private static Singleton_4 instance = null;
private Singleton_4() {
}
public static Singleton_4 getInstance() {
if (null == instance) {
synchronized (Singleton_4.class) {
if (null == instance) {
instance = new Singleton_4();
}
}
}
return instance;
}
}
/**
* 懒汉模式 + synchronized同步锁 + double-check + volatile 线程安全
*/
class Singleton_5 {
private volatile static Singleton_5 instance = null;
private Singleton_5() {
}
public static Singleton_5 getInstance() {
if (null == instance) {
synchronized (Singleton_5.class) {
if (null == instance) {
instance = new Singleton_5();
}
}
}
return instance;
}
}
/**
* 懒汉模式 内部类实现
*/
class Singleton_6 {
private static Singleton_5 instance = null;
private Singleton_6() {
}
// 内部类实现
public static class InnerSingleton {
private static Singleton_6 instance = new Singleton_6();
}
public static Singleton_6 getInstance() {
return InnerSingleton.instance;
}
}
4、总结
4.1、单例模式的优缺点
4.2、经典的实现
总结一下,单例有下面几种经典的实现方式。
1)饿汉式
饿汉式的实现方式,在类加载的期间,就已经将 instance 静态实例初始化好了,所以,instance 实例的创建是线程安全的。不过,这样的实现方式不支持延迟加载实例。
2)懒汉式
懒汉式相对于饿汉式的优势是支持延迟加载。这种实现方式会导致频繁加锁、释放锁,以及并发度低等问题,频繁的调用会产生性能瓶颈。
3)双重检测
双重检测实现方式既支持延迟加载、又支持高并发的单例实现方式。只要 instance 被创建之后,再调用 getInstance() 函数都不会进入到加锁逻辑中。所以,这种实现方式解决了懒汉式并发度低的问题。
4)静态内部类
利用 Java 的静态内部类来实现单例。这种实现方式,既支持延迟加载,也支持高并发,实现起来也比双重检测简单。
5)枚举
最简单的实现方式,基于枚举类型的单例实现。这种实现方式通过 Java 枚举类型本身的特性,保证了实例创建的线程安全性和实例的唯一性。
4.3、单例存在哪些问题?
1)单例对 OOP 特性的支持不友好
我们知道,OOP 的四大特性是封装、抽象、继承、多态。单例这种设计模式对于其中的抽象、继承、多态都支持得不好。为什么这么说呢?我们还是通过 IdGenerator 这个例子来讲解。
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 和用户 ID 采用不同的 ID 生成器来生成。为了应对这个需求变化,我们需要修改所有用到 IdGenerator 类的地方,这样代码的改动就会比较大。
public class Order {
public void create(...) {
//...
long id = IdGenerator.getInstance().getId();
// 需要将上面一行代码,替换为下面一行代码
long id = OrderIdGenerator.getIntance().getId();
//...
}
}
public class User {
public void create(...) {
// ...
long id = IdGenerator.getInstance().getId();
// 需要将上面一行代码,替换为下面一行代码
long id = UserIdGenerator.getIntance().getId();
}
}
除此之外,单例对继承、多态特性的支持也不友好。这里我之所以会用“不友好”这个词,而非“完全不支持”,是因为从理论上来讲,单例类也可以被继承、也可以实现多态,只是实现起来会非常奇怪,会导致代码的可读性变差。不明白设计意图的人,看到这样的设计,会觉得莫名其妙。所以,一旦你选择将某个类设计成到单例类,也就意味着放弃了继承和多态这两个强有力的面向对象特性,也就相当于损失了可以应对未来需求变化的扩展性。
2)单例会隐藏类之间的依赖关系
我们知道,代码的可读性非常重要。在阅读代码的时候,我们希望一眼就能看出类与类之间的依赖关系,搞清楚这个类依赖了哪些外部类。
通过构造函数、参数传递等方式声明的类之间的依赖关系,我们通过查看函数的定义,就能很容易识别出来。但是,单例类不需要显示创建、不需要依赖参数传递,在函数中直接调用就可以了。如果代码比较复杂,这种调用关系就会非常隐蔽。在阅读代码的时候,我们就需要仔细查看每个函数的代码实现,才能知道这个类到底依赖了哪些单例类。
3)单例对代码的扩展性不友好
我们知道,单例类只能有一个对象实例。如果未来某一天,我们需要在代码中创建两个实例或多个实例,那就要对代码有比较大的改动。你可能会说,会有这样的需求吗?既然单例类大部分情况下都用来表示全局类,怎么会需要两个或者多个实例呢?
实际上,这样的需求并不少见。我们拿数据库连接池来举例解释一下。
在系统设计初期,我们觉得系统中只应该有一个数据库连接池,这样能方便我们控制对数据库连接资源的消耗。所以,我们把数据库连接池类设计成了单例类。但之后我们发现,系统中有些 SQL 语句运行得非常慢。这些 SQL 语句在执行的时候,长时间占用数据库连接资源,导致其他 SQL 请求无法响应。为了解决这个问题,我们希望将慢 SQL 与其他 SQL 隔离开来执行。为了实现这样的目的,我们可以在系统中创建两个数据库连接池,慢 SQL 独享一个数据库连接池,其他 SQL 独享另外一个数据库连接池,这样就能避免慢 SQL 影响到其他 SQL 的执行。
如果我们将数据库连接池设计成单例类,显然就无法适应这样的需求变更,也就是说,单例类在某些情况下会影响代码的扩展性、灵活性。所以,数据库连接池、线程池这类的资源池,最好还是不要设计成单例类。实际上,一些开源的数据库连接池、线程池也确实没有设计成单例类。
4)单例对代码的可测试性不友好
单例模式的使用会影响到代码的可测试性。如果单例类依赖比较重的外部资源,比如 DB,我们在写单元测试的时候,希望能通过 mock 的方式将它替换掉。而单例类这种硬编码式的使用方式,导致无法实现 mock 替换。
除此之外,如果单例类持有成员变量(比如 IdGenerator 中的 id 成员变量),那它实际上相当于一种全局变量,被所有的代码共享。如果这个全局变量是一个可变全局变量,也就是说,它的成员变量是可以被修改的,那我们在编写单元测试的时候,还需要注意不同测试用例之间,修改了单例类中的同一个成员变量的值,从而导致测试结果互相影响的问题。
5)单例不支持有参数的构造函数
单例不支持有参数的构造函数,比如我们创建一个连接池的单例对象,我们没法通过参数来指定连接池的大小。针对这个问题,我们来看下都有哪些解决方案。
第一种解决思路是:创建完实例之后,再调用 init() 函数传递参数。需要注意的是,我们在使用这个单例类的时候,要先调用 init() 方法,然后才能调用 getInstance() 方法,否则代码会抛出异常。具体的代码实现如下所示:
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();
第二种解决思路是:将参数放到 getIntance() 方法中。具体的代码实现如下所示:
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);
不知道你有没有发现,上面的代码实现稍微有点问题。如果我们如下两次执行 getInstance() 方法,那获取到的 singleton1 和 signleton2 的 paramA 和 paramB 都是 10 和 50。也就是说,第二次的参数(20,30)没有起作用,而构建的过程也没有给与提示,这样就会误导用户。这个问题如何解决呢?留给你自己思考,你可以在留言区说说你的解决思路。
第三种解决思路是:将参数放到另外一个全局变量中。具体的代码实现如下。Config 是一个存储了 paramA 和 paramB 值的全局变量。里面的值既可以像下面的代码那样通过静态常量来定义,也可以从配置文件中加载得到。实际上,这种方式是最值得推荐的。
public class Config {
public static final int PARAM_A = 123;
public static final int PARAM_B = 245;
}
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;
}
}
4.4、有何替代的解决方案?
刚刚我们提到了单例的很多问题,你可能会说,即便单例有这么多问题,但我不用不行啊。我业务上有表示全局唯一类的需求,如果不用单例,我怎么才能保证这个类的对象全局唯一呢?
为了保证全局唯一,除了使用单例,我们还可以用静态方法来实现。这也是项目开发中经常用到的一种实现思路。比如,上一节课中讲的 ID 唯一递增生成器的例子,用静态方法实现一下,就是下面这个样子:
// 静态方法实现方式
public class IdGenerator {
private static AtomicLong id = new AtomicLong(0);
public static long getId() {
return id.incrementAndGet();
}
}
// 使用举例
long id = IdGenerator.getId();
不过,静态方法这种实现思路,并不能解决我们之前提到的问题。实际上,它比单例更加不灵活,比如,它无法支持延迟加载。我们再来看看有没有其他办法。实际上,单例除了我们之前讲到的使用方法之外,还有另外一种使用方法。具体的代码如下所示:
// 1. 老的使用方式
public demofunction() {
//...
long id = IdGenerator.getInstance().getId();
//...
}
// 2. 新的使用方式:依赖注入
public demofunction(IdGenerator idGenerator) {
long id = idGenerator.getId();
}
// 外部调用demofunction()的时候,传入idGenerator
IdGenerator idGenerator = IdGenerator.getInsance();
demofunction(idGenerator);
基于新的使用方式,我们将单例生成的对象,作为参数传递给函数(也可以通过构造函数传递给类的成员变量),可以解决单例隐藏类之间依赖关系的问题。不过,对于单例存在的其他问题,比如对 OOP 特性、扩展性、可测性不友好等问题,还是无法解决。
所以,如果要完全解决这些问题,我们可能要从根上,寻找其他方式来实现全局唯一类。实际上,类对象的全局唯一性可以通过多种不同的方式来保证。我们既可以通过单例模式来强制保证,也可以通过工厂模式、IOC 容器(比如 Spring IOC 容器)来保证,还可以通过程序员自己来保证(自己在编写代码的时候自己保证不要创建两个类对象)。这就类似 Java 中内存对象的释放由 JVM 来负责,而 C++ 中由程序员自己负责,道理是一样的。
对于替代方案工厂模式、IOC 容器的详细讲解,我们后面再讲。
4.5、关于单例模式的一些问题
1)单例与静态类的区别?
待续
