那些年,我们一起写过的“单例模式”

Posted 楠来风

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了那些年,我们一起写过的“单例模式”相关的知识,希望对你有一定的参考价值。

题记

度娘上对设计模式(Design pattern)的定义是:“一套被反复使用、多数人知晓的、经过分类编目的、代码设计经验的总结。”它由著名的“四人帮”,又称 GOF (即 Gang of Four),在《设计模式》(《Design Patterns: Elements of Reusable Object-Oriented Software》)一书中提升到理论高度,并将之规范化。在我看来,设计模式是前人对一些有共性的问题的优秀解决方案的经验总结,一个设计模式针对一类不断重复发生的问题给出了可复用的、经过了时间考验的较完善的解决方案。使用设计模式可以提高代码的可重用性、可靠性,从而大大提高开发效率,值得我们细细研究。


在这里,我想结合我们的 android 项目,谈谈大家在其中使用到的一些设计模式。一则,就个人的学习经验看来,研究例子是最容易学会设计模式的方式;二则,其实设计模式的应用同所使用的编程语言和环境都是有关系的,譬如说,我们最先要讨论的单例模式,在 Java 中实现的时候就要特别注意不同 JDK 版本对该模式造成的影响。所以会特意针对我们所关注的 Android 项目进行一些分析。希望通过理论与实践相结合的方式,深入学习设计模式,并自然而然地合理运用到将来,从而完美解决更多问题。

0. 引言

单例模式(Singleton Pattern)一般被认为是最简单、最易理解的设计模式,也因为它的简洁易懂,是项目中最常用、最易被识别出来的模式。既然即使是一个初级的程序员,也会使用单例模式了,为什么我们还要在这里特意地讨论它,并且作为第一个模式来分析呢?事实上在我看来,单例模式是很有“深度”的一个模式,要用好、用对它并不是一件简单的事。

  1. 首先,单例模式可以有多种实现方法,需要根据情况作出正确的选择。
    看名字就知道单例模式的目标就是要确保某个类只产生一个实例,要达到这个目的,代码可以有多种写法,它们各自有不同的优缺点,我们要综合考虑多线程、初始化时机、性能优化、java 版本、类加载器个数等各方面因素,才能做到在合适的情况下选出合用的方法。简单举例看一下 Android 或 Java 中,几个应用了单例模式的场景各自所选择的实现方式:

    isoChronology,LoggingProxy:饿汉模式;
    CalendarAccessControlContext:内部静态类;
    EventBus:双重检查加锁 DCL;
    LayoutInflater:容器方式管理的单例服务之一,通过静态语句块被注册到 Android 应用的服务中。

  2. 其次,单例模式极易被滥用。基本上知道模式的程序员都听说过单例模式,但是在不熟悉的情况下,单例模式往往被用在使用它并不能带来好处的场景下。有很多用了单例的代码并不真的只需要一个实例,这时使用单例模式就会引入不必要的限制和全局状态维护困难等缺陷。通常说来,适合使用单例模式的机会也并不会太多,如果你的某个工程中出现了太多单例,你就应该重新审视一下你的设计,详细确认一下这些场景是否真的都必须要控制实例的个数。

  3. 再者,目前对单例模式也出现了不少争议,使用时更要上心:
    a. 不少人认为,单例既负责实例化类并提供全局访问,又实现了特定的业务逻辑,一定程度上违背了“单一职责原则”,是反模式的。
    b. 单例模式将全局状态(global state)引入了应用,这是单元测试的大敌。
    譬如说 Java 用户都耳熟能详的几个方法:

    System.currentTimeMillis();
    new Date();
    Math.random();

    它们是 JVM 中非常常用的暗藏全局状态(global state)的方法,全局状态会引入状态不确定性(state indeterminism),导致微妙的副作用,很容易就会破坏了单元测试的有效性。也就是说多次调用上述的这些方法,输出结果会不相同;同时它们的输出还同代码执行的顺序有关,对于单元测试来说,这简直就是噩梦!要防止状态从一个测试被带到另一个测试,就不能使用静态变量,而单例类通常都会持有至少一个静态变量(唯一的实例),现实中更是静态变量频繁出现的类,从而是测试人员最不想看到的一个模式。
    c. 单例导致了类之间的强耦合,扩展性差,违反了面向对象编程的理念。
    单例封装了自己实例的创建,不适用于继承和多态,同时创建时一般也不传入参数等,难以用一个模拟对象来进行测试。这都不是健康的代码表现形式。

鉴于上述的这些争议,有部分程序员逐步将单例模式移除出他们的工程,然而这在我看来实在是有点因噎废食,毕竟比起测试的简便性,代码是否健壮易用才是我们的关注点。很多对单例的批评也是基于因为不了解它误用所引发的问题,如果能得到正确的使用,单例也可以发挥出很强的作用。每个模式都有它的优缺点和适用范围,相信大家看过的每一本介绍模式的书籍,都会详细写明某个模式适用于哪些场景。我的观点是,我们要做的是更清楚地了解每一个模式,从而决定在当前的应用场景是否需要使用,以及如何更好地使用这个模式。就像《深入浅出设计模式》里说的:

使用模式最好的方式是:“把模式装进脑子里,然后在你的设计和已有的应用中,寻找何处可以使用它们。”

单例模式是经得起时间考验的模式,只是在错误使用的情况下可能为项目带来额外的风险,因此在使用单例模式之前,我们一定要明确知道自己在做什么,也必须搞清楚为什么要这么做。此文就带大家好好了解一下单例模式,以求在今后的使用中能正确地将它用在利远大于弊的地方,优化我们的代码。

1. 单例模式简介

Singleton 模式可以是很简单的,一般的实现只需要一个类就可以完成,甚至都不需要UML图就能解释清楚。在这个唯一的类中,单例模式确保此类仅有一个实例,自行实例化并提供一个访问它的全局公有静态方法。

  • 一般在两种场景下会考虑使用单例(Singleton)模式:

  1. 产生某对象会消耗过多的资源,为避免频繁地创建与销毁对象对资源的浪费。如:

    对数据库的操作、访问 IO、线程池(threadpool)、网络请求等。

  2. 某种类型的对象应该有且只有一个。如果制造出多个这样的实例,可能导致:程序行为异常、资源使用过量、结果不一致等问题。如果多人能同时操作一个文件,又不进行版本管理,必然会有的修改被覆盖,所以:

    一个系统只能有:一个窗口管理器或文件系统,计时工具或 ID(序号)生成器,缓存(cache),处理偏好设置和注册表(registry)的对象,日志对象。

  • 单例模式的优点:可以减少系统内存开支,减少系统性能开销,避免对资源的多重占用、同时操作。

  • 单例模式的缺点:扩展很困难,容易引发内存泄露,测试困难,一定程度上违背了单一职责原则,进程被杀时可能有状态不一致问题。

2. 单例的各种实现

我们经常看到的单例模式,按加载时机可以分为:饿汉方式和懒汉方式;按实现的方式,有:双重检查加锁,内部类方式和枚举方式等等。另外还有一种通过Map容器来管理单例的方式。它们有的效率很高,有的节省内存,有的实现得简单漂亮,还有的则存在严重缺陷,它们大部分使用的时候都有限制条件。下面我们来分析下各种写法的区别,辨别出哪些是不可行的,哪些是推荐的,最后为大家筛选出几个最值得我们适时应用到项目中的实现方式。

因为下面要讨论的单例写法比较多,筛选过程略长,结论先行:
无论以哪种形式实现单例模式,本质都是使单例类的构造函数对其他类不可见,仅提供获取唯一一个实例的静态方法,必须保证这个获取实例的方法是线程安全的,并防止反序列化、反射、克隆(、多个类加载器、分布式系统)等多种情况下重新生成新的实例对象。至于选择哪种实现方式则取决于项目自身情况,如:是否是复杂的高并发环境、JDK 是哪个版本的、对单例对象资源消耗的要求等。

  • 上表中仅列举那些线程安全的实现方式,永远不要使用线程不安全的单例!

  • 另有使用容器管理单例的方式,属于特殊的应用情况,下文单独讨论。

直观一点,再上一张图:

  • 此四种单例实现方式都是线程安全的,是实现单例时不错的选择

  • 下文会详细给出的三种饿汉模式差别不大,一般使用第二种 static factory 方式

下面就来具体谈一下各种单例实现方式及适用范围。

2.1 线程安全

作为一个单例,我们首先要确保的就是实例的“唯一性”,有很多因素会导致“唯一性”失效,它们包括:多线程、序列化、反射、克隆等,更特殊一点的情况还有:分布式系统、多个类加载器等等。其中,多线程问题最为突出。为了提高应用的工作效率,现如今我们的工程中基本上都会用到多线程;目前使用单线程能轻松完成的任务,日复一日,随着业务逻辑的复杂化、用户数量的递增,也有可能要被升级为多线程处理。所以任何在多线程下不能保证单个实例的单例模式,我都认为应该立即被弃用。

在只考虑一个类加载器的情况下,“饿汉方式”实现的单例(在系统运行起来装载类的时候就进行初始化实例的操作,由 JVM 虚拟机来保证一个类的初始化方法在多线程环境中被正确加锁和同步,所以)是线程安全的,而“懒汉”方式则需要注意了,先来看一种最简单的“懒汉方式”的单例:

这种写法只能在单线程下使用。如果是多线程,可能发生一个线程通过并进入了 if (singleton == null) 判断语句块,但还未来得及创建新的实例时,另一个线程也通过了这个判断语句,两个线程最终都进行了创建,导致多个实例的产生。所以在多线程环境下必须摒弃此方式。

除了多并发的情况,实现单例模式时另一个重要的考量因素是效率。前述的“懒汉方式”的多线程问题可以通过加上 synchronized 修饰符解决,但考虑到性能,一定不要简单粗暴地将其添加在如下位置:

上述方式通过为 getInstence() 方法增加 synchronized 关键字,迫使每个线程在进入这个方法前,要先等候别的线程离开该方法,即不会有两个线程可以同时进入此方法执行 new Singleton(),从而保证了单例的有效。但它的致命缺陷是效率太低了,每个线程每次执行 getInstance() 方法获取类的实例时,都会进行同步。而事实上实例创建完成后,同步就变为不必要的开销了,这样做在高并发下必然会拖垮性能。所以此方法虽然可行但也不推荐。那我们将同步方法改为同步代码块是不是就能减少同步对性能的影响了呢:

但是这种同步却并不能做到线程安全,同最初的懒汉模式一个道理,它可能产生多个实例,所以亦不可行。我们必须再增加一个单例不为空的判断来确保线程安全,也就是所谓的“双重检查锁定”(Double Check Lock(DCL))方式:

此方法的“Double-Check”体现在进行了两次 if (singleton == null) 的检查,这样既同步代码块保证了线程安全,同时实例化的代码也只会执行一次,实例化后同步操作不会再被执行,从而效率提升很多(详细比较见附录 1)。

双重检查锁定(DCL)方式也是延迟加载的,它唯一的问题是,由于 Java 编译器允许处理器乱序执行,在 JDK 版本小于 1.5 时会有 DCL 失效的问题(原因解释详见附录 2)。当然,现在大家使用的 JDK 普遍都已超过 1.4,只要在定义单例时加上 1.5 及以上版本具体化了的 volatile 关键字,即可保证执行的顺序,从而使单例起效。所以 DCL 方式是推荐的一种方式。

  • Android 中鼎鼎大名的 Universal Image Loader 和 EventBus 都是采用了这种方式的单例,下面节选的源码片段就是从它们的 GitHub 工程内拷贝过来的:

  • EventBus 是一个事件发布和订阅的框架,各个组件向全局唯一的一个 EventBus 对象注册自己,就能发布和接收到 event 事件。

  • 我们项目中用到的 DCL 方式实例分析:

    • VersionManager:
      版本控制类,主要用于应用启动时判断当前属于:新安装、更新、没有改变三种情况中的哪一种,从而决定是否要检查更新、显示引导页、拉取素材等等。这个类在应用启动时就使用,貌似使用急切加载更合适,但是由于它是根据 Preference 中记录的版本号来实现判断的,在项目的 PrefsUtils 类初始化完 preference 成员变量以后才会被使用,所以使用 DCL 方式完全合适。

    • PoiManager:拉取地理位置信息(用于拼图及 Webview);WtLoginManager:QQ 登录使用;WeiboManager:新浪微博登录分享使用;CollageTemplateManager,CollageDataManager,CollageDataObserver:拼图的模板、数据、天气地理位置信息等的管理类:这些类都只有在进入了相应模块或使用某一功能时才会被用到,所以使用 DCL 方式。它们中几个持有较多资源的类,甚至还写了 destroy() 方法,可以在退出功能或使用完成时释放资源,销毁单例。以 CollageTemplateManager 类为例,它载入了模板描述文件、缩略图等较多的资源,而退出拼图功能模块后在其他模块中都不会再被使用。代码如下:

我们最后再看一种延迟加载的“静态内部类”方式:

这种方式利用了 classloder 的机制来保证初始化 instance 时只会有一个。需要注意的是:虽然它的名字中有“静态”两字,但它是属于“懒汉模式”的!!这种方式的 Singleton 类被装载时,只要 SingletonHolder 类还没有被主动使用,instance 就不会被初始化。只有在显式调用 getInstance() 方法时,才会装载 SingletonHolder 类,从而实例化对象。

“静态内部类”方式基本上弥补了 DCL 方式在 JDK 版本低于 1.5 时高并发环境失效的缺陷。《Java并发编程实践》中也指出 DCL 方式的“优化”是丑陋的,对静态内部类方式推崇备至。但是可能因为同大家创建单例时的思考习惯不太一致(根据单例模式的特点,一般首先想到的是通过 instance 判空来确保单例),此方式并不特别常见,然而它是所有懒加载的单例实现中适用范围最广、限制最小、最为推荐的一种。(下述的枚举方式限制也很少,但是可能更不易理解。)

  • 我们的 Android 项目中也用到了“静态内部类”方式来实现单例:

SoundController:用于控制拍照时的快门声音。由于用户很少会修改拍照快门声,所以此功能采用了延迟加载,静态内部类方式简洁又方便。话说回来,因为使用频率低,此处即使是使用同步方法的懒汉模式也没有什么问题。

至此,所有的常用懒汉模式都已讨论完毕,仅推荐“双重检查锁定”(DCL)方式(符合思考逻辑)和“静态内部类”方式(任意 JDK 版本可用),它们共同的特点是:懒加载、线程安全、效率较高。

2.2 加载时机

除了高并发下的线程安全,对于单例模式另一个必须要考虑的问题是加载的时机,也就是要在延迟加载和急切加载间做出选择。之前已经看了懒汉加载的单例实现方法,这里再给出两种饿汉加载方式:

这三种方式差别不大,都依赖 JVM 在类装载时就完成唯一对象的实例化,基于类加载的机制,它们天生就是线程安全的,所以都是可行的,第二种更易于理解也比较常见。

那么我们到底什么时候选择懒加载,什么时候选择饿加载呢?

首先,饿汉式的创建方式对使用的场景有限制。如果实例创建时依赖于某个非静态方法的结果,或者依赖于配置文件等,就不考虑使用饿汉模式了(静态变量也是同样的情况)。但是这些情况并不常见,我们主要考虑的还是两种方法对空间和时间利用率上的差别。

饿汉式因为在类创建的同时就实例化了静态对象,其资源已经初始化完成,所以第一次调用时更快,优势在于速度和反应时间,但是不管此单例会不会被使用,在程序运行期间会一直占据着一定的内存;而懒汉式是延迟加载的,优点在于资源利用率高,但第一次调用时的初始化工作会导致性能延迟,以后每次获取实例时也都要先判断实例是否被初始化,造成些许效率损失。

所以这是一个空间和时间之间的选择题,如果一个类初始化需要耗费很多时间,或应用程序总是会使用到该单例,那建议使用饿汉模式;如果资源要占用较多内存,或一个类不一定会被用到,或资源敏感,则可以考虑懒汉模式。

  • 有人戏称单例为“内存泄露”,即使一直没有人使用,它也占据着内存。所以再重申一遍,在使用单例模式前先考虑清楚是否必须,对于那些不是频繁创建和销毁,且创建和销毁也不会消耗太多资源的情况,不要因为首先想到的是单例模式就使用了它。

  • 下面我们先看一下项目中用到的饿汉单例的例子:

  • 根据业务逻辑需要在程序一启动的时候就进行操作的类有:
    SimpleRequest:启动时拉取相机配置和热补丁
    HotFixEngine:热补丁应用类
    CameraAttrs:相机属性,包括黑名单等
    DeviceInstance:(拍照)设备信息类
    VideoDeviceInstance:视频设备信息类
    OpDataManager:运营信息管理,包括:广告页、首页 icon、首页 banner、应用推荐、红点角标等等
    其中典型的 HotFixEngine 类用于加载 hack dex 包,需要尽早执行,不然会出现一堆 java.lang.ClassNotFoundException  错误。最好的执行时机是在 Application 的 attachBaseContext 中(如果工程中引入了 multidex 的,则放在 multidex 之后执行),所以采用了饿汉模式。

  • 也有在整个程序运行过程中从头至尾都需要用到,最好不要频繁创建回收的类:
    MemoryManager:所有缩略图的 cache,大图、拼图模板等的管理
    PerformanceLog:性能打点
    DataReport:数据上报

  • 最后是其实不太适合使用饿汉模式,可以修改为懒汉模式的类:
    LoginManager:登录管理和 WxLoginManager:微信登录管理,其实这两个类是之前同空间的话题圈合作时,工程集成了社区化功能,首页就需要拉取用户消息所引入的类。当时采用急切加载是非常合理且符合需求的,但是由于近期将社区化功能弱化以后,只有在用户反馈时才需要登录,这两个类在后续改为延迟加载会更好。
    SownloadFailDialogue:拉取 banner 后台协议出错时弹出对话框。最大问题是,这是出错时才会用到的类,很少需要使用,饿汉模式显然过于“急切”了。
    FaceValueDetector:人脸数值检测(夫妻相等)和 VideoPreviewFaceOutLineDetector:人脸检测 & 人脸追踪,并不一定会使用到,可以考虑修改为懒汉式。

之前已经举过 DCL 和静态内部类实现的单例模式,都没有问题,不过项目中也发现了一些同步方法的懒汉单例模式,这些类有空的话,最好还是可以修改成前两种方式:

那些年,我们一起写的单例模式

那些年我们一起追逐过的安全工具

那些年我们一起追逐过的安全工具

那些年我们一起追逐过的工具

那些年我们写过的T-SQL(上)

那些年我们一起踩过的坑