一、设计模式概述
常见的设计模式一共有23种,这些设计模式从行为上划分可以分为3类:创建型、结构型和行为型。
其中创建型设计模式主要解决“对象的创建”问题;
结构型设计模式主要解决“类或对象如何组合或组装”问题;
行为型设计模式主要解决的就是“类或对象之间的交互”问题;
每种设计模式的应用场景各不相同,有些设计模式的使用方式却又非常相似,让人感觉掌握了但又好像没有掌握。
但其实万变不离其宗,很多设计模式都是试图将庞大的类拆分成更细小的类,然后再通过某种更合理的结构组装在一起,只要掌握了这个根本宗旨,即使我们不去记有什么设计模式,不去记哪种设计模式的使用场景是什么,甚至是自己设计设计模式,也能做好代码设计和架构。
设计模式要干的事情就是解耦。创建型模式是将创建和使用代码解耦,结构型模式是将不同功能代码解耦,行为型模式是将不同的行为代码解耦。借助设计模式,我们将一大坨代码拆分成职责更单一的小类,以此来控制代码的复杂性,提高可扩展性。
本节内容我们介绍第一个设计模式:单例模式。
二、什么是单例模式、使用场景和实现方式
1. 什么是单例模式?
一个类只允许创建一个对象,这种设计模式就叫作单例设计模式(Singleton Design Pattern)。
2. 单例模式的使用场景
如果某些数据或者某个实体对象在系统中只应该保存一份,那就比较适合用单例模式将这个对象设计为单例对象。
比如配置信息,当配置文件被加载到内存之后,没有必要实例化为多个对象,只需要实例化为1个对象供各个模块使用即可。
又比如唯一递增 ID 号码生成器,如果程序中有两个对象,那就会存在生成重复 ID。
3. 如何实现单例模式
要实现单例模式需要做到以下几点:
a. 创建私有的构造方法保证外部无法new出一个对象;
b. 使用一个静态成员保存这个唯一对象,外部通过任何方法都只能获取该唯一的对象而非一个新对象;
每种语言都有自己的实现单例的方法,这里以PHP、Python和java为例。
PHP实现:
python实现:
python实现单例模式的方式起码有4种,这里我只列出最常见的一种。
__new__ 魔术方法是一个由 object 基类提供的内置静态方法,主要作用有两个:
1.在内存中为对象分配空间;
2.返回对象的引用;
Python 的解释器获得对象的引用后,将引用作为第一个参数,传递给 __init__ 方法。也就是说,在外部实例化一个python对象的时候,会先调用这个类的 __new__方法,然后再调用__init__构造方法。
我们只需要稍微改造一下__new__方法,将其返回的对象引用改成固定的 cls._instance 对象即可做到单例。
java实现单例模式有饿汉式和懒汉式2种模式,作者是以一个唯一递增 ID 号码生成器为例作为演示。
下面是java的饿汉式单例模式实现:
Java的懒汉式单例模式:
饿汉式在类加载的时候,instance 实例就已经创建并初始化好,需要获取实例(调用getInstance)的时候立马可以通过静态成员instance获取到;
懒汉式则是在需要获取实例(调用getInstance)的时候才创建和初始化实例。
饿汉式单例的特点在于实例是提前创建好的,真正需要用到实例的时候节省了创建实例的耗时。而且由于加载类的时候实例就已经创建好了,因此实例的创建过程是线程安全的。
懒汉式的特点在于实例是延迟创建的,只有真正要用到这个实例时才创建和加载实例,但缺点是在并发环境下,刚被创建好的实例可能会被覆盖。
三、单例模式对象创建时的线程安全问题
单例模式下,对象创建时的线程安全问题是不能忽视的。
我们假设在并发环境下,单例类中的实例还未创建,线程A和线程B同时调用单例类的获取实例方法,此时2种情况的发生会导致数据不一致。
1. A执行到getInstance,且构造函数中的初始化操作已经执行完,但对象还未被赋值给私有成员 instance的时候,CPU切换到线程B,然后B完成了构造函数中的初始化操作并将实例赋值给私有成员 instance。
这种情况下,构造函数中的初始化操作被执行了2次,如果初始化操作不是幂等性操作,就导致数据不一致。
2. 还是上面那种情况,并发创建实例的时候,CPU切换到线程B,然后B完成了构造函数中的初始化操作,将实例赋值给私有成员 instance,并且调用了实例的某个方法改变了实例的属性值。之后CPU切回线程A,A的实例把B的实例给覆盖了,导致属性值复原,数据就不一致了。
所以,单例模式的对象创建过程必须保证是并发安全的,可以通过加锁解决,这里以python实现为例,下面的代码中采用了双重检测加锁的做法。
在java、python和go这些支持多线程和协程并发的语言里,单例模式对象创建会有并发安全,但是对于PHP这种多进程语言,则没有这样的问题,因为PHP的多进程之间数据是相互隔离的,每一个进程都有一份独立的单例类内存。
只有一种情况会导致PHP的单例模式对象创建发生并发安全问题。那就是在swoole协程环境,多个协程并发调用getInstance,且单例类的构造函数包含IO操作(如文件读写,DB操作等)的场景下会有这问题。
四、单例模式的优点和缺点
单例模式的优点
节省内存和其他系统资源,因为程序全局只有该类的一个实例的内存占用,且避免了实例的重复创建和销毁,初始化操作也只需要执行1次。
单例模式的缺点
1、不利于继承和多态。单例模式一个类只能存储一个对象,如果这个类下面有多个子类实现多态,那么除了第一个先实例化的子类对象能实例化成功,其他子类的实例化都会失败。因此,可以说单例模式对面向对象特性中的继承性和多态性支持不好,对代码的扩展性也不友好。
举个例子,有一个IO任务处理类 IOTaskHandler实现了单例模式,该类通过下有子类 DownloadHandler负责下载任务、LogHandler负责读写日志任务。
如果我的一个业务逻辑中需要先用到下载类,后用到日志记录类,即先调用 DownloadHandler::getInstance() 后调用LogHandler::getInstance()。
那么调用 LogHandler::getInstance() 的时候获得的还是 DownloadHandler实例而无法获得LogHandler实例。
解决方式比较直接粗暴,那就是将 存储实例的静态私有成员 变成map,让他能存储多个实例,每种Handler子类的实例只能存储一个。
实际上这是单例模式的一种变体,叫做多例模式。
2、单例对有参数的构造函数不友好
如果你单例模式类下的构造函数有参数,那么最简单的传递方式就是让getInstance方法也设置和构造函数相同的参数。
如下所示:
但是这样会有一个问题,如果我两次执行 getInstance() 方法,那获取到的 handler1 和 handler2 的 paramA 和 paramB 都是 1,2。也就是说,第二次的参数(3,4)没有起作用。
对于这种情况,我们可以通过3种方式解决:
方法一是构造函数不设置参数,而是定义 paramA 和 paramB 的 setter 方法,通过setter方法给paramA 和 paramB 赋值。
方法二是定义一个静态的 init()方法,init()的参数设置的和 构造函数的参数一样。
获取实例之前先调用init()方法创建和存储实例,然后再调用 getInstance() 获取实例。
五、单例模式的替代方案
1、静态类
静态类是方法和属性都是静态方法和静态属性的类,并且构造函数是私有的,无法进行实例化。
静态类也能表示全局唯一的资源和数据,一个进程中的所有地方(包括该进程下的所有线程之间)都共享一个类的所有数据。从这个特性来看,静态类和单例模式是一样的。
然而静态类的缺点比单例模式更加严重,它对面向对象中的多态和继承特性更加的不友好,多态性基本丧失。原因是静态类下的子类会共享父类的成员属性。
我们看下这个例子:
这个例子中,我把 SonClass 的 p1 属性设置为100, 之后打印兄弟类 SonClass 的 p1 属性,发现也被修改为了100。
想想一下,如果上面例子中的 IOHandler 任务处理类用静态类实现,那么 DownloadHandler 和 LogHandler 类之间的成员属性相互影响,数据就会混乱。说不定LogHandler用着用着,就发现自己的属性值被改成了DownloadHandler的属性值了。
什么情况下可以用静态类代替单例模式呢?当一个类已经确定了不需要扩展和多态实现时就可以。
2、工厂模式
3、IOC容器(依赖注入容器)
六、单例模式变体——线程内单例模式和进程间单例
我们知道类在单个进程内是唯一的,单例对象在单进程的多个线程之间是共享的,在进程间是每个进程各有一份且相互独立的。
接下来我们研究两个问题:
如何实现一个线程内唯一、共享而线程间相互独立的单例?
如何实现一个进程间(或者集群间)也唯一、共享的单例?
1、如何实现线程唯一单例
只需要创建一个以线程ID作为key,实例对象为value 的 map 作为单例类的属性,来存储这些线程唯一单例即可。
每个线程只能通过自己的线程ID获取自己对应的单例实例。
2、如何实现一个进程(或集群)间的单例?
只需要将实例对象序列化并存储到外部共享存储区(如文件、db、或者redis等)。在单例类的getInstance()方法中要做的就是从外部存储区读取对象实例并反序列化即可。
如下所示:
需要注意:
进程间单例需要保证一个进程占有该实例的时候其他进程不能获取该实例。因此获取实例时需要上锁,并且只有在程序使用完这个实例的时候才能释放锁,而且使用完实例之后调用方需要将实例从内存中删除,免得这个实例一直在内存中呆着。
由于是针对进程并发的锁,因此这里的锁需要使用分布式锁;
由于需要加锁,会导致请求这个单例的进程之间在使用/请求这个单例的过程只能串行,并发度变低,而且这个锁的临界区还无法评估,调用者可能用完之后马上释放,也可能调用者忘记了释放,直到这个进程结束才被动触发__destruct()析构方法而释放。
因此,这里虽然给出了进程间单例的实现方式,但是不推荐使用。如果多个进程间需要共享一个全局唯一数据或资源,直接对那个资源的操作加锁即可,而不是使用进程间单例模式,这样可以最大程度缩小锁的临界区,提升并发度。