参考资料:
老司机来教你单例的正确姿势
《Android源码设计模式解析与实战》
前言
单例模式可以说是应用最广泛的模式了, 面试也经常被问到, 经常会被要求能够手写单例, 所以我今天也来总结一下
单例模式的定义
确保某一个类只有一个实例, 并且自定实例化并向整个系统提供这个实例
单例模式的使用场景
确保某个类有且只有一个对象的场景, 避免产生多个对象消耗过多的资源, 或者某种类型的对象只应该有且只有一个.例如, 创建一个对象需要消耗的资源过多, 如要访问IO和数据库等资源
单例的UML类图
插点题外话说一下怎么看UML类图, 例如这里的Singleton矩形框就代表一个类, 第一层显示类的名称, 抽象类用斜体表示; 第二层是类的特性, 通常是字段和属性; 第三层是类的操作, 通常是方法. ‘+’表示public, ‘-‘表示private, ‘#’表示protected, 例如这里的getInstance()方法是public的, 构造方法是private的.
与类图有区别的是接口图, 顶端有<<interface>>
显示
实现单例模式有一下几个关键点:
- 构造函数不对外开放, 一般为private, 是的客户端不能通过new的形式手动构造单例类的对象
- 通过一个静态方法或者枚举返回单例类对象
- 在多线程环境中也需要确保单例类的对象有且只有一个
- 确保单例类对象在反序列化时不会重新构建对象
最简单的单例之饿汉式
1
2
3
4
5
6
7
8public class Singleton {
private static Singleton INSTANCE = new Singleton();
private Singleton() {}
public static Singleton getInstance() {
return INSTANCE;
}
}
这种单例的写法最简单,在类加载的时候就创建了单例对象, 并且保证了Sigleton对象的唯一性,以后不再改变,所以天生是线程安全的。但是缺点是一旦类被加载,单例就会初始化,没有实现懒加载。而且有时候这个对象的构造方法需要一个参数比如context的时候这种方法就不太适合了.
懒汉式
1 | public class Singleton { |
这段代码虽然实现了懒加载, 但是多线程环境下会出现线程安全的问题, 当多个线程调用getInstance()方法时, 可能会创建多个实例, 所以我们改成这样: 给getInstance()方法加个synchronized关键字.使用synchronized关键字修饰一个方法, 该方法中所有的代码都是同步的.静态的synchronized方法它的锁对象就是该类的字节码对象
1 | public static synchronized Singleton getInstance() { |
但这样又出现了性能问题,简单粗暴的同步整个方法,导致同一时间内只有一个线程能够调用getInstance方法。
再次优化代码, 仅仅对初始化部分的代码进行同步
1 | public class Singleton { |
执行两次检测很有必要:当多线程调用时,如果多个线程同时执行完了第一次检查,其中第一个进入同步代码块的线程创建了实例,后面的线程因第二次检测不会创建新实例。这种写法还有一个名字叫做Double Check Locking(双重加锁),简称DCL.看似完美, 但是这段代码还是有问题.INSTANCE = new Singleton();
这个语句时其实做了三步工作,
- 给 INSTANCE 分配内存
- 调用构造函数初始化成员字段
- 将INSTANCE对象指向分配的内存空间(INSTANCE 不再为 null),由于 Java 虚拟机是乱序执行的,所以执行顺序可能是1-2-3,也有可能是1-3-2.
如果是后者,则在 3 执行完毕、2 未执行之前,被线程二抢占了,这时 instance 已经是非 null 了(但却没有初始化),所以线程二会直接返回 instance,然后使用,然后顺理成章地报错, 严重会导致程序崩溃.
为了解决这个问题, 可以这样:
1 | public class Singleton { |
使用 volatile 的主要原因是其另一个特性:禁止指令重排序优化。也就是说, 在 volatile 变量的赋值操作后面会有一个内存屏障(生成的汇编代码上),读操作不会被重排序到内存屏障之前。比如上面的例子,取操作必须在执行完 1-2-3 之后或者 1-3-2 之后,不存在执行到 1-3 然后取到值的情况。从「先行发生原则」的角度理解的话,就是对于一个 volatile 变量的写操作都先行发生于后面对这个变量的读操作(这里的 “后面” 是时间上的先后顺序)
到了这里就不得不说说, volatile这个关键字了, 也是个面试常客呢.我经常看到的说法是这样的, volatile可以保证在一个线程的工作内存中修改了该变量的值,该变量的值立即能回显到主内存中,从而保证所有的线程看到这个变量的值是一致的.参考这篇 面试官最爱的 volatile 关键字 和Java 之 volatile 详解我来简单谈谈.
被 volatile 修饰的共享变量,就具有了以下两点特性:
- 保证了不同线程对该变量操作的内存可见性;
- 禁止指令重排序
可见性
可见性的含义是指:一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。i = 8
如果 i 用 volatile 修饰的话,当有一个线程在主存中读取 i, 并在自己的工作内存中进行修改的时候,修改后的值会立即强制同步到主存中,并且其他线程中这个值的缓存也都无效。相比之下普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。禁止重排序
重排序的含义是:处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。
volatile 禁止指令重排序的含义:
- 当程序执行到 volatile 变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行
- 在进行指令优化时,不能将在对 volatile 变量的读操作或者写操作的语句放在其后面执行,也不能把 volatile 变量后面的语句放到其前面执行。
举例说明:
1 | x = 10; //语句1 |
这个例子中,由于 volflag 被 volatile 修饰,所以语句 3 不会被重排到语句 1、语句 2 前面,也不会被重排到语句 4、语句 5 的后面,但语句 1、2 和语句 4、5 的顺序是不能保证的。
另外 volatile 可以保证在执行到语句 3 的时候语句 1、2 是执行完毕的,语句 4、5 是没有执行的,并且语句 1、2 的执行结果是对语句 4、5 是可见的
这里我只是参考了别人的文章关于 Java volatile 关键字简单总结了一下, 其实这个volatile深入起来还有很多东西可以说, 以后有时间的话我开一篇博客详细总结一下.
静态内部类单例模式
1 | public class Singleton { |
这是我平时比较喜欢用的单例, 使用内部类来维护单例的实例.当 Singleton 被加载时,其内部类并不会被初始化,故可以确保当 Singleton 类被载入 JVM 时,不会初始化单例类。只有 getInstance() 方法调用时,才会初始化 instance。同时,由于实例的建立是时在类加载时完成,故天生对多线程友好,getInstance() 方法也无需使用同步关键字。
最佳实践单例之枚举
1 | public enum Singleton { |
使用Singleton.INSTANCE.doSomething();
《Effective Java》一书推荐此方法,说 “单元素的枚举类型已经成为实现 Singleton 的最佳方法”。不过 Android 使用 enum 之后的 dex 大小增加很多,运行时还会产生额外的内存占用,因此官方强烈建议不要在 Android 程序里面使用到enum
总结
在实际开发过程中, 我比较喜欢用内部类的单例模式, 其次是安全的DCL模式, 再次是饿汉式, 其他的基本不会使用.