设计模式之单例模式

作者: 不正经的创作者 | 来源:发表于2020-05-08 17:24 被阅读0次

    单例模式

    介绍

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

    定义

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

    使用场景

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

    单例 UML 类图

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

    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 的情况下创建实例。是不是看起来有点迷糊,下面在来解释下:

      javasinstance = 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 源码中单例模式

    LayoutInflater

    总结

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

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

    相关文章

      网友评论

        本文标题:设计模式之单例模式

        本文链接:https://www.haomeiwen.com/subject/jrfqnhtx.html