Loading...
墨滴

牛角尖

2021/03/22  阅读:31  主题:默认主题

疯了吧!单例模式竟然搞出7种写法!

前言

“设计模式的重要性不用我多说,如果你想成为优秀的 Android 工程师,设计模式是必须要掌握的。” —— 《 Android 进阶之光 》 刘望舒

好了好了,我知道设计模式很重要了,我学还不行吗!

可是,我从哪个设计模式开始呢?那就单例吧,单例谁还能不会啊?嘿嘿!

然后,我翻开落了一层土的书:

《Android 进阶之光》《Android源码设计模式解析与实战》《HeadFirst设计模式》《大话设计模式》……(枪法不行,枪贼多)

单例模式的实现方式有:饿汉模式、线程不安全的懒汉模式、线程安全的懒汉模式、DCL、静态内部类、枚举、容器

What the fuck!小小的单例模式竟然有 7 种写法?!

还要牵扯“DCL失效问题”、“可序列化单例类的反序列化问题”?

没事,枪法不行,枪多来凑,弄他!

练枪,从认识枪开始

先看看定义:

单例模式用来保证一个类只有一个实例,自行实例化此实例,并提供一个访问此实例的全局访问点。

贴一下单例模式的 UML 类图,实际上就一个类。

image.png
image.png

打枪姿势

1、饿汉模式

先贴代码:

public class Singleton {
    
    private static Singleton instance = new Singleton();

    // 私有构造,不允许外部通过构造实例化 Singleton.class
    private Singleton() {
    }

    public static Singleton getInstance() {
        return instance;
    }

}

这种写法被称为“饿汉模式”,可我感觉,完全饿不到啊!

我们知道,类的加载是由 Java 虚拟机在初始化应用程序时完成的,而类加载时,会立刻执行静态代码

也就是说,只要 Java 虚拟机启动了应用程序,Singleton 类就会被实例化,不管 Singleton 类会不会被用到 —— 不管你吃不吃,饭都在那里。

所以说,我们可以总结出来,饿汉模式的单例模式有以下几个特征:

  • 1、类加载时还要进行实例化,导致类加载速度变慢,但获取对象时速度很快;

  • 2、没有实现懒加载,如果从始至终未使用此类,却默认进行实例化,会造成内存的浪费;

  • 3、由于类的加载是在 Java 虚拟机初始化应用程序时完成的,这时候应用程序实际还未启动,且一个类只会加载一次。这就避免了多线程下的同步问题,也就是说,是线程安全的。

后续的各种单例模式的写法,都是基于饿汉模式发展而来,大家理解了饿汉模式的上述特征的话,后续的各种写法的理解就是水到渠成了。

2、懒汉模式(线程不安全)

先贴代码:

public class Singleton {
    
    private static Singleton instance;

    // 私有构造,不允许外部通过构造实例化 Singleton.class
    private Singleton() {
    }

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

}

大家可以很容易的发现,与饿汉模式相比,懒汉模式最大的改进是实现了懒加载

也就是说,此类不用就不加载,用的话在第一次调用时会进行实例化。所以说,可以节省内存资源。

另外,大家还要注意的是,在多线程的情况下,此种写法有可能产生两种问题:

  • 1、创建了多个实例,就违背了单例模式的初衷;

  • 2、多线程的安全性问题,可能导致严重的后果,后文讲“DCL失效问题”时会一起聊。

众所周知,移动端的应用程序高并发的情况较少,所以这种写法的单例模式实际上是可以用的,只是确实没有后续的写法优秀。

3、懒汉模式(线程安全)

既然上一种写法的懒汉模式最大的问题是线程不安全,那咱们就来解决下这个问题。

public class Singleton {
    
    private static Singleton instance;

    // 私有构造,不允许外部通过构造实例化 Singleton.class
    private Singleton() {
    }

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

}

很简单,给 getInstance() 方法加了同步锁 synchronized

然而事实是,这种写法是最不建议的用法。因为无论是不是多线程的环境,每次调用 getInstance() 方法都要进行同步,造成了不必要的同步开销。

4、双重检查模式(DCL)

这种写法就很优秀了,不过也存在一些问题,先看代码吧。

public class Singleton {

    private static Singleton instance;

    // 私有构造,不允许外部通过构造实例化 Singleton.class
    private Singleton() {
    }

    public static Singleton getInstance() {
        // 10行
        if (instance == null) {
            // 11行
            synchronized (Singleton.class) {
                // 12行
                if (instance == null) {
                    // 13行
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }

}

咱们看这段代码 12 ~ 14 行,自然会为了实现懒加载;

而代码 11 行,则是为了解决懒加载的线程不安全的问题;

亮点在代码 10 行,在进行同步前,先判空,如果类已经实例化,则不再进行同步,解决了每次调用 getInstance() 方法都要同步造成的不必要的同步开销问题。

综上,DCL 的写法一下子解决了上述 3 种单例模式写法的所有问题。

那 DCL 的写法存在的问题是什么呢?

DCL 失效问题(划重点)!

看代码 13 行 instance = new Singleton() ,我们都知道,这是一条非原子操作,拆分来看的话,大致分为三个步骤:

  • 1、给 Singleton 的实例分配内存空间;

  • 2、调用 Singleton 的构造函数,初始化其成员变量;

  • 3、将 instance 对象指向分配的内存空间(此时,instance 就不是 null 了)。

由于 Java 编译器允许处理器乱序执行(原因一),以及在 JDK 1.4 及 1.4 之前,JMM(Java Memory Model) 中 Cache、寄存器到主内存回写顺序的规定(原因二),导致上述 2、3 步骤顺序无法保证,也就是说执行的顺序可能是 1-2-3,或者是 1-3-2。

1-2-3 的执行顺序没什么问题,我们来关注下 1-3-2 的执行顺序。

假设线程 A 正在对 Singleton 进行实例化,执行了步骤 1 和 3,还没有执行 2。这时候线程 B 进来了,判断 instance 不是 null,就取走了 instance 进行使用,可是 Singleton 的构造函数都还没执行,成员变量也没有初始化,成员变量的指向实际上还是 null 的,就出错了。

这就是 DCL 的失效问题,也是前面提及的 “线程不安全的懒汉模式” 中可能会出现的严重后果。

那怎么解决 DCL 的失效问题呢?

在 JDK 1.4 及 1.4 以前,无解。好在现在基本也不同这么低版本的 JDK 了。

在 JDK 1.5 及 1.5 以后,我们可以在上述代码第 3 行 声明 instance 属性时加入关键字 volatile ,就可以保证 instance 对象每次都是从主内存中读取到的。

  private static volatile Singleton instance;

我们来简单解释一下:

在 JMM 中,有主内存与工作内存的区分,一般声明的变量会在主内存中存在,并在工作内存中存在其一个拷贝。当工作内存对变量的拷贝进行修改时,会从工作内存同步到主内存。

可是当对变量进行非原子性操作时,变量从工作内存到主内存的同步也是非原子性操作,在多线程的情况下,如果存与取同时发生,就会出现线程不安全的问题。

volatile 关键字,具备 “有序性” 和 “可见性” 的特性。

  • 有序性

    禁止指令重排序,解决导致 DCL 失效的原因一;

  • 可见性

    当某一工作内存对变量的拷贝进行修改时,会立即同步到主内存,同时将所有工作内存中的变量的拷贝置为无效状态,则其他工作内存要取用此变量时就要从主内存中同步过来,解决导致 DCL 失效的原因二。

这样, volatile 关键字在 JDK >= 1.5 时可以解决 DCL 失效的问题。

我们知道,使用同步锁 synchronized 进行同步操作是比较耗费资源的,volatile 虽然要比 synchronized 节俭一些,不过还是对性能有所损耗,那有没有更优秀的单例模式写法呢?

有的,往下看吧!

5、使用静态内部类实现单例

这种写法可以说***优秀***了,先看下代码吧。

public class Singleton{
    private Singleton(){
    }

    public static Singleton getInstance(){
        // 6行
        return SingletonHelper.instance;
    }

    // 9行
    private static class SingletonHelper{
        private final static Singleton instance = new Singleton();
    }

}

要搞懂这种模式,首先要搞懂 “饿汉模式”,咱们一起来理解下吧。

看这段代码 9 ~ 11 行,静态内部类 SingletonHelper 中的静态属性 instance 是随着 SingtonHelper 的加载而加载的,也就是说 一旦静态内部类 SingletonHelper 加载,就会对 Singleton 类进行实例化

那问题来了,静态内部类 SingletonHelper 什么时候加载呢?

静态内部类不同于静态属性,不会随着宿主类(Singleton)的加载而加载,是在第一次调用静态内部类的时候再由 Java 虚拟机进行加载(代码第 6 行),这样就避免了多线程的同步问题。

同时,这种写法也实现了懒加载,如果不调用 getInstance() 方法,就不会调用静态内部类对 Singleton 进行实例化。

可序列化的单例类的反序列化问题

以上,我们一起分析了 5 种单例模式的写法,实际上他们都存在一个相同的问题:可序列化的单例类的反序列化问题。

这样,我们通过序列化可以将单例类的实例对象写到磁盘,然后再读回来,即进行反序列化,就可以得到此单例类的一个实例。即使构造函数是私有的,也是有办法获得单例类的一个新的实例的。

那怎么解决此问题呢?两种方案:

方案 1、利用类中可用的一个私有的钩子函数 readResolve() ,用来控制类的反序列化。看下代码示例吧。

import java.io.ObjectStreamException;
import java.io.Serializable;

public class Singleton implements Serializable{

    private static final long serialVersionUID = 0L;

    private static volatile Singleton instance;

    // 私有构造,不允许外部通过构造实例化 Singleton.class
    private Singleton() {
    }

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

    // 钩子函数,用来控制类的反序列化操作
    // 26行
    private Object readResolve() throws ObjectStreamException {
        return instance;
    }

}

看这段代码 26 ~ 28 行,通过此钩子函数,当对类进行反序列化时,返回我们的单例的实例 instance ,就避免了反序列化时创建新实例的问题。

方案 2:就是下面咱们要说的第 6 种实现单例模式的写法:枚举模式。

6、使用枚举实现单例

我们知道,枚举在 Java 中与普通的类是一样的,可以有自己的属性,还可以有自己的方法,最重要的是,枚举实例的创建是线程安全的,并且在任何情况下它都是一个单例(包括反序列化)

让我们来看下代码示例吧。

public enum Singleton {
    INSTANCE;
    
    public void someMethod(){
    
    }
}

哈哈哈,有没有被惊掉下巴?

没错,枚举实现单例的代码就是这么简单!

那怎么获得枚举实现的单例类的实例呢?也是十分简单。

Singleton instance = Singleton.INSTANCE;

秀啊!一枝独秀!

7、使用容器实现单例

这算是一种比较另类的写法了。还是先来看代码示例,至于这种写法的优劣,请各位自己评判吧。

import java.util.HashMap;
import java.util.Map;

public class SingletonManager {

 // 存储单例类的容器
    private static Map<String, Object> mSingletonMap = new HashMap<>();

    // 不需要对此 单例管理类 进行实例化
    private SingletonManager() {
    }

    // 向容器中注册单例类
    public static void registerSingleton(String key, Object instance) {
        if (!mSingletonMap.containsKey(key)) {
            mSingletonMap.put(key, instance);
        }
    }

    // 从容器中获取单例类
    public static Object getSingleton(String key) {
        return mSingletonMap.get(key);
    }
}

在程序初始化时,会将多种单例类型的对象注入到容器中,当需要使用时通过 key 从容器中取出相应的单例类型的对象。

这种写法,很明显可以很好对单例类型进行统一管理,存取操作对用户是透明且低耦合的,问题是容器中初始的单例类型会消耗资源,无论是否会被用到。

在 Android 系统中,系统级别的服务用的就是这种方案,如 AMS、WMS、LayoutInflater 等服务,会在合适的时候以单例的形式注册到系统中,当需要调用相应服务的时候,会通过 Context.getSystemService(String name) 获取。

总结

至此,本文要介绍的 7 种单例模式的写法就介绍完了。

无论是哪种实现方式,核心原理都是首先将构造函数私有化,然后通过静态方法得到相应类的实例。

至于选用哪一种,大家要根据相应单例类的应用场景决定,比如是否是高并发环境、JDK 版本是否 >= 1.5、单例类的实例对资源的消耗情况、对单例类实例的取用频率等。

单例模式的使用场景

单例的模式的应用范围很广,具体的使用场景有以下几个:

  • 整个项目需要一个共享访问点或者需要共享数据;

  • 创建相应对象需要耗费大量资源,如 I/O 操作、数据库连接等;

  • 工具类对象等。

参考

  • 《Android源码设计模式解析与实战》
  • 《Android进阶之光》
  • 《HeadFirst 设计模式》
  • 《大话设计模式》

本文首发于 公众号:牛角尖尖上起舞,ID:niujiaojianhi,欢迎关注。

牛角尖

2021/03/22  阅读:31  主题:默认主题

作者介绍

牛角尖