单例模式解析

单例模式

Posted by Anriku on March 28, 2018

什么是单例模式以及单例模式的好处

单例顾名思义就是让一个类只能创建单个实例。生活中有很多这样的例子。比如说:一个国家只能有一个国家领导,一个公司只能有一个CEO。这些都是只能有一个实例对象的。单例模式不仅用于实际的情况,在下面两种情况也可以用单例来提高程序的性能:

  • 对于一些重量级的类,而且我们不需要new出不同对象的需求。我们可以用单例模式来提高性能。
  • 对于一般不需要new出不同的对象的类,我们也可以运行单例模式。这样的话,我new的次数少了,就不会在堆上频繁的创建对象。减轻了GC的负担。

饿汉式

//饿汉式
public class Singleton{
    private static final Singleton instance = new Singleton;
    private Singleton(){
    }
    
    public static Singleton getInstance(){
        return instance;
    }
}

从代码我们可以看到Singleton类的构造器用private对外界进行了隐藏,因此我们无法在外界通过new来创建对象。既然不给咱们构造器,那么肯定要给我们一个静态方法(注意是静态方法,不是实例方法,原因嘛仔细想想就明白)来获取对象啦!这种方法也是大多数单例模式(如:懒汉式、Double Check Lock、静态内部类单例)实现的共同点

为什么叫做饿汉式呢?看看代码,我们发现一旦这个类被加载了,它就会创建Singleton类的对象。这就像饿汉一样,也因此被称为了饿汉式。

由于我们调用Singleton的getInstance方法的时候。执行的步骤如下:

  • 首先,这个类的字节码通过类加载器加载到了JVM中
  • 然后,在字节码连接的准备阶段,为所有的静态变量分配空间,并设置默认值。这里为instance变量分配了空间,并设置了默认值,这里为null。
  • 连接阶段完了后,就进行初始化(当然连接阶段中的解析阶段和初始化是没有先后顺序的)。这里是进行new操作在堆中创建了一个Singleton对象,并被instance引用。
  • 然后就是调用getInstance方法获取instance指向的对象。

首先,instance变量是final;然后,静态方法调用是在类加载完成后调用的,此时单例对象已经被创建了。所以这种方法是多线程安全的。但是这种方式很大的一个缺点就是类一加载就被创建了。如果这个对象的创建需要一些外界传参数的情况就行不通了。我们无法将外界参数传进来后再进行实例化。

下面给你们一张类加载机制的一张可爱的图吧:

类加载机制

懒汉式(不推荐)

//懒汉式
public class Singleton{
    private static Singleton instance;
    private Singleton(){    
    }
     
    public static synchronized Singleton getInstance(){
        if(instance == null){
            instance = new Singleton();
        }
        return instance;
    }
}

懒汉式和饿汉式很大的区别就是:懒汉式是一种懒加载机制,只有当我们调用getInstance静态方法的时候,我们才会进行对象的创建。如果instance变量为null的话,我们对象就还没被创建,此时就进行对象的创建。

当在多线程的情况下,如果多个线程同时到了if判断中去了,这个时候我们就会进行多个实例的创建。因此我们在getInstance方法上要加上synchronized关键字,这样在多线程的情况下,一次只能有一个方法进行getInstance方法的调用。但是这样的话,每次调用getInstance方法的时候都不进行同步操作,开销很大,也因此这种方法我们是不推荐的

Double Check Lock(DCL)

//DCL
public class Singleton{
    private static volatile Singleton instance = null;//由于实例化的问题,在这里可以加上volatile关键字,此关键字保证了每次的对象读取会从主内存读取
    private Singleton(){
    }
     
    public static Singleton getInstance(){
        if(instance == null){//为了避免不必要的同步
            Synchronized(Singleton.class){
                if(instance == null){//在null情况下,进行实例的创建
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

由上面的代码我们可以看到,DCL单例方式也是懒加载的。DCL可以看成是懒加载的进化版的。Double Check Lock由其名字我们可以知道它是要进行双重检查的。在代码中也很明显,其中有两个if进行instance是否为null的检查。

第一个判断是为了减少开销的,当我们已经创建好了实例后,每次调用getInstance我们在外面的if判断就不会成立,因此不会像懒加载一样没有都会多出同步的开销。

第二个判断是为了避免创建多个实例,因为如果在多线程的情况下,也许会有很多的线程同时通过第一个if判断,如果不进行判断,这些每一个线程都会创建一个实例。

然后在DCL的模式下我们发现instance变量用了volatile来进行修饰。其主要有两个作用:

  • 保证volatitle所修饰的变量,在当一个线程修改了其值后,其它的线程能够立即得到新值
  • 然后就是volatile会禁止指令的重排

具体的一些理解以及解释可以参考这篇博客全面理解Java内存模型(JMM)及volatile关键字

那么我们这里为什么要将instance变量修饰为volatile呢?

我们先来分析一下instance = new Singleton(),大致有下面三步(注意下面2、3两步是可以进行交换的,也就是可以是1-2-3,也可以是1-3-2)

  • 给Singleton的实例分配内存
  • 调用Singleton的构造函数,初始化成员字段
  • instance变量指向Singleton实例在内存中的地址(此时,instance就不为空了)

由上面可以想想,我们有两个线程A、B要都调用getInstance方法的情况下。当A进行先占用CPU并执行到了instance = new Singleton()这里,然后在上面执行的是1-3-2这种情况,并且执行到了3的这步的时候,CPU被B线程抢去了。然后B执行到if判断的时候,因为A线程中已经执行了3,instance不是null了,然后B就直接return了instance,但是这个instance所引用的对象还没进行构造。这是就出问题了

然后,冷静的思考下是什么导致这样的事情发生的呢?没错,就是因为instance = new Singleton()执行的时候,指令进行了重排序而导致的,那么我们的volatile变量就是禁止重排序的。于是上面的执行的过程就变成了正常1-2-3。因此避免了上面的事情的发生,具体的分析理解和上面是一样的,这里我就不进行重复的解释了。

静态内部类单例模式(推荐使用)

现在其实很多情况下是在使用DCL方式的,它解决了资源消耗、同步开销、线程安全的问题。但是如果在JDK6之前没有volatile关键字的支持的时候,这种方式就不这么完美了。于是我们可以用静态内部类单例模式来进行替代。

//静态内部类方式
public class Singleton{
    private Singleton(){
    }

    public static Singleton getInstance(){
        return SingletonHolder.instance;
    }

    private static class SingletonHolder{
        private static final Singleton instance = new Singleton();
    }
}

由上面的代码我们可以看到这种方式下我们需要单例的类中有一个静态内部类,这个类是实现单例模式的核心所在。有关静态内部类的解释可以参考我的另外一篇博客Java内部类其中最核心的部分是当外部类加载的时候并且外部类中并没有用到内部类的时候,内部类是不会加载的,也因此单例对象也不会被创建。这就实现了懒加载,防止没有用到这个类的对象的时候却创建对象的空间浪费。

然后,我们在调用了getInstance方法的时候静态内部类才进行加载的过程在这个类加载完成后,对象也就创建出来了。此时getInstance就返回单例对象出去。静态变量的天生多线程安全,单例(详细的可以看下类加载机制)让它可以不依赖于同步锁,大大降低了开销。

枚举单例

//枚举单例
public enum Singleton {
    INSTANCE;
    public void doSomething(){
        System.out.println("do sth.");
    }
}

通过枚举来实现单例模式最大的优点就是:

  • 使用简单
  • 天生构造器私有(PS:即使是自定义构造器也只能是让你的构造器是私有的才行)
  • 它的构造相当于饿汉式,因此也是多线程安全的
  • 枚举和普通类一样可以定义成员函数、静态函数等

然后,枚举是JDK 5的时候引入的,如果在JDK5以下就不能用枚举来实现单例了。而且相当于饿汉式,所以当我们不需要实例的时候,只是调用它的静态方法的时候也会浪费不必要的空间。

使用容器实现单例

//使用容器实现单例
public class SingletonManager {
    private static Map<String,Object> objectMap = new HashMap<>();
    
    private SingletonManager(){
    }
    
    static{
        初始化容器中要单例的类...
    }
    
    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源码中有实现,但是平常也没有怎么用。

总结

单例模式的种类这么多,要用的时候具体的使用要根据具体的情况来。比如JDK版本、单例对象的资源消耗等来进行选取

参考

  • 《Android源码设计模式 第二版》

转载请注明链接