单例模式是一个相对简单的模式,但极为重要。
在我过往的工作经验中,这个模式相当实用,换言之,你可能会在项目中频繁看到,或者使用这个模式,但就像介绍前几个模式时候说的那样,我们需要保持清醒,不能为了使用模式而使用,而是要谨慎评估是否应该使用。当然这并不容易,而且往往比实现具体的设计模式更难。
好了,闲话少说,我们直接来看单例模式。
单例模式
所谓的单例模式就是为了确保只生成一个类实例的而生的模式。
这在使用访问修饰符的编程语言中很简单,只需要合适地使用访问修饰符和一个类方法即可:
package pattern5.java;
public class Single {
private static Single instance;
private Single() {
}
public static Single getInstance() {
if (instance == null) {
instance = new Single();
}
return instance;
}
public void showMe() {
System.out.print("This is a single pattern test");
}
}
这里是用Java实现了一个单例。
实现单例的关键在于程序运行时的唯一Single
类的实例由其静态变量instance
所持有,同时,外部程序只能通过调用类静态方法getInstance()
获取这个实例,且无法通过new Single()
的方式创建实例,为了确保这一点,我们特意将Single
的构造方法的访问修饰符设定为private
,也就是说除了Single
类自己,其它类都不能直接访问Single
的构造方法,自然也就不能通过new
关键字创建Single
的实例。
还有一点需要说明,就是在getInstance()
方法中我们通过检测instance
变量是否已经初始化为Single
实例来“实时”创建唯一实例,这样做的好处显而易见:将确实的实例化押后。如果这个类的实例化很消耗资源,且在程序运行一开始并不需要使用Single
实例,则将不会触发Single
实例化,当然也不会影响性能。当然,这个设计并非单例模式所必须的,你完全可以在类属性instance
定义的时候直接初始化,然后在getInstance
中直接返回该引用,当然这样做就没有相应的好处。
现在我们编写一个测试类进行测试:
需要注意的是测试单例必须是在另外一个类中测试,一开始我是直接在
Single
类中的main
方法进行测试,结果发现依然可以通过new
创建实例,差点以为是长时间没接触Java已经出现了某些新特性导致单例不是这么创建的了,大脑短路一会后才意识到在Single
中测试单例是一种愚蠢的行为。
package pattern5.java;
public class SingleTest {
public static void main(String[] args) {
// Single single = new Single();
// Exception in thread "main" java.lang.Error: Unresolved compilation problem:
// The constructor Single() is not visible
// at pattern5.java.SingleTest.main(SingleTest.java:5)
Single single = Single.getInstance();
single.showMe();
// This is a single pattern test
}
}
从输出可以看到,使用new
是无法创建Single
的实例的,所有Single
的实例都是使用getInstance
获取,而且它们事实上都是指向的同一个实例的引用,所以称为“单例”。
关于单例的UML图这里就不绘制了,因为只有一个类,并无太大意义。
下面我们探讨如何在Python中实现单例模式。
当然,我们可以依葫芦画瓢,按着Java的单例模式来抄,但是有一个致命的问题是Python解决不了的,就是Python并不存在访问修饰符,虽然我们可以用双下划线__XXX
的方式定义伪私有属性,或者干脆使用属性描述符来定义一个调用受限的属性,但是这些都不能用于初始化方法__init__
,相应的,我们抄出来的方案自然也无法阻止别人通过single=Single()
的方式创建新实例。
但另一方面,如果换一个思路,用纯Python的方式思考,单例模式不就是要保证别人不能创建新实例嘛,事实上Python和Java的实例创建方式存在很大不同,这点从Python坚持没有使用new
或者类似的关键字来表示实例创建就能看出,在Python中,single=Single()
这段代码仅仅表明通过一个可执行对象Single
创建了一个实例,并赋值给single
,而具体Single
是怎么实现的,或者干脆可能不是一个类,这些Python都不关心,仅仅需要保证Single
是一个可执行对象即可。
而具体到Single
作为一个类应用于single=Single()
,这里会调用其__new__
方法,而该方法也相当灵活,并不需要保证一定要返回一个Single
实例(虽然一般来说会那样),如果我们需要,甚至可以返回其它任何东西,包括None
,这显然和死板的Java是完全不同的运作方式,而这一点恰恰是我们需要利用的:
from typing import Any
class Single:
def __new__(cls) -> Any:
if not hasattr(cls, "__instance"):
setattr(cls, "__instance", super().__new__(cls))
return getattr(cls, "__instance")
def showMe(self):
print("this is a single pattern test")
single1 = Single()
single2 = Single()
print(single1)
print(single2)
print(single1 is single2)
# <__main__.Single object at 0x0000018A9D61A4C0>
# <__main__.Single object at 0x0000018A9D61A4C0>
# True
这里的__new__
起到了Java代码中的getInstance()
的作用,更妙的是在任何地方使用xxx = Single()
都会调用__new__
进而得到指向同一个Single
实例的引用。
当然,这并非在Python中实现单例的唯一方式,其它方式包括但不限于:使用元类,类修饰器等。如果想了解可以阅读,但个人以为其中几个实现并不严谨,严格来说并不算真正的“单例”。
setattr
等动态处理属性的方法可以通过了解更多。元类的相关内容可以通过了解更多。
如果我们讨论的范畴仅仅限于单线程的情况的话,本文就应该到此结束,然而事实往往并非如此美好,多线程这头怪兽往往会把事情弄的一团糟。
多线程下的单例
Python的多线程因为机制的原因(全局线程锁),实质上多线程的代码对单例模式的影响是相对小的(除非人为在单例的生成过程加入阻塞),所以下面的演示代码主要以Java为例进行说明。
为了复现多线程下之前的设计可能会产生的问题,这里对Single
类进行修改:
package pattern5.java;
public class SingleV2 {
private static SingleV2 instance;
private SingleV2() {
}
public static SingleV2 getInstance() {
if (instance == null) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
instance = new SingleV2();
}
return instance;
}
public void showMe() {
System.out.print("This is a single pattern test");
}
}
在getInstance
中执行instance == null
的判断后,我们人为加入一个线程阻塞Thread.sleep(1000)
,这样就会在多个线程调用时候产生线程切换,让其它线程执行,并且也同样在这里阻塞。这样就会产生一个问题,在多线程下,是有可能同时有超过一个线程经过instance == null
的判断进入if
块,并且创建SingleV2
的实例后赋值给类变量并且返回,在这种情况下自然会产生超过一个SingleV2
实例。
通过测试代码我们可以进行验证:
package pattern5.java;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
public class SingleV2Test implements Callable<SingleV2> {
public static void main(String[] args) {
FutureTask<SingleV2> t1 = SingleV2Test.startNewThread();
FutureTask<SingleV2> t2 = SingleV2Test.startNewThread();
SingleV2 s1 = null;
SingleV2 s2 = null;
try {
s1 = t1.get();
s2 = t2.get();
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
} catch (ExecutionException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
if(s1 != null && s1 == s2){
System.out.println("create same single instance");
}
else if(s1 != null && s1 != s2){
System.out.println("create another single instance");
}
else{
;
}
}
public static FutureTask<SingleV2> startNewThread(){
SingleV2Test test = new SingleV2Test();
FutureTask<SingleV2> task = new FutureTask<>(test);
Thread thread = new Thread(task);
thread.start();
return task;
}
public SingleV2Test() {
}
public SingleV2 call() throws Exception {
// TODO Auto-generated method stub
return SingleV2.getInstance();
}
}
输出的结果是create another single instance
,这表明生成了两个不同的SingleV2
实例。
如果将
SingleV2
中的阻塞代码Thread.sleep()
注释掉,会输出create same single instance
,但这并不意味着没有阻塞的SingleV2
是线程安全的,因为Java的多线程机制和Python不同,不存在全局线程锁,所以是否线程安全并不能依赖于代码中是否有阻塞出现,因为理论上CPU是可以在任意的两行代码之间进行线程切换的,甚至根本就是不同的核心同时执行不同的线程。
解决的方法可以很简单,比如:
public static synchronized SingleV3 getInstance() {
if (instance == null) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
instance = new SingleV3();
}
return instance;
}
只需要给getInstance
加上synchronized
即可。
测试代码与
SingleV2
的测试代码完全一致,完整代码见Github仓库的SingleV3.java
和SingleV3Test.java
。
现在不存在之前所说的问题了,但是带来一个新问题,所有调用getInstance()
的地方都会进行同步检查,也就是说本来单例已经初始化了,只需要调用getInstance
获取单例,完全不需要进行instance == null
检查的情况下,也必须进行同步检查,结果多个线程中同时只有一个线程同时可以使用getInstance()
,其它线程只能干等着。这当然是很糟糕的。
既然给整个方法加锁很糟糕,会极大影响性能,那自然而然的,缩小锁影响的代码的范围就是个不错的选择,这往往也是多线程性能优化的常用方式。
package pattern5.java;
public class SingleV4 {
private volatile static SingleV4 instance;
private SingleV4() {
}
public static SingleV4 getInstance() {
if (instance == null) {
synchronized (SingleV4.class) {
if (instance == null) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
instance = new SingleV4();
}
}
}
return instance;
}
public void showMe() {
System.out.print("This is a single pattern test");
}
}
关于
volatile
关键字可以阅读。
这里对getInstance
的改造使用的方案被称为"双重检查加锁",加锁当然指的是synchronized (SingleV4.class)
的方式加同步锁,限制进程访问。双重检查指的是锁的内外都存在instance == null
的if
判断,内部的if
很好理解,本来我们加锁就是为了避免同时有一个以上的线程同时进入if
,自然要将其包含在锁的代码块内部,外部的if
其用途也很明了:尽量避免不必要的线程进入锁检查环节。关于这一点,你可以将外层if
去除后进行思考,那样的情形下所有线程都必须经过锁检查,无论是否instance
已经被初始化,在这种情况下其实和给整个方法加锁是没有本质区别的,所以当然要通过外层添加一层if
检查来进行规避。这样在instance
已经被初始化的情况下所有线程都会直接返回instance
,不需要进行锁检查。
除此之外,其实还可以通过直接在定义instance
的时候直接初始化的方式彻底规避上面遇到的问题:
package pattern5.java;
public class SingleV5 {
private static SingleV5 instance = new SingleV5();
private SingleV5() {
}
public static SingleV5 getInstance() {
return instance;
}
public void showMe() {
System.out.print("This is a single pattern test");
}
}
这种方式简单粗暴,可能有人会觉得这种解决方案很“low”,但其实计算机领域很多解决方式都很“low”,重要的其实并不是是否“low”,而是是否能解决问题。对于这种方案,我们可以称其为“急切实例化”。
现在我们对比一下上面介绍的几种多线程下的单例方案:
-
同步
getInstance()
-
优点:容易实现。
-
缺点:性能较差。
-
-
急切实例化
-
优点:容易实现。
-
缺点:形式上比较“low”,与经典的单例模式样式不同,且不具备延后实例化的能力。
-
-
双重检查加锁
-
优点:平衡了对性能的影响的同时具备延后实例化的能力。
-
缺点:不容易实现。
-
综上所述,每一种方案都具有不同的优缺点,所以可以视具体情况灵活使用。
最后说一下为什么这里没有讨论Python多线程下的单例模式实现。
之前已经说过了,Python的多线程机制和Java有很大不同,因为其具有全局线程锁,事实上Python一直在单个线程下进行运作,无论你编写的是否为多线程程序,除非遇到I/O阻塞,在遇到阻塞后才会切换到其它线程执行(至少在CPython解释器下如此)。
在这种前提下,只有同时出现你的程序为多线程,且单例模式中的单例类实例创建过程中出现阻塞,这两种条件同时满足才会出现我们之前讨论的问题,进而你可能需要按照前边Java的解决方案实现一个类似的Python版本。
在我看来这种情况出现的概率并不高,而且Python并不是很重视多线程,其中一个旁证就是同样是为了处理并发而引入的包,通过异步方式解决问题的asyncio
就比通过多线程解决问题的futures
包使用范围更广。
或许在JPython之类的线程安全的解释器下Python程序可能会需要切实考虑此类问题,如果有人了解相关问题,欢迎留言讨论。
单例的应用
单例的应用还是挺广泛的,所有系统中需要存在的单一的控制、管理模块都值得考虑是否应当使用单例来实现。
在我的工作经验中,曾经用单例实现过系统配置模块,消息队列等。
关于单例模式的讨论就到这里了,这是一个相当实用的设计模式,希望大家能喜欢,谢谢阅读。
参考文献:
文章评论