Skip to content

软件设计模式 - 单例模式

Published: at 01:23 PM

模式简介

某些时候整个系统只需要拥有一个全局对象,却有利于协调系统整体的行为。比如在某个服务器程序中,该服务器的配置信息存放在一个文件中,这些配置数据由一个单例对象统一读取,然后服务进程中的其他对象再通过这个单例对象获取这些配置信息,这种方式简化了在复杂环境下的配置管理。 单例类存在多种实现方式,较为普遍的实现方式为饿汉式单例类与懒汉式单例类。其中饿汉式单例类在被加载时就将自己实例化。单从资源利用效率角度来讲,饿汉式单例类比懒汉式单例类稍差些。从速度和反应时间角度来讲,饿汉式单例类则比懒汉式单例类稍好些。懒汉式单例类在实例化时,必须处理好在多个线程同时首次引用此类时的访问限制问题,特别是当单例类作为资源控制器,在实例化时必然涉及资源初始化,而资源初始化很有可能耗费大量时间,这意味着出现多线程同时首次引用此类的机率变得较大,需要通过同步化机制进行控制。具体的单例模式实现方式会在后文重点讨论。 单例模式的优点在于可以严格控制客户怎样以及何时访问它,为设计及开发团队提供了共享的概念。同时由于在系统内存中只存在一个对象,因此可以节约系统资源,对于一些需要频繁创建和销毁的对象,单例模式无疑可以提高系统的性能。然而,由于单例模式中没有抽象层,因此单例类的扩展有很大的困难。单例类的职责过重,在一定程度上违背了“单一职责原则”。至于单例模式相关的设计原则会在后文中逐一列举。 单例模式应用十分广泛,如需创建一个对象需要耗费大量时间与空间资源时,如IO,数据库连接等,再如需要生成唯一id、静态类型的的工具类等情形也需要使用单例模式。

设计原则

与单例设计模式相关的有以下OOP设计原则。

实现方法

单例模式的实现需以下三个部分:

图3.1 单例模式的类图

在所有常见的设计模式中,singleton模式是唯一一个能够用短短几十行代码完成实现的模式,接下来以不同的例子探讨单例模式的解法。

  1. 只适用于单线程模式的解法(懒汉式) 由于单例模式要求只能生成一个实例,因此我们必须把构造函数设为私有函数以禁止他人创建实例。可以通过定义一个静态的实例,在需要的时候创建该实例。下面定义类型Singleton1就是基于这个思路的实现:
package com.singleton;

public class Singleton1 {
    private Singleton1() {
    }

    private static Singleton1 instance = null;

    public static Singleton1 getInstance() {
        if (instance == null) {
            instance = new Singleton1();
        }
        return instance;
    }
}

代码3.1 只适用于单线程模式的解法 上述代码只有在instance为null时才创建一个实例从而避免重复创建,同时我们把构造函数定义为私有函数从而确保只创建一个实例。该代码仔单线程的时候工作正常,但是在多线程的情况下就会出现问题。设想如果两个线程同时运行到判断instance是否为null的if语句,并且instance的确没有创建时,那么两个线程都会创建一个实例,此时类型Singleton1就不再满足单例模式的要求,可以通过以下解法改变该局面。

  1. 适用于多线程的解法(效率欠佳的懒汉式) 为了保证在多线程环境下还是只能得到类型的一个实例,需要加上一个同步锁。假设有两个线程同时想创建一个实例。由于在一个时刻只有一个线程能得到同步锁,当第一个线程加上锁时,第二个线程只能等待。当第一个线程发现实例还没有创建时,它创建出一个实例。接着第一个线程释放同步锁,此时第二个线程可以加上同步锁,并运行接下来的代码。这个时候由于实例已经被第一个线程创建出来了,第二个线程就不会重复创建实例了,这样就保证了在多线程环境中也只能得到一个实例。把Singleton1稍做修改得到了如下代码:
package com.singleton;

public class Singleton2 {
    private Singleton2() {
    }

    private static Singleton2 instance = null;

    public static synchronized Singleton2 getInstance() {
        if (instance == null) {
            instance = new Singleton2();
        }
        return instance;
    }
}

代码3.2 适用于多线程的低效率解法 但是 Singleton2 还不是很完美。我们每次通过 getInstance 方法得到Singleton2的实例,都会试图加上一个同步锁,而加锁是一个非常耗时的操作,在没有必要的时候应该尽量避免。

  1. 加同步锁前后两次判断实例是否已存在的解法(DCL) 在实例还没有创建之前需要加锁操作,以保证只有一个线程创建出实例。然而当实例已经完成创建之后,已经不需要再做加锁操作了。Singleton3中只有当instance为null即没有创建时,需要加锁操作。当instance 已经创建出来之后,则无须加锁。参考以下改进后的Singleton2代码,其时间效率能够比Singleton2提升很多。其中instance被volatile 修饰,增加线程之间的可见性,并且任何线程对它的写操作都会立即刷新到主内存中,并且会强制让缓存了instance变量的线程中的数据清空,必须从主内存重新读取最新数据。
package com.singleton;

public class Singleton3 {
    private volatile static Singleton3 instance;

    private Singleton3() {
    }

    public static Singleton3 getInstance() {
        if (instance == null) {
            synchronized (Singleton3.class) {
                if (instance == null) {
                    instance = new Singleton3();
                }
            }
        }
        return instance;
    }
}

代码3.3 加同步锁前后两次判断实例是否已存在的解法 Singleton3用加锁机制来确保在多线程环境下只创建一个实例,并且用两个 if 判断来提高效率。这样的代码实现起来比较复杂,容易出错,然而还有其他解法。

  1. 利用静态构造函数的解法(饿汉式) 静态构造函数的实现代码非常简洁。因为单例对象只创建一次,所以考虑使用 static 修饰,这样在 JVM 加载该类的时候就会自动创建对象,又因为不希望其他类执行该单例类的构造方法再去创建单例对象,所以把构造函数的属性设置为 private。效果为在调用静态构造函数时初始化静态变量,确保只调用一次静态构造函数,从而保证只初始化一次instance。
package com.singleton;

public class Singleton4 {
    private Singleton4() {
    }

    private static final Singleton4 instance = new Singleton4();

    public static Singleton4 getInstance() {
        return instance;
    }
}

代码3.4 利用静态构造函数的解法 假设我们在Singleton4 中添加一个静态方法,调用该静态函数是不需要创建一个实例的,但如果按照Singleton4的方式实现单例模式,则仍然会过早地创建实例,从而降低内存的使用效率。

  1. 利用静态内部类的解法 静态内部类的优点为,外部类加载时不需要立即加载内部类,内部类不被加载则不去初始化instance,即不会在内存中占据位置。如下巧妙运用了这种方法,即第一次调用getInstance() 方法使得JVM加载SingletonStatic类,从某种程度上而言实现了按需创建实例。
package com.singleton;

public class Singleton5 {
    private Singleton5() {
    }

    private static class SingletonStatic {
        private static Singleton5 instance = new Singleton5();
    }

    public static Singleton5 getInstance() {
        return SingletonStatic.instance;
    }
}

代码3.5 利用静态内部类的解法 当Singleton5类第一次被加载时,并不需要立即加载SingletonStatic内部类,只有当 getInstance() 方法第一次被调用时,才会初始化instance对象。这种方法不仅能确保线程安全,也能保证单例的唯一性,同时也延迟了单例的实例化。

  1. 利用枚举的解法 引用 《Effective Java》书中的一句话,“单元素的枚举类型已经成为实现Singleton的最佳方法。”这种解法利用枚举的特性保证了按需加载、线程同步。
package com.singleton;

public enum Singleton6 {
    instance;

    public void testMethod() {
    }
}

代码3.6 利用枚举类型的解法 目前该实现方式还没有被广泛采用,但它更简洁,自动支持序列化机制,绝对防止多次实例化。它不仅能避免多线程同步问题,而且还自动支持序列化机制,防止反序列化重新创建新的对象,绝对防止多次实例化。不过,由于 JDK1.5 之后才加入 enum 特性,用这种方式写不免让人感觉生疏。

  1. 解法总结 以简洁明了的表格总结前文中实现的六种单例模式的解法,对于不同解法的参考指标为是否懒加载、是否容易实现、是否线程安全以及是否高效率。
实现方法解法一解法二解法三解法四解法五解法六
懒加载
易实现
线程安全
高效率

表格3.1 解法总结表

一般情况下不建议使用第一种和第二种懒汉解法,第三种解法补足了前两者的短板然而编写逻辑较为复杂,第四种解法虽然没有实现懒加载的效果但仍为比较通用的解法,静态内部类的解法能够实现按需加载不失为一种优秀的算法,而如果涉及到反序列化创建对象时,可以尝试使用最后一种枚举类型的算法。

模式简例

在JDK内部也存在对单例模式的运用。Runtime类就是十分典型的例子。

图4.2 Runtime类图 在每一个Java应用程序中,都有唯一的一个Runtime对象,通过这个对象应用程序可以与其运行环境发生相互作用。Runtime类提供私有的静态的Runtime对象 currentRuntime、私有的空Runtime构造方法以及一个静态工厂方法getRuntime(),通过调用getRuntime()方法,可以获得Runtime类唯一的一个实例,并且从源代码代码中可以看出,Runtime使用了饿汉式单例模式。

package java.lang;
public class Runtime {
    private static final Runtime currentRuntime = new Runtime();
public static Runtime getRuntime() {
    return currentRuntime;
    }
    private Runtime() {}

}

代码4.3 Runtime源代码 前文中有提及,单例模式还可以用于生成唯一id 的情形,这里以前文中的Singleton4 实现方式为例。通过创建Main主类,并且在该类的main()方法中,创建2个Singleton4对象,获取对象的hashCode。

package com.singleton;

public class Main {
    public static void main(String[] args) {
        System.out.println("创建单例对象1:");
        Singleton4 singleton1 = Singleton4.getInstance();
        System.out.println(singleton1.hashCode());
        System.out.println("创建单例对象2");
        Singleton4 singleton2 = Singleton4.getInstance();
        System.out.println(singleton2.hashCode());
    }
}

代码4.5 测试类 运行结果符合预期,测试类通过getInstance()方法获得的是同一对象,因而哈希值是一致的。此特点适用于生产唯一序列号的场景。

图4.3 测试结果 对于单例模式的应用与验证至此告一段落,显然单例模式的运用远不止于此,相关的还有Web计数器、数据库配置文件等等。

问题与缺陷

滥用单例可能带来一些负面问题,如为了节省资源将数据库连接池对象设计为单例类,可能会导致共享连接池对象的程序过多而出现连接池溢出。在Java语言中,连接池采用持久化服务的方式,滥用单例将导致连接得不到释放,内存不断上升从而溢出。 JVM提供了自动垃圾回收的机制,并且采用根搜索算法,其基本思路为:任何“活”的对象一定能最终追溯到其存储在堆栈或静态存储区中的引用。通过一系列根(GC Roots)的引用作为起点开始搜索,经过一系列的路径,如果可以到达java堆中的对象,那么这个对象就是不可回收的。可以作为根的对象有:

方法区是JVM的一块内存区域,用来存放类相关的信息。java中单例模式创建的对象被自己类中的静态属性所引用,符合第二条,因此,单例对象不会被JVM垃圾收集。虽然JVM堆中的单例对象不会被垃圾收集,但是单例类本身如果长时间不用会不会被收集呢?因为JVM对方法区也是有垃圾收集机制的。如果单例类被收集,那么堆中的对象就会失去到根的路径,必然会被垃圾收集掉。 通过以下代码测试单例对象是否会被回收。

package com.singleton;

class Singleton {
    private byte[] test = new byte[6*1024*1024];
    private static Singleton singleton = new Singleton();
    private Singleton(){}

    public static Singleton getInstance(){
        return singleton;
    }
}
class Obj {
    private byte[] test = new byte[3*1024*1024];
}

public class Client{
    public static void main(String[] args) throws Exception{
        Singleton.getInstance();
        while(true){
            new Obj();
        }
    }
}

代码5.1 测试代码 运行时JVM 的参数被设定为: -verbose:gc -Xms20M -Xmx20M 即每次JVM进行垃圾回收时显示内存信息,JVM的内存设为固定20M。 通过模拟J2EE容器,实例化大小为6M的单例类,然后不断的创建对象,迫使JVM进行垃圾回收,观察垃圾收集信息,如果进行垃圾收集后,内存仍然大于6M,则说明垃圾回收不会回收单例对象。

图5.1 测试结果 从运行结果中可以看到有6M空间没有被收集。达到GC的条件其一为该类所有的实例都已经被回收,即java堆中不存在该类的任何实例。单例的类不满足该条件,因此单例类也不会被回收。也就是说,只要单例类中的静态引用指向JVM堆中的单例对象,那么单例类和单例对象都不会被垃圾收集。所以“如果实例化的对象长时间不被利用,系统会认为它是垃圾,会自动销毁并回收资源,下次利用时又将重新实例化,这将导致对象状态丢失。”这一点目前无法在实验中验证。 至于违背了单一职责原则这一点前文已有讨论,不复引述。

参考资料