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

本文来自:“天天P图攻城狮”公众号(ttpic_dev)

题记

度娘上对设计模式(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 LoaderEventBus 都是采用了这种方式的单例,下面节选的源码片段就是从它们的 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 和静态内部类实现的单例模式,都没有问题,不过项目中也发现了一些同步方法的懒汉单例模式,这些类有空的话,最好还是可以修改成前两种方式:

CameraManager:相机管理类
MaterialDownloadBroadcast:素材下载广播类

2.3 其他需要注意的对单例模式的破坏

2.3.1 序列化

除了多线程,序列化也可能破坏单例模式一个实例的要求。

序列化一是可以将一个单例的实例对象写到磁盘,实现数据的持久化;二是实现对象数据的远程传输。当单例对象有必要实现 Serializable 接口时,即使将其构造函数设为私有,在它反序列化时依然会通过特殊的途径再创建类的一个新的实例,相当于调用了该类的构造函数有效地获得了一个新实例!下述代码就展示了一般情况下行之有效的饿汉式单例,在反序列化情况下不再是单例。

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

输出如下:

Is singleton pattern normally valid: true
Is singleton pattern valid for deserialize: false

要避免单例对象在反序列化时重新生成对象,则在 implements Serializable  的同时应该实现 readResolve() 方法,并在其中保证反序列化的时候获得原来的对象。

readResolve() 是反序列化操作提供的一个很特别的钩子函数,它在从流中读取对象的 readObject(ObjectInputStream) 方法之后被调用,可以让开发人员控制对象的反序列化。我们在 readResolve() 方法中用原来的 instance 替换掉从流中读取到的新创建的 instance,就可以避免使用序列化方式破坏了单例。)

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

在单例中加入上述代码后,输出即变为:

Is singleton pattern normally valid: true
Is singleton pattern valid for deserialize with readResolve(): true

单例有效。

如果想要比较“优雅”地避免上述问题,最好的方式其实是使用枚举。这种方式也是 Effective Java 作者 Josh Bloch 在 item 3 讨论中提倡的方式。枚举不仅在创建实例的时候默认是线程安全的,而且在反序列化时可以自动防止重新创建新的对象。实现如下:

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

枚举类型是有“实例控制”的类,确保了不会同时有两个实例,即当且仅当 a=ba.equals(b),用户也可以用 == 操作符来替代 equals(Object) 方法来提高效率。使用枚举来实现单例还可以不用 getInstance() 方法(当然,如果你想要适应大家的习惯用法,加上 getInstance() 方法也是可以的),直接通过 Singleton.INSTANCE 来拿取实例。枚举类是在第一次访问时才被实例化,是懒加载的。它写法简单,并板上钉钉地保证了在任何情况(包括反序列化,以及后面会谈及的反射、克隆)下都是一个单例。不过由于枚举是 JDK 1.5 才加入的特性,所以同 DCL 方式一样,它对 JDK 的版本也有要求。因为此法在早期 JDK 版本不支持,且和一般单例写起来的思路不太一样,还没有被广泛使用,使用时也可能会比较生疏。所以在实际工作中,很少看见这种用法,在我们的项目中甚至没有找到一例应用的实例。

2.3.2 反射

除了多线程、反序列化以外,反射也会对单例造成破坏。反射可以通过 setAccessible(true) 来绕过 private 限制,从而调用到类的私有构造函数创建对象。我们来看下面的代码:

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

将会打印:

Is singleton pattern normally valid: true
Is singleton pattern valid for Reflection: false

说明使用反射调利用私有构造器也是可以破坏单例的,要防止此情况发生,可以在私有的构造器中加一个判断,需要创建的对象不存在就创建;存在则说明是第二次调用,抛出 RuntimeException 提示。修改私有构造函数代码如下:

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

这样一旦程序中出现代码使用反射方式二次创建单例时,就会打印出:

Is singleton pattern normally valid: true
java.lang.reflect.InvocationTargetException
Caused by: java.lang.RuntimeException: Cannot construct a Singleton more than once!

另外,同反序列化相似,也可以使用枚举的方式来杜绝反射的破坏。当我们通过反射方式来创建枚举类型的实例时,会抛出“Exception in thread "main" java.lang.NoSuchMethodException: net.local.singleton.EnumSingleton.<init>()”异常。所以虽然不常见,但是枚举确实可以作为实现单例的第一选择。

2.3.3 克隆

clone() 是 Object 的方法,每一个对象都是 Object 的子类,都有clone() 方法。clone() 方法并不是调用构造函数来创建对象,而是直接拷贝内存区域。因此当我们的单例对象实现了 Cloneable 接口时,尽管其构造函数是私有的,仍可以通过克隆来创建一个新对象,单例模式也相应失效了。即:

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

输出为:

Is singleton pattern normally valid: true
Is singleton pattern valid for clone: false

所以单例模式的类是不可以实现 Cloneable 接口的,这与 Singleton 模式的初衷相违背。那要如何阻止使用 clone() 方法创建单例实例的另一个实例?可以 override 它的 clone() 方法,使其抛出异常。(也许你想问既然知道了某个类是单例且单例不应该实现 Cloneable 接口,那不实现该接口不就可以了吗?事实上尽管很少见,但有时候单例类可以继承自其它类,如果其父类实现了 clone() 方法的话,就必须在我们的单例类中复写 clone() 方法来阻止对单例的破坏。)

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

输出:

Is singleton pattern normally valid: true
java.lang.CloneNotSupportedException

P.S. Enum 是没有 clone() 方法的。

2.4 登记式单例——使用 Map 容器来管理单例模式

在我们的程序中,随着迭代版本的增加,代码也越来越复杂,往往会使用到多个处理不同业务的单例,这时我们就可以采用 Map 容器来统一管理这些单例,使用时通过统一的接口来获取某个单例。在程序的初始,我们将一组单例类型注入到一个统一的管理类中来维护,即将这些实例存放在一个 Map 登记薄中,在使用时则根据 key 来获取对象对应类型的单例对象。对于已经登记过的实例,从 Map 直接返回实例;对于没有登记的,则先登记再返回。从而在对用户隐藏具体实现、降低代码耦合度的同时,也降低了用户的使用成本。简易版代码实现如下:

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

Android 的系统核心服务就是以如上形式存在的,以达到减少资源消耗的目的。其中最为大家所熟知的服务有 LayoutInflater Service,它就是在虚拟机第一次加载 ContextImpl 类时,以单例形式注册到系统中的一个服务,其它系统级的服务还有:WindowsManagerService、ActivityManagerService 等。JVM 第一次加载调用 ContextImpl 的 registerService() 方法,将这些服务以键值对的形式(以 service name 为键,值则是对应的 ServiceFetcher)存储在一个 HashMap 中,要使用时通过 key 拿到所需的 ServiceFetcher 后,再通过 ServiceFetcher 的 getService() 方法来获取具体的服务对象。在第一次使用服务时,ServiceFetcher 调用 createService() 方法创建服务对象,并缓存到一个列表中,下次再取时就可以直接从缓存中获取,无需重复创建对象,从而实现单例的效果。

3. 关于单例模式的其他问题(Q & A)

3.1 还有其他情况会使单例模式失效吗?

是的,其实前文有提到过,上述的所有讨论都是基于一个类加载器(class loader)的情况。由于每个类加载器有各自的命名空间,static 关键词的作用范围也不是整个 JVM,而只到类加载器,也就是说不同的类加载器可以加载同一个类。所以当一个工程下面存在不止一个类加载器时,整个程序中同一个类就可能被加载多次,如果这是个单例类就会产生多个单例并存失效的现象。因此当程序有多个类加载器又需要实现单例模式,就须自行指定类加载器,并要指定同一个类加载器。基于同样的原因,分布式系统和集群系统也都可能出现单例失效的情况,这就需要利用数据库或者第三方工具等方式来解决失效的问题了。

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

3.2 单例的构造函数是私有的,那还能不能继承单例?

单例是不适合被继承的,要继承单例就要将构造函数改成公开的或受保护的(仅考虑 Java 中的情况),这就会导致:

1)别的类也可以实例化它了,无法确保实例“独一无二”,这显然有违单例的设计理念。
2) 因为单例的实例是使用的静态变量,所有的派生类事实上是共享同一个实例变量的,这种情况下要想让子类们维护正确的状态,顺利工作,基类就不得不实现注册表(Registry)功能了。

要实现单例模式的代码非常简洁,任意现有的类,添加十数行代码后,就可以改造为单例模式。也许继承并不是一个好主意。同时,也应该审视一下单例模式是否在此处被滥用了,在需要继承和扩展的情况下,一开始就不要使用单例模式,这会为你省下很多时间。总之,决定一下对你的需求来说,到底是单例更重要还是可继承更重要。

3.3 单例有没有违反“单一责任原则”?

单例确实承担了两个责任,它不仅仅负责管理自己的实例并提供全局访问,还要处理应用程序的某个业务逻辑。但是由类来管理自己的实例的方式可以让整体设计更简单易懂,单例类自己负责实例的创建也已经是很多程序员耳熟能详的做法了,何况单例模式的创建只需要屈指可数的几行代码,在结构不复杂的情况下,单独将其移到其它类中并不一定经济。

当然在代码繁复的情况下优化你的设计,让单例类专注于自己的业务责任,将它的实例化以及对对象个数的控制封装在一个工厂类或生成器中,也是较好的解决方案。除了遵循了“单一责任原则”,这样做的另一个好处,是可以在创建的时候传入参数,解耦了类,对对象的创建有了更好的控制,也使使用模拟对象(Mock Object)完成测试目标成为可能,基本上解决了文章开头谈到的单例是测试不友好的争议。

3.4 是否可以把一个类的所有方法和变量都定义为静态的,把此类直接当作单例来使用?

事实上在最开始讨论过的,Java 里的 java.lang.System 类以及 java.lang.Math 类都是这么做的,它们的全部方法都用 static 关键词修饰,包装起来提供类级访问。可以看到,Math 类把 Java 基本类型值运算的相关方法组织了起来,当我们调用 Math 类的某个类方法时,所要做的都只是数据操作,并不涉及到对象的状态,对这样的工具类来说实例化没有任何意义。所以如果一个类是自给自足的,初始化简洁,也不需要维护任何状态,仅仅是需要将一些工具方法集中在一起,并提供给全局使用,那么确实可以使用静态类和静态方法来达到单例的效果。但如果单例需要访问资源并对象状态是关注点之一时,则应该使用普通的单例模式。

静态方法会比一般的单例更快,因为静态的绑定是在编译期就进行的。但是也要注意到,静态初始化的控制权完全握在 Java 手上,当涉及到很多类时,这么做可能会引起一些微妙而不易察觉的,和初始化次序有关的bug。除非绝对必要,确保一个对象只有一个实例,会比类只有一个单例更保险。

3.5 考虑技术实现时,如何从单例模式和全局变量中作出选择?

全局变量虽然使用起来比较简单,但相对于单例有如下缺点:

1) 全局变量只是提供了对象的全局的静态引用,但并不能确保只有一个实例;
2) 全局变量是急切实例化的,在程序一开始就创建好对象,对非常耗费资源的对象,或是程序执行过程中一直没有用到的对象,都会形成浪费;
3) 静态初始化时可能信息不完全,无法实例化一个对象。即可能需要使用到程序中稍后才计算出来的值才能创建单例;
4) 使用全局变量容易造成命名空间(namespace)污染。

3.6 据说垃圾收集器会将没有引用的单例清除?

比较早的 Java 版本(JVM ≤ 1.2)的垃圾收集器确实有 bug,会把没有全局引用的单例当作垃圾清除。假设一个单例被创建并使用以后,它实例里的一些变量发生了变化。此时引用它的类被销毁了,除了它本身以外,再没有类引用它,那么一小会儿后,它会就被 Java 的垃圾收集器给清除了。这样再次调用此单例类的 getInstance() 时会重新生成一个单例,使用时会发现之前更新过的实例的变量值都回到了最原始的设置(如网络连接被重新设置等),一切都混乱了。这个 bug 在 1.2 以后的版本已经被修复,但是如果还在使用 Java 1.3 之前的版本,必须建立单例注册表,增加全局引用来避免垃圾收集器将单例回收。

3.7 可以用单例对象 Application 来解决组件见传递数据的问题吗?

在 Android 应用启动后、任意组件被创建前,系统会自动为应用创建一个 Application 类(或其子类)的对象,且只创建一个。从此它就一直在那里,直到应用的进程被杀掉。所以虽然 Application 并没有采用单例模式来实现,但是由于它的生命周期由框架来控制,和整个应用的保持一致,且确保了只有一个,所以可以被看作是一个单例。

一个 Android 应用总有一些信息,譬如说一次耗时计算的结果,需要被用在多个地方。如果将需要传递的对象塞到 intent 里或者存储到数据库里来进行传递,存取都要分别写代码来实现,还是有点麻烦的。既然 Application(或继承它的子类)对于 App 中的所有 activity 和 service 都可见,而且随着 App 启动,它自始至终都在那里,就不禁让我们想到,何不利用 Application 来持有内部变量,从而实现在各组件间传递、分享数据呢?这看上去方便又优雅,但却是完全错误的一种做法!!如果你使用了如上做法,那你的应用最终要么会因为取不到数据发生 NullPointerException 而崩溃,要么就是取到了错误的数据。

我们来看一个具体的例子:

1) 在我们的 App 启动后的第一个 Activity A 中,会要求用户输入需要显示的字符串,假设为 “Hello, Singlton!”,然后我们把它作为全局变量 showString 保存在 Application 中;
2) 然后从 Activity A 中 startActivity() 跳转到 Activity B,我们从 Application 对象中将 showString 取出来并显示到屏幕上。目前看起来,一切都很正常。
3) 但是如果我们按了 Home 键将 App 退到后台,那么在等了较长的时间后,系统可能会因为内存不够而回收了我们的应用。(也可以直接手动杀进程。)
4) 此时再打开我们的 App,系统会重新创建一个 Application 对象,并恢复到刚刚离开时的页面,即跳转到 Activity B。
5) 当 Activity B 再次运行到向 Application 对象拿取 showString 并显示时,就会发现现在显示的不再是“Hello, Singlton!”了,而是空字符串。

这是因为在我们新建的 Application 对象中,showString并没有被赋值,所以为 null。如果我们在显示前先将字符串全部变为大写,showString.toUpperCase(),我们的程序甚至会因此而 crash!!

究其本质,Application 不会永远驻留在内存里,随着进程被杀掉,Application 也被销毁了,再次使用时,它会被重新创建,它之前保存下来的所有状态都会被重置。

要预防这个问题,我们不能用 Application 对象来传递数据,而是要:

1) 通过传统的 intent 来显式传递数据(将 Parcelable 或 Serializable 对象放入Intent / Bundle。Parcelable 性能比 Serializable 快一个量级,但是代码实现要复杂一些)。
2) 重写 onSaveInstanceState() 以及 onRestoreInstanceState() 方法,确保进程被杀掉时保存了必须的应用状态,从而在重新打开时可以正确恢复现场。
3) 使用合适的方式将数据保存到数据库或硬盘。
4) 总是做判空保护和处理。

上述这个问题除了 Application 类存在,App 中的任何一个单例或者公共的静态变量都存在,这就要求我们写出健壮的代码来好好来维护它们的状态,也要在考虑是否使用单例时慎之又慎。

3.8 在 Android 中使用单例还有哪些需要注意的地方

单例在 Android 中的生命周期等于应用的生命周期,所以要特别小心它持有的对象是否会造成内存泄露。如果将 Activity 等 Context 传递给单例又没有释放,就会发生内存泄露,所以最好仅传递给单例 Application Context。

4. 举一个例子

我们的某个项目中单例的实现略有点特别,它把单例抽象了出来,写了一个抽象的 Singlton 泛型类:

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

所有的单例创建都是在继承了 Application 的 XXXXXApplication 类中,以其中以用于登录和注册的单例为例,首先创建单例,使用时只需要调用 XXXXXApplication.getLoginManager() 就可以拿到实例了:

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

说实话,当年我咋一看到这个单例实现,觉得那是相当的“高大上”,似乎也很好用:同时用到了抽象类和泛型类,安全性高,灵活性好,通用性强;用全局唯一的 Application 类来统一管理各个单例也貌似再合适不过,但是如果我们仔细分析一下的话,可以发现这种实现方式有不少问题:

  1. 虽然使用泛型感觉是很有弹性的做法,但是事实上所有的单例都继承了这个类,而父类的 get() 方法用了 final 来修饰,在子类中是不能被重写的,这就造成了我们应用中的所有单例用的是相同的单例方式,也就是都用了 DCL 方式来实现单例,难以想象一种单例可以适用于整个项目(此项目中的单例类包括:登录注册管理类 LoginManager,账户管理类 AccountManager,用户信息业务逻辑类 UserBusiness,主线程 Handler 类 MainHandler,数据上报 Looper 类 ReportLooper,Preference 管理类 PrefManager,WNS 数据透传管理类 SenderManager, PUSH业务逻辑类 PushBusiness,素材业务逻辑类 MaterailBusiness,搜索业务逻辑类 SearchBusiness,消息业务逻辑类 MessageBusiness 等等,DCL 显然不适用于所有这些单例。P.S. 感觉单例的使用也有点多了,需要检查一下是否有滥用)。

  2. 这种方法其实是 3.2 中讨论的单例的继承的情况,为了提高可扩展性,父类的构造函数不再是私有的,导致单例的“唯一性”遭到了破坏。工程的任意处,我调用如下代码,即可以再得到一个 LoginManager:
    《那些年,我们一起写过的 “单例模式”》
    整个项目中考虑到可扩展性偶一为之还能接受(不推荐),但所有的单例都不能确保独一无二就是一个大问题了。

  3. 代码的 owner 用了 privatefinalstatic 等关键词,可能是希望能确保单例的唯一性(前面已经证明这一目的并未达到),但是它们使得这些单例类在 XXXXXApplication 类加载的时候,即程序一开始运行时就被实例化了。无论这些单例类有没有用到,它的实例都存在于内存中了。虽然因为 DCL 方式实现的单例有延迟加载的优点,这些单例的 instance 会在使用时才创建,但是现在思路混乱地把两者搭配在一起,不但无法体现两者的优势,反而会同时有两者的限制;

上面只列举了几处明显问题,显然这个反面教材是在没有深刻理解单例的情况下编写的,从而思路不清,错漏百出。而这样的代码一直存在于我们的项目中,在没有深入研究单例这个模式前,我也完全没有看出任何问题,使用得非常欢快:(。我希望大家看了此文,了解了单例的方方面面后,除了能正确地使用好单例,也能体会到设计模式是久经时间考验、多次优化后的经验总结,在没有理解透彻前的随意改动可能会引入意想不到的问题。另外,代码也不是用到的“高端”技巧越多就是越好的,“高端”往往意味着不常用,不熟悉,不通用,不易理解,所以使用时一定要谨慎!!

5. 总结

关于单例模式先讲到这里,其实总结已经在文章前半部分给出了,我也没有体力重申一遍了:P
由于内容比较多,又是利用平时的零碎时间断断续续撰写此文的,难免会有错失遗漏,大家有任何想法和建议也请不吝赐教,谢谢!

附录

重新贴一遍“双重检查锁定(DCL)”方式实现单例模式的代码,在下面两个分析中都会涉及:

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

  1. 粗略比较一下高并发的情况下,同步方法方式同 DCL 方式效率上的差别。在服务器允许的情况下,假设有一百个线程,则耗时结果如下:
    《那些年,我们一起写过的 “单例模式”》
    在第一次运行的时候,同步方法方式耗费的时间为:100 * (同步判断时间 + if 判断时间)。以后也保持这样的消耗不变。
    而 DCL 方式中虽然有两个 if 判断,但 100 个线程是可以同时进行第一个 if 判断的(因为此时还没有同步),理论上 100 个线程第一个 if 判断消耗的总时间只需一次判断的时间,第二个 if 判断,在第一次执行时,如果是最坏的情况会有 100 次,加上 100 个同步判断时间,DCL 方法第一次执行会比同步方法方式多一个判断时间,即 100 * (同步判断时间 + if 判断时间) + 1 * if 判断时间。但重要的是,这种 DCL 方式只在第一次实例化的时候进行加锁,之后就不会再通过第一个 if 判断,也就不用加锁,不再有同步判断和第二次 if 判断的时间损耗,100 个线程也只会有一个 if 判断时间,效率相比 100 * (同步判断时间 + if判断时间) 大大提高。

  2. 双重检查锁定(DCL)单例在 JDK 1.5 之前版本失效原因解释
    在高并发环境,JDK 1.4 及更早版本下,双重检查锁定偶尔会失败。其根本原因是,Java 中 new 一个对象并不是一个原子操作,编译时 singleton = new Singleton(); 语句会被转成多条汇编指令,它们大致做了3件事情:
    1) 给 Singleton 类的实例分配内存空间;
    2) 调用私有的构造函数 Singleton(),初始化成员变量;
    3)singleton 对象指向分配的内存(执行完此操作 singleton 就不是 null 了)
    由于 Java 编译器允许处理器乱序执行,以及 JDK 1.5 之前的旧的 Java 内存模型(Java Memory Model)中 Cache、寄存器到主内存回写顺序的规定,上面步骤 2) 和 3) 的执行顺序是无法确定的,可能是 1) → 2) → 3) 也可能是 1) → 3) → 2) 。如果是后一种情况,在线程 A 执行完步骤 3) 但还没完成 2) 之前,被切换到线程 B 上,此时线程 B 对 singleton 第1次判空结果为 false,直接取走了 singleton使用,但是构造函数却还没有完成所有的初始化工作,就会出错,也就是 DCL 失效问题。
    在 JDK 1.5的版本中具体化了 volatile 关键字,将其加在对象前就可以保证每次都是从主内存中读取对象,从而修复了 DCL 失效问题。当然,volatile 或多或少还是会影响到一些性能,但比起得到错误的结果,牺牲这点性能还是值得的。

参考资料

[1] 何红辉,关爱民. Android 源码设计模式解析与实战[M]. 北京:人民邮电出版社,2015. 23-42.
[2] Eric Freeman,Elisabeth Freeman,Kathy Sierra,Bert Bates. Head First 设计模式(中文版)[M]. 北京:中国电力出版社,2007. 169-190.
[3] Erich Gamma,Richard Helm,Ralph Johnson,John Vlissides. 设计模式:可复用面向对象软件的基础[M].北京:机械工业出版社,2010. 84-90.
[4] Scott Densmore. Why singletons are evil,May 2004
[5] Steve Yegge. Singletons considered stupid, September 2004
[6] Miško Hevery. Clean Code Talks – Global State and Singletons,November 2008
[7] Joshua Bloch. Creating and Destroying Java Objects,May 2008
[8] Javin Paul. Why Enum Singleton are better in Java,July 2012
[9] Philippe Breault. Don’t Store Data in the Application Object,May 2013
[10] IcyFenix. 探索设计模式之六——单例模式,01/2010
[11] Card361401376. 设计模式-单例模式(Singleton)在Android中的应用场景和实际使用遇到的问题,05/2016
[12] liuluo129. 单例模式以及通过反射和序列化破解单例模式,09/2013

    原文作者:算法小白
    原文地址: https://juejin.im/entry/58c1320cac502e0062c446a8
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞