设计模式 (一) 通过理论 + 代码示例 + Android 源码中单例模式来学习单例

1,807 阅读11分钟

介绍

单例模式是应用最为广泛的模式之一,也可能是很多入门或初级工程师唯一会使用的设计模式之吧,在应用这个模式时,单例对象的类必须保证只有一个实例存在。许多时候整个系统只需要拥有一个实例类。有利于我们的调用,避免一个相同的类重复创建实例,比如一个网络请求,图片请求/下载,数据库操作等,如果频繁创建同一个相同对象的话,很消耗资源,因此,没有理由让它们构造多个实例。全局都需要使用这个功能的时候,避免重复创建,就可以用单例,这就是单例使用场景。

定义

确保某个类只有一个实例,而且自行实例化并向整个系统提供这个实例。

使用场景

应用中重复使用某个类时,为了避免多次创建产生的资源消耗,那么这个时候就可以考虑使用单例设计模式。

单例 UML 类图

czjKW.png

实现单例模式主要有如下几个关键点:

  1. 构造函数不对外开放,一般为 private;
  2. 通过一个静态方法或者枚举返回单例对象;
  3. 确保单例类的对象有且只有一个,尤其是在多线程环境下;
  4. 确保单例类对象在反序列化时不会被重新构建对象。

单例示例

饿汉式

单例模式是设计模式中比较简单的,只有一个单例类,没有其他层次结构与抽象。该模式需要确保该类只能生成一个对象,通常是该类需要消耗较多的资源或者没有多个实例的情况。例如下面的代码:

public class DaoManager {

    /**
     * 饿汉式单例
     */
    private static DaoManager instance = new DaoManager();

    private DaoManager(){}

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

测试

    @Test
    public void test(){
        String dao = DaoManager.getInstance().toString();
        String dao1 = DaoManager.getInstance().toString();
        String dao2 = DaoManager.getInstance().toString();
        String dao3 = DaoManager.getInstance().toString();


        System.out.println(dao);
        System.out.println(dao1);
        System.out.println(dao2);
        System.out.println(dao3);
    }

Output

com.devyk.android_dp_code.singleton.DaoManager@28ba21f3
com.devyk.android_dp_code.singleton.DaoManager@28ba21f3
com.devyk.android_dp_code.singleton.DaoManager@28ba21f3
com.devyk.android_dp_code.singleton.DaoManager@28ba21f3

从上面代码可以看到 DaoManager 不能通过 new 的形式构造对象,只能通过 getInstance() 拿到实例,而 DaoManager 对象是静态的,那么在声明的时候已经初始化了,这就保证了对象的唯一性,从输入结果中发现, DaoManager 四次输出的地址都是一样的。这个实现的核心在与将 DaoManager 类的构造方法私有化,使得外部程序不能通过构造来 new 对象,只能通过 getInstance() 来返回一个对象。

懒汉模式

懒汉模式是声明了一个静态对象,并且在第一调用的时候进行初始化,而上面的饿汉纸则是在声明的时候已经初始化了。懒汉式的实现如下:

public class DaoManager2 {
    
    private static DaoManager2 instance;
    
    private DaoManager2(){}

    /**
     * 保证线程安全的懒汉式
     * @return
     */
    public static synchronized DaoManager2 getInstance(){
        if (null == instance) {
            instance = new DaoManager2();
        }
        return instance;
    }
}

细心的读者可能已经发现了,getInstance() 方法中添加了 synchronized 关键字, getInstance 是一个同步方法,保证了在多线程情况下单例对象唯一性。细想下,大家可能会发现一个问题,即使 instance 已经被初始化,每次调用都会进行同步检查,这样会消耗不必要的资源,这也是懒汉单例模式存在的最大问题。

最后总结一下,懒汉单例模式的优点是单例只有再使用的时候进行初始化,在一定程度上节约了资源;缺点是第一次加载时需要进行初始化,反应稍慢,最大的问题就是每次调用的时候 getInstance 都进行同步,造成不必要的开销。这种模式一般不建议使用。

Double Check Lock 实现单例

DCL 方式实现单例模式的有点是既能够在需要时初始化单例,又能保证线程安全,且单例对象初始化后调用 instance 不进行同步锁,代码如下:

public class DaoManager3 {

    private static DaoManager3 sinstance;

    private DaoManager3() {
    }

    /**
     * 保证线程安全的懒汉式
     *
     * @return
     */
    public static DaoManager3 getInstance() {
        if (null == sinstance) {
            synchronized (DaoManager3.class) {
                if (null == instance)
                    sinstance = new DaoManager3();
            }
        }
        return sinstance;
    }
}

本段代码的亮点就在于 getInstance 方法上,可以看到 getInstance 方法对 instance 进行了两次判空;第一层判断主要是为了避免不必要的同步,第二层的判断则是为了在 null 的情况下创建实例。是不是看起来有点迷糊,下面在来解释下:

sinstance = new DaoManager3();

这个步骤,其实在jvm里面的执行分为三步:

  1. 在堆内存开辟内存空间;
  2. 在堆内存中实例化 DaoManager3 里面的各个参数;
  3. 把对象指向堆内存空间;

由于在 JDK 1.5 以前 Java 编译器允许处理器乱序执行,以及 JMM 无法保证 Cache, 寄存器(Java 内存模型)保证按照 1,2,3 的顺序执行。所以可能在 2 还没执行时就先执行了 3,如果此时再被切换到线程 B 上,由于执行了 3,sinstance 已经非空了,会被直接拿出来用,这样的话,就会出现异常。而且不易复现不易跟踪是一个隐藏的 BUG。

不过在 JDK 1.5 之后,官方也发现了这个问题,故而具体化了 volatile ,即在 JDK 1.6 以后,只要定义为 private volatile static DaoManager3 sinstance ; 就可解决 DCL 失效问题。volatile 确保 sinstance 每次均在主内存中读取,这样虽然会牺牲一点效率,但也无伤大雅。

DCL 优点:资源利用率高,第一次执行 getInstance 时单例对象才会被实例化,效率高。

DCL 缺点:第一次加载时,反应稍慢,也由于 Java 内存模型的原因偶尔会失败。在高并发环境下也有一定的缺陷,虽然发生概率很小。

DCL 模式是使用最多的模式,它能够在需要时才被实例化,并且能够在绝大多数场景下保证单例对象的唯一性,除非你的代码在并发场景比较复杂或者低于 JDK 6 版本下使用,否则,这种方式一般能够满足需求。

静态内部类单例模式

DCL 虽然在一定程度上解决了资源消耗、多余的同步、线程安全等问题,但是,它还是在某些情况下出现失效的问题,这个问题被称为双重检查锁定失效,在《Java 并发编程实践》一书的最后谈到了这个问题,并指出这种 “优化” 是丑陋的,不赞成使用。而建议使用如下的代码替代。

public class DaoManager4 {
    
    private DaoManager4(){}

    public static DaoManager4 getInstance(){
        return DaoManager4Holder.sInstance;
    }

    /**
     * 静态内部类
     * 
     */
    private static class DaoManager4Holder{
        private static final DaoManager4 sInstance = new DaoManager4();
    }
}

那么,静态内部类又是如何实现线程安全的呢?首先,我们先了解下类的加载时机。

类加载时机:JAVA 虚拟机在有且仅有的 5 种场景下会对类进行初始化。

  1. 遇到 new、getstatic、setstatic 或者 invokestatic 这4个字节码指令时,对应的 java 代码场景为:new 一个关键字或者一个实例化对象时、读取或设置一个静态字段时 ( final 修饰、已在编译期把结果放入常量池的除外)、调用一个类的静态方法时。
  2. 使用 java.lang.reflect 包的方法对类进行反射调用的时候,如果类没进行初始化,需要先调用其初始化方法进行初始化。
  3. 当初始化一个类时,如果其父类还未进行初始化,会先触发其父类的初始化。
  4. 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的类),虚拟机会先初始化这个类。
  5. 当使用 JDK 1.7 等动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。 这 5 种情况被称为是类的主动引用,注意,这里《虚拟机规范》中使用的限定词是"有且仅有",那么,除此之外的所有引用类都不会对类进行初始化,称为被动引用。静态内部类就属于被动引用的行列。

我们再回头看下 getInstance() 方法,调用的是 DaoManager4Holder.sInstance ,取的是DaoManager4Holder 里的 sInstance 对象,跟上面那个 DCL 方法不同的是 ,getInstance()方法并没有多次去 new 对象,故不管多少个线程去调用 getInstance() 方法,取的都是同一个sInstance 对象,而不用去重新创建。当 getInstance() 方法被调用时,DaoManager4Holder 才在 DaoManager4 的运行时常量池里,把符号引用替换为直接引用,这时静态对象sInstance 也真正被创建,然后再被 getInstance() 方法返回出去,这点同饿汉模式。那么sInstance 在创建过程中又是如何保证线程安全的呢?在《深入理解JAVA虚拟机》中,有这么一句话:

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

故而,可以看出 sInstance 在创建过程中是线程安全的,所以说静态内部类形式的单例可保证线程安全,也能保证单例的唯一性,同时也延迟了单例的实例化。

那么,是不是可以说静态内部类单例就是最完美的单例模式了呢?其实不然,静态内部类也有着一个致命的缺点,就是传参的问题,由于是静态内部类的形式去创建单例的,故外部无法传递参数进去,例如 Context 这种参数,所以,我们创建单例时,可以在静态内部类与 DCL 模式里自己斟酌。

枚举单例

前面讲解了几个单例模式的实现方式,这几个实现方式不是稍显麻烦就是会在某种情况下出现问题,那么还有没有更简单的实现方式勒? 我们先来看看下面的实现方式。

public enum  DaoManager5 {
    
    INSTANCE;
    
    public void doSomething(){
        Log.i("DAO->","枚举单例");
    }
}

没错,就是枚举单例!

写法简单简单是枚举单例最大的优点,枚举在 Java 中与普通的类时一样的,不仅能够拥有字段,还能够拥有自己的方法。最重要的是默认枚举实例的创建是线程安全的,并且在任何情况下它都是一个单例。

优点:枚举本身是线程安全的,且能防止通过反射和反序列化创建实例。

缺点:对 JDK 版本有限制要求,非懒加载。

使用容器实现单例模式

学习了上面 5 大单例模式,最后在来介绍一种容器单例模式,请看下面代码实现:

public class DaoManager6 {

    /**
     * 定义一个容器
     */
    private static Map<String,Object> singletonMap = new HashMap<>();
    
    private DaoManager6(){}
    
    public static void initDao(String key,Object instance){
        if (!singletonMap.containsKey(key)){
            singletonMap.put(key,instance);
        }
    }
    
    public static Object getDao(String key){
        return singletonMap.get(key);
    }
}

在程序的初始,可以将单例类型注入到统一管理类中,在使用的时候根据 key 获取对应单例对象,并且在使用时可以通过统一的接口进行获取操作,降低了用户的使用成本,也对用户隐藏了具体实现,降低了耦合度。

Android 源码中单例模式

Android 源码中涉及了大量的单例模式,这里就拿较为熟悉的 context.getSystemService(String name); 容器单例模式,以 Context.LAYOUT_INFLATER_SERVICE 举例。

从 setContentView 入口,全方位分析 LayoutInflater

总结

单例模式在应用中时属于使用频率最高的一种设计模式了,但是由于客户端通常没有高并发的情况,因此,选择哪种实现方式并不会有太大的影响。当然,考虑效率和并发的场景还是推荐大家使用 DCL 或 静态内部类单例模式。

注意:如果单例对象必须持有参数的话,那么最好建议使用弱引用来接收参数,如果是 Context 级别的类型,建议使用 context.getApplication() 否则容易造成内存泄漏;

文章中出现的代码

感谢你的阅读,谢谢!