不完全的单例类
什么是不完全的单例类
估计有些读者见过下面这样的“不完全”的单例类。
代码清单10:“不完全”单例类
package com.javapatterns.singleton.demos; public class LazySingleton { private static LazySingleton m_instance = null; /** * 公开的构造子,外界可以直接实例化 */ public LazySingleton() { } /** * 静态工厂方法 * @return 返还LazySingleton 类的惟一实例 */ synchronized public static LazySingleton getInstance() { if (m_instance == null) { m_instance = new LazySingleton(); } return m_instance; } }
上面的代码乍看起来是一个“懒汉”式单例类,仔细一看,发现有一个公开的构造子。由于外界可以使用构造子创建出任意多个此类的实例,这违背了单例类只能有一个(或有限个)实例的特性,因此这个类不是完全的单例类。这种情况有时会出现,比如javax.swing.TimerQueue 便是一例,关于这个类,请参见《Java与模式》一书中的“观察者模式与Swing 定时器” 一章。
造成这种情况出现的原因有以下几种可能:
(1) 初学者的错误。许多初学者没有认识到单例类的构造子不能是公开的,因此犯下这个错误。有些初学Java 语言的学员甚至不知道一个Java 类的构造子可以不是公开的。在 这种情况下,设计师可能会通过自我约束,也就是说不去调用构造子的办法,将这个不完全的单例类在使用中作为一个单例类使用。
在这种情况下,一个简单的矫正办法,就是将公开的构造子改为私有的构造子。
(2) 当初出于考虑不周,将一个类设计成为单例类,后来发现此类应当有多于一个的实例。为了弥补错误, 干脆将构造子改为公开的,以便在需要多于一个的实例时, 可以随时调用构造子创建新的实例。要纠正这种情况较为困难,必须根据具体情况做出改进的决定。如果一个类在最初被设计成为单例类,但后来发现实际上此类应当有有限多个实例,这时候应当考虑是否将单例类改为多例类(Multiton)。
(3)设计师的Java 知识很好,而且也知道单例模式的正确使用方法,但是还是有意使用这种不完全的单例模式,因为他意在使用一种“改良”的单例模式。这时候, 除去共有的构造子不符合单例模式的要求之外,这个类必须是很好的单例模式。
默认实例模式
有些设计师将这种不完全的单例模式叫做“默认实例模式”(Default Instance Pattern)。在所谓的“ 默认实例模式”里面, 一个类提供静态的方法,如同单例模式一样, 同时又提供一个公开的构造子,如同普通的类一样。
这样做的惟一好处是,这种模式允许客户端选择如何将类实例化:创建新的自己独有的实例,或者使用共享的实例。这样一来,由于没有任何的强制性措施,客户端的选择不一定是合理的选择。其结果是设计师往往不会花费时间在如何提供最好的选择上,而是不恰当地将这种选择交给客户端的程序员,这样必然会导致不理想的设计和欠考虑的实现。
本文建议读者不要这样做。
相关模式
有一些模式可以使用单例模式,如抽象工厂模式可以使用单例模式,将具体工厂类设计成单例类;建造模式可以使用单例模式,将具体建造类设计成单例类。
多例(Multiton)模式
正如同本章所说的,单例模式的精神可以推广到多于一个实例的情况。这时候这种类叫做多例类,这种模式叫做多例模式。单例类(左)和多例类(右)的类图如下所示。
关于多例模式,请见《Java与模式》一书中的“专题:多例(Multiton)模式与多语言支持”一章。
简单工厂(Simple Factory)模式
单例模式使用了简单工厂模式(又称为静态工厂方法模式)来提供自己的实例。在上面ConfigManager 例子的代码中, 静态工厂方法getInstance() 就是静态工厂方法。在java.awt.Toolkit 类中,getDefaultToolkit() 方法就是静态工厂方法。简单工厂模式的简略类图如下所示。
本章讨论了单例模式的结构和实现方法。
单例模式是一个看上去很简单的模式,很多设计师最先学会的往往是单例模式。然而,随着Java 系统日益变得复杂化和分散化,单例模式的使用变得比过去困难。本书提醒读者在分散式的Java 系统中使用单例模式时,尽量不要使用有状态的。
问答题
1. 为什么不使用一个静态的“全程”原始变量,而要建一个类?一个静态的原始变量当然只能有一个值,自然而然不就是“单例”的吗?
2. 举例说明如何调用EagerSingleton 类。
3. 举例说明如何调用RegSingleton 类和RegSingletonChild 类。
4. 请问java.lang.Math 类和java.lang.StrictMath 类是否是单例模式?
5. 我们公司只购买了一个JDBC 驱动软件的单用户使用许可,可否使用单例模式管理通过JDBC 驱动软件连接的数据库?
问答题答案
1. 单例模式可以提供很复杂的逻辑,而一个原始变量不能自已初始化,不可能有继承的关系,没有内部结构。因此单例模式有很多优越之处。
在Java 语言里并没有真正的“全程”变量,一个变量必须属于某一个类或者某一个实例。而在复杂的程序当中,一个静态变量的初始化发生在哪里常常是一个不易确定的问题。当然,使用“全程”原始变量并没有什么错误,就好像选择使用Fortran 语言而非Java语言编程并不是一种对错的问题一样。
2. 几种单例类的使用方法如下。
代码清单11:几种单例类的使用方法
public class RegSingletonTest { public static void main(String[] args) { //(1) Test eager System.out.println( EagerSingleton.getInstance()); //(2) Test reg System.out.println( RegSingleton.getInstance( "com.javapatterns.singleton.demos.RegSingleton").about()); System.out.println( RegSingleton.getInstance(null).about() ); System.out.println( RegSingleton.getInstance( "com.javapatterns.singleton.demos.RegSingletonChild").about()); System.out.println( RegSingletonChild.getInstance().about()); } } 3. 见上题答案。
4. 它们都不是单例类。原因如下:
这两个类均有一个私有的构造子。但是这仅仅是单例模式的必要条件,而不是充分条件。回顾在本章开始提出的单例模式的三个特性可以看出,无论是Math 还是StrictMath 都没有为外界提供任何自身的实例。实际上,这两个类都是被设计来提供静态工厂方法和常量的,因此从来就不需要它们的实例,这才是它们的构造子是私有的原因。Math和StrictMath 类的类图如下所示。
5. 这样做是可行的,只是必须注意当使用在分散式系统中的时候,不一定能保证单例类实例的惟一性。
附录:双重检查成例的研究
成例是一种代码层次上的模式,是在比设计模式的层次更具体的层次上的代码技巧。成例往往与编程语言密切相关。双重检查成例(Double Check Idiom )是从C 语言移植过来的一种代码模式。在C 语言里,双重检查成例常常用在多线程环境中类的晚实例化(Late Instantiation)里。
本节之所以要介绍这个成例(严格来讲,是介绍为什么这个成例不成立), 是因为有很多人认为双重检查成例可以使用在“懒汉”单例模式里面。
什么是双重检查成例
为了解释什么是双重检查成例,请首先看看下面没有使用任何线程安全考虑的错误例子。
从单线程的程序谈起
首先考虑一个单线程的版本。
代码清单13:没有使用任何线程安全措施的一个例子
// Single threaded version class Foo { private Helper helper = null; public Helper getHelper() { if (helper == null) { helper = new Helper(); } return helper; } // other functions and members... } 这是一个错误的例子,详情请见下面的说明。
写出这样的代码,本意显然是要保持在整个JVM 中只有一个Helper 的实例;因此,才会有if (helper == null) 的检查。非常明显的是,如果在多线程的环境中运行,上面的代码会有两个甚至两个以上的Helper 对象被创建出来,从而造成错误。
但是,想像一下在多线程环境中的情形就会发现,如果有两个线程A 和B 几乎同时到达if (helper == null)语句的外面的话,假设线程A 比线程B 早一点点,那么:
(1)A 会首先进入if (helper == null) 块的内部,并开始执行new Helper() 语句。此时,helper 变量仍然是null,直到线程A 的new Helper() 语句返回并给helper 变量赋值为止。
(2) 但是,线程B 并不会在if (helper == null)语句的外面等待,因为此时helper == null 是成立的,它会马上进入if (helper == null)语句块的内部。这样,线程B 会不可避免地执行helper = new Helper();语句,从而创建出第二个实例来。
(3)线程A 的helper = new Helper();语句执行完毕后,helper 变量得到了真实的对象引用,(helper == null)不再为真。第三个线程不会再进入if (helper == null) 语句块的内部了。
(4)线程B 的helper = new Helper(); 语句也执行完毕后,helper 变量的值被覆盖。但是第一个Helper 对象被线程A 引用的事实不会改变。
这时,线程A 和B 各自拥有一个独立的Helper 对象,而这是错误的。
线程安全的版本
为了克服没有线程安全的缺点,下面给出一个线程安全的例子。
代码清单14:这是一个正确的答案
// Correct multithreaded version class Foo { private Helper helper = null; public synchronized Helper getHelper() { if (helper == null) { helper = new Helper(); return helper; } } // other functions and members... } 显然,由于整个静态工厂方法都是同步化的,因此,不会有两个线程同时进入这个方法。因此,当线程A 和B 作为第一批调用者同时或几乎同时调用此方法时:
(1)早到一点的线程A 会率先进入此方法,同时线程B 会在方法外部等待。
(2) 对线程A 来说,helper 变量的值是null ,因此helper = new Helper(); 语句会被执行。
(3)线程A 结束对方法的执行,helper 变量的值不再是null。
(4)线程B 进入此方法,helper 变量的值不再是null ,因此helper = new Helper(); 语句不会被执行。线程B 取到的是helper 变量所含有的引用,也就是对线程A 所创立的Helper 实例的引用。
显然,线程A 和B 持有同一个Helper 实例,这是正确的。
画蛇添足的“双重检查”
但是,仔细审察上面的正确答案会发现,同步化实际上只在helper 变量第一次被赋值之前才有用。在helper 变量有了值以后,同步化实际上变成了一个不必要的瓶颈。如果能有一个方法去掉这个小小的额外开销,不是更加完美了吗?因此,就有了下面这个设计“巧妙”的双重检查成例。在读者向下继续读之前,有必要提醒一句:正如本小节的标题所标明的那样,这是一个反面教材,因为双重检查成例在Java 编译器里无法实现。
代码清单15:使用双重检查成例的懒汉式单例模式
// Broken multithreaded version // "Double-Checked Locking" idiom class Foo { private Helper helper = null; public Helper getHelper() { if (helper == null) //第一次检查(位置1) { //这里会有多于一个的线程同时到达 (位置2) synchronized(this) { //这里在每个时刻只能有一个线程 (位置3) if (helper == null) //第二次检查 (位置4) { helper = new Helper(); } } } return helper; } // other functions and members... } | 这是一个错误的例子,详情请见下面的解释。
对于初次接触双重检查成例的读者来说,这个技巧的思路并不明显易懂。因此,本节在这里给出一个详尽的解释。同样,这里假设线程A 和B 作为第一批调用者同时或几乎同时调用静态工厂方法。
(1) 因为线程A 和B 是第一批调用者,因此,当它们进入此静态工厂方法时,helper 变量是null。因此,线程A 和B 会同时或几乎同时到达位置1。
(2)假设线程A 会首先到达位置2,并进入synchronized(this) 到达位置3。这时,由于synchronized(this) 的同步化限制,线程B 无法到达位置3,而只能在位置2 等候。
(3)线程A 执行helper = new Helper() 语句,使得helper 变量得到一个值,即对一个Helper 对象的引用。此时,线程B 只能继续在位置2 等候。
(4)线程A 退出synchronized(this) ,返回Helper 对象,退出静态工厂方法。
(5)线程B 进入synchronized(this) 块,达到位置3,进而达到位置4。由于helper 变量已经不是null 了,因此线程B 退出synchronized(this),返回helper 所引用的Helper 对象(也就是线程A 所创建的Helper 对象),退出静态工厂方法。
到此为止,线程A 和线程B 得到了同一个Helper 对象。可以看到,在上面的方法
getInstance() 中,同步化仅用来避免多个线程同时初始化这个类,而不是同时调用这个静态工厂方法。如果这是正确的,那么使用这一个成例之后,“ 懒汉式”单例类就可以摆脱掉同步化瓶颈,达到一个很妙的境界。
代码清单16:使用了双重检查成例的懒汉式单例类
public class LazySingleton { private static LazySingleton m_instance = null; private LazySingleton() { } /** * 静态工厂方法 */ public static LazySingleton getInstance() { if (m_instance == null) { //More than one threads might be here!!! synchronized(LazySingleton.class) { if (m_instance == null) { m_instance = new LazySingleton(); } } } return m_instance; } } 这是一个错误的例子,请见下面的解释。
第一次接触到这个技巧的读者必定会有很多问题,诸如第一次检查或者第二次检查可不可以省掉等。回答是:按照多线程的原理和双重检查成例的预想方案,它们是不可以省掉的。本节不打算讲解的原因在于双重检查成例在Java 编译器中根本不能成立。
双重检查成例对Java 语言编译器不成立
令人吃惊的是,在C 语言里得到普遍应用的双重检查成例在多数的Java 语言编译器里面并不成立[BLOCH01, GOETZ01, DCL01] 。上面使用了双重检查成例的“懒汉式”单例类,不能工作的基本原因在于,在Java 编译器中,LazySingleton 类的初始化与m_instance 变量赋值的顺序不可预料。如果一个线程在没有同步化的条件下读取m_instance 引用,并调用这个对象的方法的话,可能会发现对象的初始化过程尚未完成,从而造成崩溃。
文献[BLOCH01] 指出:一般而言,双重检查成立对Java 语言来说是不成立的。
给读者的一点建议
有很多非常聪明的人在这个成例的Java 版本上花费了非常多的时间,到现在为止人们得出的结论是:一般而言,双重检查成例无法在现有的Java 语言编译器里工作[BLOCH01, GOETZ01, DCL01] 。
读者可能会问,是否有可能通过某种技巧对上面的双重检查的实现代码加以修改,从而使某种形式的双重检查成例能在Java 编译器下工作呢?这种可能性当然不能排除,但是除非读者对此有特别的兴趣,建议不要在这上面花费太多的时间。
在一般情况下使用饿汉式单例模式或者对整个静态工厂方法同步化的懒汉式单例模式足以解决在实际设计工作中遇到的问题。
|