设计模式之单例模式——对象创建型模式

前言

本文主要参考 那些年,我们一起写过的“单例模式”

何为单例模式?

顾名思义,单例模式就是保证一个类仅有一个实例,并提供一个访问它的全局访问点。通常我们可以让一个全局变量使得一个对象被访问,但它不能防止你实例化多个对象。一个最好的办法就是,让类自身负责保存它的唯一实例。这个类可以保证没有其他实例可以被创建,并且它可以提供一个访问实例的方法。

结构

单例模式结构图

适用情况

  1. 当类只能有一个实例而且客户可以从一个众所周知的访问点访问它时。
  2. 产生某对象会消耗过多的资源,为避免频繁地创建与销毁对象对资源的浪费。如对数据库的操作、访问IO、线程池、网络请求等。

单例模式的优缺点

  • 优点:可以减少系统内存开支,减少系统性能开销,避免对资源的多重占用、同时操作。
  • 缺点:扩展困难,容易引发内存泄露,测试困难,一定程度上违背了单一职责原则,进程被杀死时可能状态不一致问题。

单例的各种实现

单例模式按加载时机可以分为:饿汉模式和懒汉模式;按实现的方式有双重检查加锁方式,内部类方式、枚举方式以及通过Map容器来管理单例的模式。作为一个单例,首先要确保的就是实例的“唯一性”,但是有很多因素如多线程、序列化、反射、克隆等因素会导致“唯一性”失效。其中,多线程问题尤为突出。所以,我们应该保证无论是在单线程还是多线程下单例模式都是可以运行的。

  1. 懒汉式线程不安全方式

     public class Singleton{
         private static Singleton instance;
    
         //private的构造函数,只能在本类内部实例化
         private Singleton() {
         }
    
         //通过此静态方法提供全局获取唯一可用对象的实例
         public static Singleton getInstance(){
             if(instance == null){
                 instance = new Singleton();
             }
             return instance;
         }
     }
    

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

  1. 线程安全效率低方式

     public class Singleton {
         private static Singleton instance;
     
         //private的构造函数,只能在本类内部实例化
         private Singleton() {
         }
     
         //通过此静态方法提供全局获取唯一可用对象的实例
         public static synchronized Singleton getInstance() {
             //通过加上synchronized修饰符解决多线程不安全问题
             if (instance == null) {
                 instance = new Singleton();
             }
             return instance;
         }
     }
    

这种方式虽然解决了线程安全问题,但是这样迫使每个线程在进入这个方法之前,要先等待其他的线程离开该方法,即不会有两个线程同时进入此方法进行 new Singleton(),从而保证了单例的有效性。但是当每个线程每次执行getInstance()方法获取类的实例时,都会进行同步。而事实上当实例创建完成后,同步就变为不必要的开销了,这样做在高并发下必然会拖垮性能。

  1. 同步代码块方式

     public class Singleton {
         private static Singleton instance;
     
         //private的构造函数,只能在本类内部实例化
         private Singleton() {
         }
     
         //通过此静态方法提供全局获取唯一可用对象的实例
         public static Singleton getInstance() {
             if (instance == null) {
                 //仅同步实例化的代码块
                 synchronized (Singleton.class){
                     instance = new Singleton();
                 }
             }
             return instance;
         }
     }
    

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

  1. 双重检查锁定(Double Check Lock(DCL))方式

     public class Singleton {
         //注意此处的volatile修饰符
         //Java编译器允许处理器乱序执行,会有DCL失效的问题
         //JDK大于等于1.5的版本,具体化了volatile关键字,定义时加上它可以保证执行的顺序(虽然会影响性能)
         //从而单例起效
         private static volatile Singleton instance;
     
         //private的构造函数,只能在本类内部实例化
         private Singleton() {
         }
     
         //通过此静态方法提供全局获取唯一可用对象的实例
         public static Singleton getInstance() {
             if (instance == null) {
                 //第一次check,避免不必要的同步
                 synchronized (Singleton.class) { //同步
                     if (instance == null) {
                         //第二次check,保证线程安全
                         instance = new Singleton();
                     }
                 }
             }
             return instance;
         }
     }
    

此方法的“Double-Check”体现了两次 if(instance == null)的检查,这样既同步代码块保证线程安全,同时实例化的代码只会执行一次,实例化后同步不会再被执行,从而提高效率。
双重检查锁定(DCL)方式也延迟加载的,它唯一的问题是Java编译器允许处理器乱序执行,在JDK版本低于1.5会有DCL失效的问题。在版本大于等于1.5的JDK上,只需在定义单例时加上volatile关键字,即可保证执行的顺序,从而使单例起效。

  1. 延迟加载的静态内部类

     public class Singleton {
     
         private Singleton(){}
     
         public static final Singleton getInstance(){
             return SingletonHolder.INSTANCE;
         }
     
         private static class SingletonHolder{
             private static final Singleton INSTANCE = new Singleton();
         }
     }
    

静态内部类利用了classloader的机制来保证初始化 instance 时只会有一个,这是因为虚拟机会保证一个类的 <clinit>() 方法在多线程环境中被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>方法,其他线程都需要阻塞等待,直到活动线程执行 <clinit>() 方法完毕。如果在一个类的 <clinit>() 方法中有耗时很长的操作,就可能造成多个线程阻塞,这在实际应用中往往是很隐蔽的。需要注意的是,其他线程虽然会被阻塞,但如果执行 <clinit>() 方法的那条线程退出 <clinit>() 方法后,其他线程唤醒之后不会再次进入 <clinit>() 方法。同一个类加载器下,一个类型只会初始化一次。

需要注意的是,虽然它的名字中有“静态”两字,但它属于“懒汉模式”的。这种方式的Singleton类被加载时,因为内部静态类是要在有引用了之后才会装载进内存,所以在第一次调用 getInstance()之前,换言之,只要 SingletonHolder 类还没有被主动使用,instance 就不会被初始化。只有在显示调用getInstance()方法时,产生了对SingleHolder的引用才会加载SingltonHolder类,从而实例化对象。

  1. 饿汉加载方式

     //优点是比较简洁
     public class Singleton{
         //注意这里用的是public而不是private,因此无需getInstance()方法,可以直接
         //拿到instance实例
         //此方法的final关键词来确保每次返回的都是同一个对象的引用,私有的构造方法函数
         //也只会被调用一次
         private Singleton(){}
         public static final Singleton instance = new Singleton();
     }
    
     //Singleton with static factory
     //现代的JVM基本都内嵌了对static factory
     //方法的调用,使得第一种public field方式不再有优势
     //此方法更灵活,只需修改getInstance的返回逻辑,而不需要
     //改变API就可以将类改为非单例类
     public class Singleton {
         private Singleton(){}
         private static final Singleton instance = new Singleton();
         public static Singleton getInstance(){
             return instance;
         }
     }
    
     public class Singleton {
         private Singleton() {
         }
     
         private static Singleton instance = null;
         static {
             instance = new Singleton();
         }
         
         public static Singleton getInstance(){
             return instance;
         }
     }
    
      public class Singleton {
         private Singleton() {
         }
    
         private static Singleton instance = null;
         static {
             instance = new Singleton();
         }
    
         public static Singleton getInstance(){
             return instance;
         }
     }
    

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

  1. 枚举方式 关于枚举

     public enum  Singleton {
         INSTANCE;
         //枚举同Java中的普通Class一样,能够有自己的成员变量和方法
         public void doSomething(){
             System.out.println("Do whatever you want");
         }
     }
    

枚举类型时有“实例控制”的类,确保了不会同时有两个实例,即当且仅当 a=b 时 a.eaquals(b) ,用户也可以用 == 操作符来代替 equals(Object) 方法来提供效率。使用枚举来实现单例还可以不用getInstance()方法(当然,如果你想要适应大家的习惯用法,加上 getInstance()方法也是可以的),直接通过Singeton.INSTANCE 来拿取实例。枚举类是在第一次访问时才被实例化,是懒加载的。它写法简单,并确保了在任何情况下(包括反序列化,反射,克隆)下都是一个单例。不过枚举是在JDK1.5之后才加入的特性。

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

  1. 除了多线程,序列化也可能破坏单例模式一个实例的要求。二是实现对象数据的远程传输。当单例对象有必要实现Serializable接口时,即使将其构造函数设为私有,在它反序列化时依然会通过特殊的途径再创建类的一个新的实例,相当于调用了该类的的构造函数有效地获得除了一个新的实例。

     public class Singleton implements Serializable{
         private static Singleton instance = new Singleton();
         private Singleton(){}
         public static Singleton getInstance(){
             return instance;
         }
     
         public static void main(String[] args) {
             Singleton instance1 = Singleton.getInstance();
             Singleton instance2 = Singleton.getInstance();
             System.out.println("normal:" + (instance1 == instance2));
             try {
                 //序列化
                 File file = new File("tt.txt");
                 ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(file));
                 oos.writeObject(instance1);
                 oos.close();
                 //反序列化
                 ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file));
                 Singleton instance3 = (Singleton) ois.readObject();
                 System.out.println("deserialize:" + (instance1 == instance3));
             } catch (FileNotFoundException e) {
                 e.printStackTrace();
             } catch (IOException e) {
                 e.printStackTrace();
             } catch (ClassNotFoundException e) {
                 e.printStackTrace();
             }
         }
     }
    

输出如下:
normal:true
deserialize:false

要比避免单例对象在反序列化重新生成对象,则在implements Serializable的同时应该实现readResolve()方法,并在其中保证反序列化的时候获得原来的对象。
(注:readResolve()是反序列化操作提供的一个很特别的钩子函数,它在从流中读取对象的readObject(ObjectInputStream)方法之后被调用,可以让开发人员控制对象的反序列化。

    public class Singleton implements Serializable{
            ......
            private Object readResolve(){
                return instance; //默认返回 instance 对象,而不是重新生成一个新的对象
            }
            ......
    }

单例有效。

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

public class Singleton {
        
            private static Singleton instance = new Singleton();
        
            private Singleton() {
            }
        
            public static Singleton getInstance() {
                return instance;
            }
        
            public static void main(String[] args) {
                Singleton getInstance1 = Singleton.getInstance();
                Singleton getInstance2 = Singleton.getInstance();
                System.out.println("Is singleton pattern normally valid: " + (getInstance1 == getInstance2));
                try {
                   /* Class<Singleton> clazz = (Class<Singleton>) Class.forName("com.designpatterns.Singleton");
                    Constructor<Singleton> constructor = clazz.getConstructor(null); //获得无参构造函数*/
                    Constructor<Singleton> constructor = Singleton.class.getDeclaredConstructor();
                    constructor.setAccessible(true); //跳过权限检查,可以访问私有的构造函数
                    Singleton refInstance3 = constructor.newInstance();
                    System.out.println("Is single pattern valid for Reflection: " + (getInstance1 == refInstance3));
                } catch (Exception e) {
                    e.printStackTrace();
                }
        
            }
        
        }

将会打印:

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

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

    public class Singleton{
            ......
            private Singleton(){
                    //也可以在这里使用 flag 或者 计数器 count 来判断
                    if(null  != instance){
                            throw new RuntimeException("Cann't construct a Singleton more than once!");
                    }
            }
    }

同反序列化相似,枚举的方式也可以杜绝反射的破坏。当我们通过反射方式来创建枚举类型的实例时,会抛出异常。

  1. 克隆
    clone()是 Object 的方法,每一个对象都是 Object 的子类,都有 clone() 方法。 clone() 方法并不是调用构造函数来创建对象,而是直接拷贝内存区域。因此当我们的单例对象实现了 Cloneable 接口时,尽管其构造函数时私有的,仍可以通过克隆来创建一个新对象,单例模式也相应的失效了。
public class Singleton implements Cloneable{
        private static Singleton instance = new Singleton();
        private Singleton(){}
        public static Singleton getInstance(){
            return instance;
        }
    
        public static void main(String[] args) {
            Singleton getInstance1 = Singleton.getInstance();
            Singleton getInstance2 = Singleton.getInstance();
            System.out.println("Is singleton pattern normally valid: " + (getInstance1 == getInstance2));
            try {
                Singleton cloneInstance3 = (Singleton) getInstance1.clone();
                System.out.println("Is singleton pattern valid for clone: " + (getInstance1 == cloneInstance3));
            } catch (CloneNotSupportedException e) {
                e.printStackTrace();
            }
        }
    }

输出为:

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

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

    public class Singleton implements Cloneable{
            ......
            @Override
            protected Object clone() throws CloneNotSupportException{
                    throw new CloneNotSupportException();
            }
            ......
    }

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

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

采用Map容器来统一管理这些单例,使用时通过统一的接口来获取某个单例。将一组单例类型注入到一个统一的管理类中来维护,即将这些实例存放在一个Map登记簿中,在使用时则根据 key 来获取对象对应类型的单例对象。对于已经登记过的实例,从 Map 直接返回实例;对于没有登记的,则先登记再返回。从而在对用户隐藏具体实现、降低代码耦合度的同时,也降低了用户的使用成本。简易版代码实现如下

public class SingletonManager {
    private static Map<String,Object> objectMap = new HashMap<>();
    private SingletonManager(){};

    public static void registerService(String key,Object instance){
        if(!objectMap.containsKey(key)){
            objectMap.put(key,instance);
        }
    }
    
    public static Object getService(String key){
        return objectMap.get(key);
    }
}

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

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

  1. 还有其他情况会使单例模式失效吗?
    上述的所有讨论都是基于一个类加载器(class loader)的情况。由于每个类加载器有各自的命名空间, static 关键词的作用范围也不是整个 JVM,而之到类加载器,即不同的类加载器可以加载同一个类。所以当一个工程下面存在不止一个类加载器时,整个程序中同一个类就可能被加载多次,如果这是个单例类就会产生多个单例并存失效的现象,并要指定同一个类加载器。

  2. 单例的构造函数时私有的,那还能不能继承单例?
    单例是不适合被继承的,要继承单例就要将构造函数改成公开的或受保护的(仅考虑Java中的情况),这就会导致:
    1)别的类也可以实例化它了,无法确保保实例“独一无二”,这显然有违单例的设计理念。
    2)因为单例的实例是使用的静态变量,所有的派生类事实上是共享同一个实例变量的,这种情况下要想让子类们维护正确的状态,顺利工作,基类就不得不实现注册表功能了。

  3. 单例有没有违反“单一责任原则”?
    单例确实承担了两个责任,它不仅仅负责管理自己的实例并提供全局访问,还要处理应用程序的某个业务逻辑。但是有类来管理自己的实例的方式可以让整体设计更简单易懂。
    当然在代码繁复的情况下优化你的设计,让单例类专注于自己的业务责任,将它的实例化以及对对象个数的控制封装在一个工厂类或生成器中,也是较好的解决方法。

  4. 是否可以把一个类的所有方法和变量都定义为静态的,把此类直接当作单例来使用?
    事实上在最开始讨论过的,Java里的 java.lang.System 以及 java.lang.Math 类都是这么做的,它们的全部方法都用 static 关键词修饰,包装起来提供类级访问。可以看到,Math 类的把 java 基本类型值运算的相关方法组织了起来,当我们调用 Math 类的某个类方法时,所要做的都只是数据操作,并不涉及到对象的状态,对这样的工具类来说实例化是没有任何意义的。
    静态方法会比一般的单例更快,因为静态的绑定是在编译期就进行的。但是也要注意到,静态初始化的控制权完全握在 Java 手上,当涉及到很多类时,这么做可能会一起一些微妙而不易察觉的,和初始化次序有关的bug。除非绝对必要,确保一个对象只有一个实例,会比类只有一个单例更保险。

  5. 考虑技术实现时,如何从单例模式和全局变量中作出选择?
    全局变量虽然使用起来比较简单,但对于单例有如下缺点:
    1) 全局变量只是提供了对象的全局静态引用,但并不能确保只有一个实例。
    2) 全局变量时急切实例化的,在程序一开始就创建好对象,对非常耗费资源的对象,或是程序执行过程中一直没有用到的对象,都会形成浪费。
    3) 静态初始化时可能信息不完全,无法实例化一个对象。即可能需要使用到程序中稍后计算出来的值才能创建单例。
    4) 使用全局变量容易造成命名空间污染。

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

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

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

  1. 在 Android 中使用单例还有哪些需要注意的地方
    单例在 Android 中的生命周期等于应用的生命周期,所以要特别小心它持有的对象是否会造成内存泄露,所以最好仅传递给单例 Application Context。

附录

双重检查锁定(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 内存模型中 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] opalli. 那些年,我们一起写过的 “单例模式”[EB/OL]. [2017-03-09]. http://mp.weixin.qq.com/s/wEK3UcHjaHz1x-iXoW4_VQ.
[2] Erich Gamma,Richard Helm,Ralph Johnson,John Vlissides. 设计模式:可复用面向对象软件的基础[M]. 李英军等译.北京:机械工业出版社,2009.
[3] 程杰. 大话设计模式[M]. 北京 : 清华大学出版社 , 2007.

推荐阅读更多精彩内容