随便抓住一个程序员,让他说一下最熟悉的三种设计模式,其中肯定会有单例模式。网上有很多文章讲解什么是单例模式,怎么实现的,但涉及以下问题的回答,却非常少见。


1、为什么要使用单例?


单例模式就是一个类只允许创建一个实例,那这个类就是单例类,这样的设计模式就是单例模式。为什么要使用单例,主要有两个原因,一是可以用来解决资源冲突,比如,两个对象同时写入日志文件,或者对共享变量执行修改,可能存在相互覆盖的情况,单例模式只会产生一个对象,这个对象统一来访问共享资源或竞争资源,就可以避免出现相互覆盖的情况,另外单例不会产生过多的对象,节省内存,由于 new 操作的次数减少,也可以减轻 GC 压力。二是表示全局唯一类,有些数据在系统中只应该保留一份,此时就应该设计成单例类,比如配置文件,全局 ID 生成器。


2、单例存在哪些问题?


大部分情况下,使用单例类都是用它表示全局唯一类,比如配置信息类,连接池类,ID 生成器类,单例模式虽然使用方便,但也会带来问题,比如,对面向对象的特性支持不好,单例对抽象,继承,多态的支持都不好,单例还会隐藏类之间的依赖关系,对代码的扩展性不好,单例还对代码的可测试性不友好,不支持有参数的构造函数。


3、有哪些可以替代单例的方案?


比如保证全局唯一,除了单例,我们还可以使用静态方法。也可以将一个类的对象通过对构造函数传入参数的形式传给其他类,确保该类只有一个对象。工厂模式也是一种选择。程序员自己控制只生成一个对象也是一种选择。


4、如何理解单例模式中的唯一性?


根据定义,一个类只允许创建唯一的一个对象,对象的唯一作用范围是什么?是指进程内只允许创建一个,还是线程内只允许创建一个?答案是前者,也就是说单例创建的对象是进程内唯一的。怎么理解呢,我们编写的程序最终执行时,都是操作系统先启动一个进程,然后将程序(可执行文件)加载到内存地址空间,一条一条执行其中的指令,遇到类的实例化时就分配内存地址给新的对象,如果该进程 fork 了另外的新进程,操作系统会分配新的地址空间,并将原来的进程空间的所有内容全部复制到新的地址空间,包括已经实例化的对象,单例类在老进程内只有一个,在新进程内也只有一个,也就是说进程内唯一,进程间不唯一。


5、如何实现线程唯一的单例?


单例类默认是进程内只存在唯一的对象,进程又包含一个或多个线程,这也意味着在线程间也是唯一的,那么如何改进,实现线程内唯一,线程间不唯一的单例呢?其实也非常简单,我们只需借助一个字典,将线程 ID 作为键,单例类对象作为值进行存储,这样就可以做到相同的线程对应相同的单例对象。


6、如何在集群环境中实现单例?


刚才说了进程内唯一,线程内唯一,现在提到的集群环境中实现单例,就是集群内唯一,实质就是进程间唯一。进程之间是不共享内存的,那就需要借助外部存储来实现,比如文件或数据库,或像 redis 一样具有存储功能的中间件。我们把单例类对象通过序列化保存在外部存储,进程在使用这个单例类对象时先访问外部存储,然后反序列化成对象使用,使用完成后在序列化保存在外部存储。


为了保证任何时刻,在进程之间只有一份对象存在,一个进程在反序列化获取对象之后需要对对象加锁,防止其他进程获取该对象,使用完后序列化保存到外部存储,然后显式的从内存中删除对象(instance = None),并释放锁。


7、如何实现一个多例模式?


多例模式是指一个类可以实例化指定个数的对象,比如一个类被限制为只能实例化 3 个对象。常见的使用场景是日志,比如 logger= logging.getLogger("logger name"),这里传入不同的logger name,获得的 logger 是不同的对象,传入同一个 logger name 得到同一个对象。可以理解为同一个类型只能创建一个对象,不同的类型创建不同的对象。这里的类型就是我们自己定义的,体现多例模式中的多。具体实现方法同样是借助字典,把类型作为键,把实例化的对象作为值,通过控制键的个数来控制对象的个数。