单例模式以及它的一些争议

之所以突然起了这篇文章,是因为这周工作中,使用单例模式实现了一个功能,,在code review的时候,被指出尽量不要使用单例模式,给了我几篇文章看,收获了一些内容,所以系统地总结下。

单例模式

首先还是说一下,什么是单例模式,以及它的实现方法。

什么是单例模式

这里引用下维基百科的定义:

单例模式,也叫单子模式,是一种常用的软件设计模式,属于创建型模式的一种。在应用这个模式时,单例对象的类必须保证只有一个实例存在。许多时候整个系统只需要拥有一个的全局对象,这样有利于我们协调系统整体的行为。比如在某个服务器程序中,该服务器的配置信息存放在一个文件中,这些配置数据由一个单例对象统一读取,然后服务进程中的其他对象再通过这个单例对象获取这些配置信息。这种方式简化了在复杂环境下的配置管理。

实现单例模式的思路是:一个类能返回对象一个引用(永远是同一个)和一个获得该实例的方法(必须是静态方法,通常使用getInstance这个名称);当我们调用这个方法时,如果类持有的引用不为空就返回这个引用,如果类保持的引用为空就创建该类的实例并将实例的引用赋予该类保持的引用;同时我们还将该类的构造函数定义为私有方法,这样其他处的代码就无法通过调用该类的构造函数来实例化该类的对象,只有通过该类提供的静态方法来得到该类的唯一实例。

单例模式在多线程的应用场合下必须小心使用。如果当唯一实例尚未创建时,有两个线程同时调用创建方法,那么它们同时没有检测到唯一实例的存在,从而同时各自创建了一个实例,这样就有两个实例被构造出来,从而违反了单例模式中实例唯一的原则。 解决这个问题的办法是为指示类是否已经实例化的变量提供一个互斥锁(虽然这样会降低效率)。

实现单例模式的几种方式

实现单例模式有很多种方式,只要保证只有一个实例就可以了,这在单线程和多线程的场合中也是有所区别的,我们就简单介绍几种

饿汉式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class SingletonEH {
/**
*是否 Lazy 初始化:否
*是否多线程安全:是
*实现难度:易
*描述:这种方式比较常用,但容易产生垃圾对象。
*优点:没有加锁,执行效率会提高。
*缺点:类加载时就初始化,浪费内存。
*它基于 classloder 机制避免了多线程的同步问题,
* 不过,instance 在类装载时就实例化,虽然导致类装载的原因有很多种,
* 在单例模式中大多数都是调用 getInstance 方法,
* 但是也不能确定有其他的方式(或者其他的静态方法)导致类装载,
* 这时候初始化 instance 显然没有达到 lazy loading 的效果。
*/
private static SingletonEH instance = new SingletonEH();
private SingletonEH (){}
public static SingletonEH getInstance() {
System.out.println("instance:"+instance);
System.out.println("加载饿汉式....");
return instance;
}
}

饿汉式天生就是线程安全的,可以直接用于多线程而不会出现问题

懒汉式

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
/// <summary>
/// 单例模式的实现
/// </summary>
public class Singleton
{
// 定义一个静态变量来保存类的实例
private static Singleton uniqueInstance;

// 定义私有构造函数,使外界不能创建该类实例
private Singleton()
{
}

/// <summary>
/// 定义公有方法提供一个全局访问点,同时你也可以定义公有属性来提供全局访问点
/// </summary>
/// <returns></returns>
public static Singleton GetInstance()
{
// 如果类的实例不存在则创建,否则直接返回
if (uniqueInstance == null)
{
uniqueInstance = new Singleton();
}
return uniqueInstance;
}
}

上面的单例模式的实现在单线程下确实是完美的,然而在多线程的情况下会得到多个Singleton实例,因为在两个线程同时运行GetInstance方法时,此时两个线程判断(uniqueInstance ==null)这个条件时都返回真,此时两个线程就都会创建Singleton的实例,这样就违背了我们单例模式初衷了,既然上面的实现会运行多个线程执行,那我们对于多线程的解决方案自然就是使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
26
27
28
29
30
31
32
33
34
35
36
37
/// <summary>
/// 单例模式的实现
/// </summary>
public class Singleton
{
// 定义一个静态变量来保存类的实例
private static Singleton uniqueInstance;

// 定义一个标识确保线程同步
private static readonly object locker = new object();

// 定义私有构造函数,使外界不能创建该类实例
private Singleton()
{
}

/// <summary>
/// 定义公有方法提供一个全局访问点,同时你也可以定义公有属性来提供全局访问点
/// </summary>
/// <returns></returns>
public static Singleton GetInstance()
{
// 当第一个线程运行到这里时,此时会对locker对象 "加锁",
// 当第二个线程运行该方法时,首先检测到locker对象为"加锁"状态,该线程就会挂起等待第一个线程解锁
// lock语句运行完之后(即线程运行完之后)会对该对象"解锁"
lock (locker)
{
// 如果类的实例不存在则创建,否则直接返回
if (uniqueInstance == null)
{
uniqueInstance = new Singleton();
}
}

return uniqueInstance;
}
}

上面这种解决方案确实可以解决多线程的问题,但是上面代码对于每个线程都会对线程辅助对象locker加锁之后再判断实例是否存在,对于这个操作完全没有必要的,因为当第一个线程创建了该类的实例之后,后面的线程此时只需要直接判断(uniqueInstancenull)为假,此时完全没必要对线程辅助对象加锁之后再去判断,所以上面的实现方式增加了额外的开销,损失了性能,为了改进上面实现方式的缺陷,我们只需要在lock语句前面加一句(uniqueInstancenull)的判断就可以避免锁所增加的额外开销,这种实现方式我们就叫它 “双重锁定”,下面具体看看实现代码的:

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
32
33
34
35
36
37
38
39
40
/// <summary>
/// 单例模式的实现
/// </summary>
public class Singleton
{
// 定义一个静态变量来保存类的实例
private static Singleton uniqueInstance;

// 定义一个标识确保线程同步
private static readonly object locker = new object();

// 定义私有构造函数,使外界不能创建该类实例
private Singleton()
{
}

/// <summary>
/// 定义公有方法提供一个全局访问点,同时你也可以定义公有属性来提供全局访问点
/// </summary>
/// <returns></returns>
public static Singleton GetInstance()
{
// 当第一个线程运行到这里时,此时会对locker对象 "加锁",
// 当第二个线程运行该方法时,首先检测到locker对象为"加锁"状态,该线程就会挂起等待第一个线程解锁
// lock语句运行完之后(即线程运行完之后)会对该对象"解锁"
// 双重锁定只需要一句判断就可以了
if (uniqueInstance == null)
{
lock (locker)
{
// 如果类的实例不存在则创建,否则直接返回
if (uniqueInstance == null)
{
uniqueInstance = new Singleton();
}
}
}
return uniqueInstance;
}
}

为什么尽量不使用单例模式

首先,使用全局状态的程序很难测试。可测试性的标志之一是类的松散耦合,允许您隔离单个类并完全测试它。当一个类使用一个单例(我说的是一个经典的单例,一个通过静态 getInstance ()方法强制它自己的奇点)时,单例用户和单例不可避免地耦合在一起。如果不测试单例模式,那么测试单例的用户就不可能了(因为该用户一定会调用单例)。在许多情况下,这是一个交易破坏者,可以阻止开发人员测试一个类,特别是如果单例代表一个不应该被测试更新的资源(例如一个重要的数据库)。这里的理想解决方案是在用户的构造函数中将单例作为参数传入,允许测试人员为测试轻松地模拟单例。然后,单例模式不必强制执行它自己的奇异性; 这可以由客户机或工厂类来处理,工厂类可以生成实际版本或测试版本,从而完全消除全局状态。事实上,让一个对象对自己的奇异性和正常任务负责,应该被认为违反了 OO 设计的单一责任原则。

其次,依赖于全局状态的程序隐藏了它们的依赖关系。单例模式的独特功能之一是,可以通过其全局可用的静态方法(即 getInstance ())在任何地方访问它,允许程序员在方法内部使用它,而不必通过参数明确地传递它。虽然这对程序员来说似乎更容易,但是依赖于这个静态实例意味着方法的签名不再显示它们的依赖关系,因为这个方法可以“凭空”得到一个单例这意味着用户需要了解代码的内部工作原理才能正确使用它,这使得它更难以使用和测试。

将这两个问题联系在一起显示了单例模式的另一个问题。在当今测试驱动和敏捷开发的世界中,比以往任何时候都更重要的是要有覆盖大部分代码的小型测试。关于这些测试的一个重要的事情是,它们必须能够以任何顺序运行(不相互依赖) ,这可能成为单例使用的一个问题。由于某些给定方法在依赖于单例(从静态 getter 获取)时的依赖关系并不清楚,测试人员可能不知道通过修改共享资源(单例)编写两个实际上相互依赖的测试。这可能会产生片状测试,这些测试在以一种顺序运行时通过,但在以另一种顺序运行时失败,这些测试没有多大用处

实际上,只有一个对象实例没有什么错。但利用“单例模式”来实现这一目标从来都不是最佳解决方案。在这篇文章的最后,我会告诉你为什么以及如何做得更好。

我们用一个例子来说明单例模式违反了什么设计原则

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class InterestingThingsDoer {
private static InterestingThingsDoer instance = new InterestingThingsDoer();

//possible some instance state

private InterestingThingsDoer() {}

public static InterestingThingsDoer getInstance() {
return instance;
}

public ResultType doSomethingInteresting() {
//something interesting
}
}

然后我们在另一个类的函数中调用这个单例

1
2
3
4
5
public void evenMoreInteresting() {
InterestingThingsDoer doer = InterestingThingsDoer.getInstance();
ResultType result = doer.doSomethingInteresting();
//do something even more interesting...
}

难以测试

单例将所有调用者耦合到单例的具体实现中,因为被调用者要求单例对象。您的类变得更难测试: 您不能单独测试它们,而不能运行 Singleton 中的代码。

在上面的示例中,如果不运行“InterestingThingsDoer”中的一些代码,就不能测试方法“evenMoreInteresting”。

那么这个有什么问题吗?

我们想象一下下面这种场景:

1
2
3
4
5
6
public void evenMoreInteresting() {
InterestingThingsDoer doer = InterestingThingsDoer.getInstance();
ResultType result = doer.doSomethingInteresting();
ResultTypeDao dao = ResultTypeDao.getInstance();
dao.save(result);
}

现在,您需要一个数据库来测试“evenMoreInteresting”。而且,如果“evenMoreInteresting”再次在单例中定义(例如,因为它是无状态服务的一部分,并且每个人都知道您只需要一个无状态服务的实例) ,那么您将需要一个数据库来测试每个调用“evenMoreInteresting”的方法。诸如此类。您的测试中有很大一部分将是集成测试,而这些those are evil

违背了控制反转

当你在一个类中使用一个单例对象时,你违反了“控制反转”: 你的类没有注入合作者(单例对象)。

在上面的代码中,evenMoreInteresting() 对控制流拥有完全的控制权,因为它获得所有必需的协作者的具体实现,并决定如何处理它们。

为了达到控制反转,你必须确保类不负责寻找/创建他们的合作者(除了其他事情)。你可以使用一种叫做“依赖注入”的技术——最好是“构造函数注入”。

但是如果注入过多,可能导致构造函数签名变得太大了!

如果你改变一些现有的代码为依赖注入,特别是构造函数注入,你会注意到你的一些构造函数有很多很多的参数。抓住重构的机会: 这个类显然做得太多了!

违反了开闭原则

单例本身违反了“开/闭原则”: 如果你想扩展或改变单例的行为,你必须改变类。换句话说,如果不打开文件并编辑代码,就不能从上面更改“ InterestingThingsDoer”的行为。

更改代码比添加新代码风险更大。当您更改现有代码时,总是有可能在代码中完全不同的依赖于更改代码的区域中引入不希望的副作用(即“缺陷”或“ bug”)。

当你可以通过添加新代码来扩展现有代码的功能时,在代码的“不相关”部分引入副作用的可能性就会大大降低(但仍然可能大于零,这取决于你的架构和设计)。

因此,违反开放/关闭原则的类/模块/函数增加了以后需要更改缺陷时引入缺陷的可能性。

违反单一职责原则

单例模式本身违反了“单一责任原则”: 除了他们的主要责任外,他们还负责实现他们自己的生命周期。

使用单例的类也违反了“单一责任原则”: 除了它们的主要责任外,它们还负责决定它们的合作者的具体类(至少一个)。

每一个类的责任也是一个改变的理由。无论何时需要更改“ InterestingThingsDoer”,无论何时它的生命周期发生变化,您都必须更改它。当“ InterestingThingsDoer”的生命周期发生变化时,您必须更改所有调用方,因为它们也有多重职责。

并且,如前所述,更改代码本质上是有风险的: 如果因为类的某些职责发生了更改而更改代码,那么还存在一定的风险,即您可能会破坏处理同一类的其他职责的代码。

此外,如果类有多个职责,则必须测试所有职责。你可能无法单独测试它们。这意味着您必须编写更复杂/更难理解的测试。

违反了依赖倒置原则

“依赖反转原则指出,系统的高层策略不应该依赖于低层细节。此外,抽象不应该依赖于细节。

单例通常实现一些底层细节。当您从业务逻辑(系统的高级策略)中使用它们时,您违反了“依赖反转原则”。

该依赖反转原则确保您可以更改系统的低级细节,而不必更改高级“业务逻辑”。

想象一台自动售货机: 你选择一种产品,付出价格,得到产品。当这个高级工作流的实现有任何依赖于低级细节的时候,比如支付是如何工作的,当你添加一些新的支付方法(比如 NFC 支付)时,你可能不得不改变处理这个高级工作流的代码。你不想这样,因为改变代码是危险的。

违反高内聚低耦合

通常,多个类依赖于单例的具体实现。这增加了系统中的耦合性: 当您想要更改单例的行为时,您可能必须检查并更改所有调用方。所有使用单例的类之间总是有共同的耦合: 它们共享一个全局变量——单例!

有时,将代码移动到单例(例如,为了最小化代码重复)也会降低内聚性: 您将东西移动到概念上应该是原始类/包的一部分的不同类/包。

在一个紧密耦合的系统中,更改会波及整个系统: 您希望更改一点点功能,但是要做到这一点,您必须修改20个类和50个测试。

在一个内聚力较低的系统中,甚至很难找到在哪里进行更改: 它可以是10个不同模块中的任何一个。也可能是他们所有人。有时甚至很难知道您是否已经找到了修复一个小缺陷所需要更改的所有位置。

如何更好地使用单例模式

前面说过了,只想有一个对象实例是没有问题的,但是我们完全可以不使用getInstance这种方式

下面提供两种比较好的做法:

如果您真的只想要某个类的一个实例,那么为您的依赖项实现控制反转。如果你已经在使用一个依赖注入容器(比如 Guice 或 Spring) ,只需要确保所有的单例对象都是由这个容器管理的。所有这些容器都有一种将对象声明为单例的方法。

如果你不想使用这些容器中的一个,只需要在一个地方创建所有需要单例的对象实例,然后通过它们的构造函数将它们注入到其他对象中。在应用程序的整个生命周期中,您可能只需要一个实例,但不需要单例模式。只需构造一个,并按照需要将其注入到类中。

有状态的单例和无状态的单例

基本上有两种类型的单例:

  • 无状态单例: 此类不需要多个实例,因为它没有状态。但是你可以拥有很多这样的实例: 这并不重要。因为他们没有状态

  • 有状态单例: 您必须具有这个类的一个实例,因为它表示某个全局共享状态。

第一类不是问题。只要使用其中一种“明智”的方法从上面创建单例,不要使用“单例模式”。即使您构造了不止一个类,这也不是什么问题: 无论如何类是无状态的,您最终只会消耗更多的资源。对于大多数应用程序(但不是所有应用程序) ,这种开销可能是可以忽略不计的.

当您使用一种“明智”的方法来确保只有一个对象时,第二类可能甚至是一个问题: 共享的、可变的状态使得在更改内容时很容易引入缺陷,并使得难以扩展应用程序。

参考文章:
什么是单例模式
单例懒汉式和饿汉式的区别
单例实现的几种方法
为什么单例模式令人困扰
为什么要尽量避免单例模式