作为对象的创建模式,单例模式确保某一个类只有一个实例,而且自行实例化并向整个系统提供这个实例。与单例模式对应的,多例模式中的多例类可以有多个实例,多例类也必须自己创建、管理自己的实例,并向外界提供自己的实例。本文会着重探讨单例模式 ,并把多例模式也介绍一下。单例类看似简单,实则暗藏了很多坑,稍不注意就会出错。
单例模式的特点
单例模式的要点有三个:
(1)单例类只能有一个实例,将构造方法设为private,保证外部无法实例化该类,同时把它设为静态变量;
(2)它必须自行创建这个实例,在类的内部创建,且只创建一次;
(3)它必须自行向整个系统提供这个实例,提供一个静态的工厂方法,返回该类的实例。
从以上的描述中,我们已经可以初见单例模式的端倪。单例模式又分成饿汉式单例模式,懒汉式单例模式,登记式单例模式三种,下面我们一一道来。
1.饿汉式单例类
从单例模式的特点中,我们知道在单例类中定义一个静态域,类型就是这个类本身。饿汉式是一种很形象的叫法,就好像这个类很急迫,不管现在是否需要这个实例,在定义静态变量的定义处就把它初始化,提前初始化。
/** * 饿汉式单例模式 * 在类加载的初始化阶段,instance会被初始化,单从资源利用角度来看,稍差 * 线程安全的 * @author cxy */ public class EagerSingleton { private static final EagerSingleton instance = new EagerSingleton(); //私有构造方法,保证外界无法直接实例化 private EagerSingleton() { //初始化操作 } //静态工厂方法,线程安全 public static EagerSingleton getInstance() { return instance; } }
饿汉式单例类是线程安全的,但是它在一定程度上造成了浪费,尤其是某些框架里的工厂类,它可能会持有很多类的实例,如果使用饿汉式,那么 会造成加载代价太大。由此,又引出了懒汉式单例类。
2.懒汉式单例类
懒汉式单例类就是采用延迟初始化,延迟到需要域的值时才将它初始化,如果永远不需要这个值,那么这个域就永远不需要初始化。
/** * 懒汉式单例模式,使用延迟初始化 * instance直到使用时才初始化 * 在多线程环境下,必须同步getInstance()方法 */ public class LazySingleton { private static LazySingleton instance = null; //私有的构造方法,保证外界无法直接实例化 private LazySingleton() { System.out.println("只能有一个实例"); } /*必须得是synchronized,否则在多线程下会出现多个实例*/ public synchronized static LazySingleton getInstance() { if(instance == null) { //在非同步的情况下,为了看到多于一个的线程在创建实例 Thread.yield(); try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } instance = new LazySingleton(); } return instance; } }
一定要注意,getInstance()是一个同步方法,这是为了保证在多线程下,该类仍然只有一个实例。如果不是同步方法会发生什么呢?考虑下面的情况:
(1)线程1调用getInstance(),它首先检查instance是否为空,也就是执行这句话:if(instance == null),一看确实是空的,准备创建吧,恰在这时,它让出了CPU;
(2)线程2也调用getInstance(),它也检查instance是否为空,结果为空,创建实例,现在该类有了第一实例了,完成,返回;
(3)线程1又开始运行了,它该执行这句话了:instance = new LazySingleton();又创建了该类的一个实例,这样它就有两个实例了,这已经不符合要求了。
所以,如果getInstance()必须得同步。但是,创建仅仅只发生了一次,更多的行为是返回这个实例,把getInstance()变成同步方法后,这样每次只能有一个线程调用该方法。为了一个单一行为,却牺牲了常态行为的性能,这样好吗?(注意,如果这个类是不可变的,那么即使getInstance()不同步,它也是安全的)。
双重成例检查
我们分析一下getInstance()方法:其实我们真正要同步的仅仅只是这一句话:instance = new LazySingleton()。明确了这一点后,看下面的代码
if(instance == null) {//第一次检查 synchronized(LazySingleton.class) { if(instance == null) {//第二次检查 instance = new LazySingleton(); } } }这一段理解起来也没有那么难:
(1)说先判断instance是否为空,若为空则去创建该实例;
(2)为了保证只有一个实例,创建语句需要同步,只有获得LazySingleton对应的Class对象的锁之后,才能去执行创建代码;
(3)终于获得了Class对象的锁,要去创建实例了。等一等,刚才可能已经有其他的线程创建了该实例,还是需要判断一下,实例是否为空,若为空,则说明我是第一个进入到同步块的线程,实例还没有创建,那么就让我一个人在毫无打扰的环境下工作吧;若不为空,则说明已经有其他线程创建过实例了,我还是乖乖的享用别人的成果吧。
这样只有第一次创建实例的时候才需要同步,获取实例的时候,由于实例不为空,根本不用进入同步块,这效率,唰唰唰。多么天才的一个想法!接下来,我知道了,这个想法并不是我创造的,之前的人早就使用了,这就是在C语言中大名鼎鼎的双重成例检查(DCL,Double-Checked Locking),如名所示,进行了两次是否为空的检查。可是,双重成例检查在Java中不可行,它已经臭名昭著了,只是他们的解释,着实让人看不懂!
在《Java concurrency in practice》的16.2中,和《Effective Java 2th Edition》第七十一条中,作者提到了真正的原因:在Java 1.5之前的版本中,由于volatile修饰符的语义不够强,双重检查模式的功能很不稳定。他们是JMM规范的制定者,我觉得他们的话确凿无疑的可信。在JMM的后续版本(1.5及以后)中,将instance声明为volatile,那么就能安全的启用DCL了。之所以会出现DCL,是因为JVM在无竞争同步的执行速度很慢,经过优化后,这些都不是问题了,所以使用双重成例检查并不是一种高效的优化措施。
《Effective Java》的第71条:慎用延迟加载。在大多数情况下,正常的初始化要优于延迟初始化。经过衡量之后,必须采用延迟初始化,分两种情况:首先是实例域,采用volatile配合DCL(1.5及以后版本)。
/** * 在Java 5.0之后的版本中,使用volatile和DCL可以解决 * 多线程环境下,实例域的延迟初始化 */ public class VolatileAndDCL { private volatile VolatileAndDCL instance; //私有构造方法 private VolatileAndDCL() { //执行初始化 } /* * 使用双重成例检查,必须配合volatile使用 * 只有第一次创建实例的时候需要进入同步块 */ public VolatileAndDCL getInstance() { //使用局部变量result可以提高性能(参考数据:25%),而且更加优雅 VolatileAndDCL result = instance; //第一次检查,只有为空时,才进入同步块 if(result == null) { synchronized(VolatileAndDCL.class) { //第二次检查,如果仍为空,创建 if(result == null) instance = result = new VolatileAndDCL(); } } return result; } }
对局部变量result的使用让人感到困惑,作者解释到使用局部变量result可以提高性能,而且更加优雅。注意,这里的field是实例域,当然了静态域也可以使用DCL,但是我们有更好的选择:lazy initialization holder class(也称作initialize-on-demand holder class idiom,延迟初始化站位类模式),这种模式使用一个静态内部类持有外围类静态域(也就是instance):
class StaticFieldSingleton { /** * 使用静态内部类来延迟初始化instance, * 在加载StaticFieldSingleton时,不会对FieldHolder初始化 * 直到读取field时才对FieldHolder初始化,也就是对instance初始化 */ private static class FieldHolder { static final StaticFieldSingleton instance = new StaticFieldSingleton(); } private StaticFieldSingleton() { //执行一些初始化操作 } //完全不需要同步 public static StaticFieldSingleton getInstance() { return FieldHolder.instance; } }
当加载StaticFieldSingleton类时,并不会对初始化FieldHolder类,只有第一次读取getInstance()时,FieldHolder类才会初始化。这种模式的魅力在于:首先它保证了延迟初始化的优点,同时getInstance()又没有被同步,也就没有增加任何访问开销。现代的VM在初始化FieldHolder类时,会同步对intance的访问,保证多线程下的初始化过程中的安全性。一旦这个类被初始化,VM将修补代码,以便后续对instance的访问不会导致任何测试或同步。这个思路,简直就简直了!
通过对懒汉式单例模式的分析,我们大致可以得出如下结论:慎用延迟初始化,必须经过测量类在用和不用延迟初始化时的性能差别之后,再做决定。如果是实例域的延迟初始化,那么就是用volatile配合DCL;如果是静态域的延迟初始化,使用lazy initialization holder class模式。
3.登记式单例模式
在单例模式中,要求构造方法是private,这样外部无法直接实例化该类的对象,但是构造方法是private,也就才造成了该类不可被继承的缺点。为了克服这个缺点,GoField又提出了登记式单例模式,在父类中使用一个map来持有所有子类的实例,也就是说:说有子类到父类的map中登记,当要获得该类实例时,去Map中获得该类的实例。
/** * 登记式单例类 * 是为了克服饿汉式和懒汉式均不能继承的缺点而设计的 * 子类的实例化只能是懒汉式 * @author cxy */ public class RegSingleton { //所有子类都要到registry中去登记 private static HashMap<String,RegSingleton> registry = new HashMap<String, RegSingleton>(); //饿汉式实例化 static { RegSingleton instance = new RegSingleton(); registry.put(instance.getClass().getName(), instance); } //为了让子类可以继承,不能为私有 protected RegSingleton() { } /** * 静态工厂方法,返回指定类的唯一实例。 * 首先去registry中查找是否有该类的实例,如有,直接返回。 * 如果没有,使用反射机制生成该类的实例,并放到registry中。 */ public static RegSingleton getInstance(String name) { //如果为空,则返回此类的实例 if(name == null) { name = "com.javapatterns.singleton.RegSingleton"; } /** * 如果返回的是null,说明这个子类没有登记过 * 则创建子类的实例,并且登记到registry中 */ if(registry.get(name) == null) { try { //在Java中,由于反射的存在,可以让子类的构造方法是私有的,就像下面注释的那样实现 //但这已经是语言的特性了,超出了设计模式的范畴了 //RegSingleton t_instance = (RegSingleton) Class.forName(name).getDeclaredConstructors()[0].newInstance(); registry.put(name, (RegSingleton) Class.forName(name).newInstance()); }catch(Exception e) { System.out.println("实例化对象失败"); } } return registry.get(name); } } /** * 子类需要父类的帮助才能实例化 */ class RegSingletonChild extends RegSingleton { public RegSingletonChild(){} /** * 静态工厂方法 */ public static RegSingletonChild getInstance() { return (RegSingletonChild)RegSingleton.getInstance("com.javapattens.singleton.RegSingletonChild"); } }
通过上面的代码,我们可以看出父类的构造方法不再是private的了,这是登记式单例模式的一个缺点。这个登记式单例模式是根据Java语言的特点来写的,还有其他的形式。
使用单例模式的条件
使用单例模式有一个必要条件:在一个系统要求一个类只有一个实例时才应当应用单例模式。反过来说,如果一个类可以有几个实例共存,那么就没有必要使用单例类。
模式学习总是简单,但是如何使用,在什么情况下,却是一个很需经验和技巧的事情。且学且珍惜吧!
上面介绍了单例模式的特点,三种类型的单例模式,以及各自的优缺点及注意事项,着重谈论了懒汉式单例模式的延迟初始化问题,这一部分已经超出了设计模式的范畴了,但是,我觉得仍然很有必须花时间去弄明白。一般情况下,饿汉式单例模式已经够使用了,如果需要延迟加载,本文也已经给出了完美的解决方案。由于单例模式内容很多,因此分成两篇来探讨,下一篇讲一下多例模式,以及单例模式和多例模式的一个实际应用。
----------------------------------------------------------------- 更新--------------------------------------------------------------------------------------
单元素的枚举
Java 1.5中提供了枚举类型,枚举类型是实现单例类的最佳方法,而且更加简洁。由于枚举类实现了Serializable接口,使用枚举实现的单例类可以直接享受枚举提供了的序列化机制,绝对多次实例化。使用枚举出了可以实现单例,还可以实现有限例,其情形与单例相似,给出示意性代码:
public enum Singleton { Instance(some args); private Singleton(some args) { //初始化 } //提供的方法 public void f() { } public XXX xxx(xxx) { } }
参考资料:《Java与模式》,《Effective Java 2th Edition》,《Java并发编程实战》,GoF《设计模式》。