常见设计模式一:单例模式

518 阅读15分钟

在 23 种设计模式中,我们平时接触使用的最多的可能就是单例模式了,虽然这个设计模式大家都会,也很简单,但是里面还是有些东西值得探讨一下的,最终目的是能够结合实际需要写出最适合的单例代码。

单例模式的特点

单例模式是为了保证一个类只有一个实例,并且提供一个访问该实例的全局访问点。那么最起码要有以下的特点:

  1. 不能被其他对象初始化(构造方法需要私有)
  2. 全局只有一个实例(自己本身只能创建一个实例)
  3. 对外提供统一访问方法(提供静态方法供外部访问唯一实例)

知道了上面的几个特点,下面依次看下常见的几种单例实现方式。

ps:暂不考虑序列化传输和反射创建单例对象的情况

常见的几种实现方式

一、饿汉式

特点

  1. 线程安全
  2. 非懒加载

实现思路:

  1. 首先将构造方法私有化,保证不能被其他类的对象创建对象,
  2. 然后 new 出一个对象,保存在内部的一个私有的静态属性 INSTANCE,
  3. 对外提供一个静态方法用于外部访问

代码实现:

线程安全和非懒加载的原理

代码实现起来很简单,但是这种实现方式有个很大的问题:不管我们本次程序运行是否使用到了这个单例,都会进行单例类的初始化,创建单例对象

因为我们在代码中有一个静态属性 INSTANCE ,在 JVM 虚拟机装载类信息的时候,会对其进行初始化,也就是会执行 new Sington() 创建一个 Sington 的对象,也就是说在程序启动加载类信息的时候,对象的实例就会被创建,同样由于在类加载阶段就初始化了对象,也可以保证线程安全。

上面一段话说了,在类加载阶段的静态区中可以保证线程安全,我们并没有给其加锁,是怎么保证线程安全的呢?

这是因为 JVM 加载过程的保护机制。和普通类的实例被分配到 Java 堆中不同,类的静态属性和静态方法都保存在方法区的静态区,创建的过程是在类加载过程,所以会受到类加载过程的影响。

类的加载过程大致分为:加载、验证、准备、解析、初始化、使用、卸载共七个部分。

其中加载、验证、准备、初始化、卸载这 5 个阶段的顺序是确定的,而解析阶段不一定,因为解析阶段在某些情况下可以在初始化阶段以后再开始,这是为了支持 Java 的运行时绑定。

关于初始化:JVM 明确规定,有且只有 5 种情况必须执行对类的初始化(加载、验证、准备 三个阶段肯定是在此之前要发生)

  1. 遇到 new、getstatic、putstatic、invokestatic,如果类没有初始化,则必须初始化,这几条指令分别是指:new 新对象、读取静态变量、设置静态变量,调用静态函数。
  2. 使用 java.lang.reflect 包的方法对类进行反射调用时,如果类没初始化,则需要初始化
  3. 当初始化一个类时,如果发现父类没有初始化,则需要先触发父类初始化。
  4. 当虚拟机启动时,用户需要制定一个执行的主类(包含 main 函数的类),虚拟机会先初始化这个类。
  5. 但是用 JDK1.7 启的动态语言支持时,如果一个MethodHandle实例最后解析的结果是 REF_getStatic、REF_putStatic、Ref_invokeStatic 的方法句柄时,并且这个方法句柄所对应的类没有进行初始化,则要先触发其初始化。

再来看下具体的几个阶段:

加载阶段

加载阶段主要做一下三件事情:

  1. 通过一个类的全限定名称来获取此类的二进制流
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
  3. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据访问入口。
验证阶段

这个阶段主要是为了确保 Class 文件字节流中包含信息符合当前虚拟机的要求,并且不会出现危害虚拟机自身的安全。

准备阶段

准备阶段是正式为类变量分配内存并设置类静态变量初始值的阶段。这些变量所使用的内存都在方法区中分配。

首先,这个时候分配内存仅仅是指的是静态变量,不包括实例变量。实例变量会在对象实例化的时候随着对象一起分配在堆内存中

需要注意的是,这个时候初始值,通常指的是默认值,并不是具体设置的值。比如:

public static int key = 123;

在准备阶段完了以后,key 的值是 0,而不是 123,因为还没有执行任何 java 方法,而把 key 赋值为 123 是在程序编译后,存放在类构造函数 <clinit>() 方法中。(在初始化阶段)

解析阶段

解析阶段是把虚拟机中的常量池的符号引用替换为直接引用的过程。

初始化阶段

类初始化阶段是类加载的最后一步,前面的类加载过程中,除了加载阶段用户可以通过自定义类加载器参与以外,其余都是 JVM 虚拟机主导和控制进行的,到了初始化阶段才是真正执行类中定义 Java 代码的地方。

在准备阶段中,静态变量已经赋值为系统要求的默认值,而在初始化阶段,根据静态变量就要被赋值为我们在 Java 代码中定义的值。

这个过程(初始化阶段)就是执行类构造器 <clinit>() 方法的过程,也就是说静态变量的赋值操作是发生在 <clinit>()内的

<clinit>()方法是由编译器自动收集类中所有静态变量的赋值动作和静态语句块中的语句合并产生的,收集的顺序是按照语句在源文件中出现的顺序。静态语句块中只能访问定义在静态语句块之前的变量,定义在它之后的变量可以赋值,但不能访问,如下:

public class Test{
    static{
        i=0;//給变量赋值,可以通过编译
        System.out.print(i);//这句编译器会提示:“非法向前引用”
    }
    static int i=1;

}

<clinit>()方法执行的过程中,JVM 会对类加锁,保证在多线程环境下,只有一个线程能成功执行 <clinit>() 方法,其他线程都将被拥塞,并且 <clinit>() 方法只能被执行一次,被拥塞的线程被唤醒之后也不会再去执行<clinit>()方法。<clinit>()方法与类构造函数(或者说实例构造器<init>()方法)不同,他不需要显式地调用父类构造器,虚拟机会保证子类的<clinit>()方法执行之前,父类的<clinit>()已经执行完毕。

由此可见,静态代码的多线程安全是由 JVM 在类加载阶段为其加锁实现的。

其实这里也就解释了为什么不允许类中的静态属性使用非静态属性的原因,因为非静态属性的初始化是在创建对象的时候才赋值的,而静态的属性在类加载的初始化阶段已经被赋值,并加载到了内存中,类的对象的创建是在 类加载初始化阶段 之后进行,自然是不能赋值了。

二、懒汉式

懒汉式的最大特点是懒加载,可以在我们使用的时候再进行初始化,既然是我们用的时候再进行初始化而不是 JVM 类加载的时候进行初始化,那么就没有 JVM 帮我们加锁保证线程安全的机制,需要我们自己去实现。下面依次从线程不安全,到线程安全依次实现。

写法一

这种写法很明显可以实现懒加载,在调用 getInstance() 的时候,如果这个时候 INSTANCE 静态变量不为 null,直接返回,但是为 null 的情况就存在问题了。

假如这个时候 2 个线程同时进入if 语句,执行 INSTANCE = new Sington1(),这个时候就可能会创建两个 Sington1 对象,很明显不是我们想要的,所以这种是线程不安全的。

那么既然是线程不安全的,那么使用 synchronized 关键字是不是可以解决问题呢?

接下来看写法二:

写法二

或者这样写:

这两种写法其实是一样的,本质上都是对类进行加锁。

既然对类进行了加锁,那么就保证了在多线程情况下的安全,同时也实现了懒加载。

那么是不是这种写法就是完美的呢?

答案是 不是,因为这种写法还有个问题: synchronized 是比较耗费性能的,我们每次调用这个 getInstance() 方法的时候,都会进入 synchronized 包裹的代码块内,即使这个时候单例对象已经生成,不再需要创建对象也会进入 synchronized 内部,是不合理的。

所以为了解决这个问题,有了下面的写法三:

写法三

可以看到在这种写法的 getInstance() 方法上,把 synchronized 关键字放到了 方法内部,并且放到了 if (null == INSTANCE) {} 内部,这样在多线程访问的时候,分下面两种情况:

1. 单例对象未生成

这个时候即使多个线程进入了 if (null == INSTANCE) {} 内部,但是遇到了 synchronized 代码块,这个时候只能由一个线程去执行创建对象的操作,这样一旦有对象生成,即使下个线程进入了 synchronized 代码块内, if (null == INSTANCE) 为 false ,也不会再去创建对象。

所以就保证了对象的唯一。

2. 单例对象已生成

这个时候如果单例对象已经生成,那么就不会执行到了 synchronized 代码块内,直接返回单例对象。

这里也是和写法二相对改进的地方,不用每次调用 getInstance() 方法都会进入 synchronized 代码块内,提高了性能。

写法三也是大家经常说的双重锁定 DCL(Double Check Locking) 写法。

写法三看起来已经能够实现懒加载和线程安全了,但是还存在一个问题,那就是没有考虑到 JVM 编译器的指令重排序,我们用写法四来改进。

写法四

先来看指令重排序的问题,分析完在来看实现代码:

JVM 编译指令重排序

在程序运行过程中,编译器和处理器会对指定做重排序。但是 JMM (Java Memory Model)能够确保在不同的编译器和不同的处理器平台上,通过插入指定类型的 Memory Barrier 来禁止特定类型的编译器重排序和处理器重排序,为上层提供一致的内存可见性保证。

指令重排序可能会改变代码的执行顺序,能够保证不影响代码在单线程中的结果,但是多线程中并发执行的结果则是不可控的。

比如线程 A:

context = getContext();
inited = true

线程 B:

while(!inited){
    sleep();
}
doSomeThingWithContext(context);

正常情况下,如果没发生指令重排序,代码执行是没问题的,线程 A 在执行完 getContext()给 context 赋值结束以后,inited 为 true,线程 B 这个时候读取到 inited 为 true,就会跳出循环,去执行 doSomeThingWithContext(context),是没有任何问题的。

但是如果 A 发生指令重排序,执行就不一样了:

假设线程 A 发生了重排序,变成:

inited = true
context = getContext();

先执行 inited 为 true ,假如这个时候线程 B 正好拿到了执行权,会跳出 while 循环,然后执行 doSomeThingWithContext(context);,但是 A 的 context = getContext();还没初始化完成,这个时候 线程 B 拿到的 context 就是一个空的,会引起不可控的错误。

这个是我们自己编的例子,只是为了理解指令重排序,下面看下对应写法三种的可能引发的问题。

对应写法三中的问题

上面讲的指令重排序问题,其实就对应到了 写法三 中的这行代码:

INSTANCE = new Sington1();

在执行这行代码的时候,JVM 其实做了三个步骤:

  1. 给 INSTANCE 分配内存
  2. 调用 Sington1 的构造函数来初始化变量
  3. 讲 INSTANCE 对象指向分配的内存空间,也就是非 NULL 对象了

结合 JVM 指令重排序,可能发生的执行顺序就是 : 第一种: 1 -> 2 -> 3 第二种: 1 -> 3 -> 2

如果是第一种,那么执行没有什么问题。

如果是第二种,就可能发生问题,假如此时有两个线程A 和 B。

A 线程执行到了完了步骤3,在执行步骤 2 之前,B 线程拿到了执行权,这个时候 INSTANCE 是非 NULL 的(INSTANCE 有指向堆内存中的具体地址),但是没有进行对象初始化,所以 B 线程会返回 INSTANCE, 这个时候是没有具体的对象的,所以在接下来再去调用单例里面的方法的时候,自然就会报错了。

那么我们要做的就是禁止 JVM 的指令重排序,其实很简单,使用 volatile 关键字即可解决,因为 volatile 可以禁止指令重排序,于是有了写法四如下:

到这里懒汉式已经有了最佳写法,也就是写法四了。

经过上面的分析,会发现懒加载和线程安全是我们自己通过加锁和 volatile 关键字实现的,那么有没有让 JVM 帮我们实现线程安全和懒加载呢?

答案是有的,那就是下面的静态内部类写法。

三、静态内部类

实现

这种实现同样是利用了 Java 的类加载机制。

首先在 JVM 进行类加载的时候,只是加载了 Sington2 类,并不会去执行其中的静态方法,也不会去加载 Sington2 内的静态内部类 Sington2Holder。所以也就是并不会在初次类加载的时候创建单例对象。

在我们使用 getInstance() 的时候,我们使用 Sington2Holder 的静态属性,这个时候会对 Sington2Holder 这个静态内部类进行加载,这个时候,就回到了第一种写法 饿汉式中的原理,在类加载的初始化阶段,会对创建单例对象,并且赋值给 INSTANCE 属性。同样,这些操作是发生在类加载阶段的,由 JVM 保证了线程安全,并且是在使用的时候进行加载的,也实现了懒加载。

我个人是比较偏爱这种方式实现单例的,但是这种方式实现有个缺点就是:初始化的时候没法传值给单例类。这个时候就可以使用上面懒加载的写法四去实现单例了。

四、枚举

可以看到用枚举实现单例非常简单,使用也很简单:

// 获取单例对象
Sington3.INSTANCE
// 假如枚举类中有一个方法 getString(),就可以这样调用
Sington3.INSTANCE.getString()

看下优缺点:

优点:

  1. 可以保证单例在序列化传输中的问题(实现了序列化接口)
  2. 保证单例不被反射创建对象(JVM 层面禁止反射)
  3. 线程安全
  4. 书写简单

缺点:

  1. 不能懒加载
  2. 运行时占用内存比非枚举的大很多

总结

上面分别介绍了饿汉式、懒汉式、静态内部类、枚举四种单例实现方式,在总结下特点

单例实现方式 代码量 线程安全 是否懒加载 序列化传输能否保证对象唯一 能否反射创建对象
饿汉式 较少
懒汉式 较多
静态内部类 较多
枚举 极少

其实关于后面的序列化传输在 Android 开发中基本用不到(可能我还没接触到),关于反射创建对象也属于非常规手段,也不太能遇到,真的要考虑这种情况,也是可以通过代码去实现序列化传输和禁止反射创建对象的。

其实开发中我们最多的考虑因素就是 线程安全和懒加载 ,我个人是比较喜欢静态内部类形式的,但是还是要根据实际业务代码选择,比如我需要在创建单例对象的时候接受参数,并且还要求单例懒加载,静态内部类就不合适了,用懒汉式会更好点。

至于枚举实现单例,我觉得 Android 里面还是少用枚举,首先不能懒加载,其次占用的内存还大,看起来也没其他方式有安全感,何必呢。

欢迎关注我的公众号:

我的公众号