定义
确保某个类只有一个实例,而且自行实例化并向整个系统提供这个实例。
应用场景
确保某个类有且只有一个对象的场景,避免产生多个对象消耗过多资源,或者某种类型的对象只应该有且只有一个。例如,创建一个对象需要消耗的资源过多,如果要访问IO和数据库等资源,这时就要考虑使用单例模式。
关键点
- 构造函数不对外开放。
- 通过静态方法或枚举返回单例类对象。
- 确保单例类的对象有且只有一个,特别是在多线程环境下。
- 确保单例类的对象在反序列化时不会重新构造对象。
单例模式实现方式
实现方式比较多,但是都有各自的优缺点。
饿汉模式
饿汉模式是在类加载时就创建好单例类实例。
实现
public class HungerSingleton {
private static HungerSingleton mInstance = new HungerSingleton();
private HungerSingleton() {
}
public static HungerSingleton getInstance() {
return mInstance;
}
}
instance是静态对象,在声明的时候就已经初始化了,保证了对象的唯一性。
优点:
- 简单明了,不需要关心线程同步的问题。
- 获取对象比较快,不需要做其他任何工作。
缺点:
- 类加载时因为需要初始化对象,所以比较慢。
- 可能会产生很多多余无用的对象。
适用场景
如果单例模式实例在系统中经常会被用到,选择此方式实现单例比较合适。但是如果对象初始化工作复杂且在程序运行过程中不一定会使用到此单例对象,那饿汉模式就不太适合了。
懒汉模式
懒汉模式不同于饿汉模式的是,它是在第一次调用getInstance()时才初始化单例对象。
实现
public class LazySingleton {
private static LazySingleton mInstance;
private LazySingleton() {
}
public static synchronized LazySingleton getInstance() {
if (mInstance == null) {
mInstance = new LazySingleton();
}
return mInstance;
}
}
给getInstance()加了synchronized关键字,这样就能够保证不会有两个线程同时去访问这个方法创建重复对象了,将getInstance()变为同步方法保证了单例对象的唯一性。但是在初始化之后获取对象时都不可避免的会造成同步开销。
优点
- 节约资源,在使用到时才会初始化单例对象。
缺点
- 第一次调用反应稍慢,需要加载的时间。
- 每次调用都会进行同步,造成不必要的同步开销。
DCL模式
DCL优点是既能在使用使才初始化对象,又能保证线程安全,且在初始化之后再调用getInstance()不进行同步锁。
实现
public class DCLSingleton {
private static DCLSingleton mInstance = null;
private DCLSingleton() {
}
public static DCLSingleton getInstance() {
if (mInstance == null) {
synchronized (DCLSingleton.class) {
if (mInstance == null) {
mInstance = new DCLSingleton();
}
}
}
return mInstance;
}
}
外层对instance的判空避免了多余的同步工作,只有在初始化对象时才需要同步。
内层的instance的判空保证了对象的唯一。
DCL失效问题
这种方法暂时还是不安全的,并不能保证一定单例,还是有几率会失败的。
主要原因是new一个对象这个操作并不是原子性的,它会被编译为三条汇编指令,分为三个步骤。但是由于JVM的指令重排,这三个步骤的后两个步骤顺序不定。
- 分配内存空间。
- 初始化内存空间。
- 引用变量指向这块内存空间。
指令重排序是JVM为了优化指令,提高程序运行效率。指令重排序包括编译器重排序和运行时重排序。JVM规范规定,指令重排序可以在不影响单线程程序执行结果前提下进行。
指令重排并没有考虑多线程的情况,所以可能会出现下面的场景。
Thread 1 | Thread 2 |
---|---|
外层instance判空:yes | - |
进入同步代码块 | - |
内层instance判空:yes | - |
为单例对象分配内存空间 | - |
instance指向这块内存空间 | - |
- | 外层instance判空:no |
- | 直接返回未初始化完毕的instance对象 |
初始化这块内存空间 | - |
这样就会拿到一个未初始化完成的对象了,这就是DCL失效问题。
解决失效问题
在java5之后,具体化了volatile关键字,所以在java5之后,就可以用这个关键字来解决DCL失效问题了。
//使用 volatile修饰 instance
private volatile static DCLSingleton mInstance = null;
volatile防止指令重排序,禁止把new过程的指令与把引用赋值给变量的语句重排序,赋值只发生在new结束之后。这样就不会出现上述的失效问题了。
volatile或多或少会影响性能,但是保证了DCL的正确性。
优点
- 节约资源,在使用到时才会初始化单例对象。
缺点
- 第一次调用反应稍慢,需要加载的时间。
- 会有一定的机率发生问题。在jdk5之后可以使用volatile保证正确性,volatile或多或少会影响性能。
静态内部类
同样也是在第一次调用getInstance()才会初始化单例对象。
实现
第一次调用getInstance()时会导致虚拟机加载SingletonHolder类,初始化单例对象,这种方法不仅保证了线程安全,单例对象的唯一性,也延迟了单例对象的实例化,实现起来还非常简单。
public class StaticInnerSingleton {
private StaticInnerSingleton() {
}
public static StaticInnerSingleton getInstance() {
return SingletonHolder.mInstance;
}
private static class SingletonHolder {
private static final StaticInnerSingleton mInstance = new StaticInnerSingleton();
}
}
在类的初始化期间,JVM会去获取一个锁,这个锁可以同步多个线程对同一个类的初始化,从而保证了单例唯一。
枚举单例
这可能是最简单的单例实现了。枚举和普通类一样可以拥有字段和方法,并且默认枚举实例创建是线程安全的,在仍和时刻都是单例的,并且哪怕是支持反序列化,也不会生成新的单例对象。反序列化不同于普通类,枚举是根据名字去查找存在的对象,而不是重新创建对象。
实现
public enum EnumSingleton {
INSTANCE;
public int a = 2;
public int aaa() {
return a;
}
}
枚举在序列化的时候Java仅仅是将枚举对象的name属性输出到结果中,反序列化的时候则是通****过java.lang.Enum的valueOf方法来根据名字查找枚举对象。
支持反序列化
而上面几种方法,如果需要保证完全的单例,还需要去增加readResolve(),需要做如下修改,拿支持序列化的饿汉模式举例:
public class HungerSingleton implements Serializable {
private static final long serialVersionUID = 1L;
//...
private Object readResolve() throws ObjectStreamException {
return mInstance;
}
直接将单例对象返回而不是重新创建新对象。
集合实现
实现
通过一个map来保存所有的单例对象,这种方式换了一种思路,从单例自身转移到单例保存方式上,提供get()、register()来提供单例和获取单例。
public class MapSingletonManager {
private static Map<String, Object> objMap = new HashMap<>();
private MapSingletonManager() {
}
public static void registerService(String key, Object instance) {
if (!objMap.containsKey(key)) {
objMap.put(key, instance);
}
}
public static Object getService(String key) {
return objMap.get(key);
}
}
这种方法降低了耦合,不会像之前的方法一样将单例逻辑糅杂在类中。但是这种方式需要自己去创建单例对象,并且并不能保证堆中只有一个单例对象,只能保证在使用时,从map中提取出来的是同一个单例对象。
小结
书上推荐DCL和静态内部类的方式实现单例,最后总结一下单例模式的优缺点。
优点
- 减少了内存开销。
- 降低了系统性能开销。
- 避免对资源的多重占用。
- 可以设置全局访问点,优化和共享资源访问。
缺点
- 拓展困难,一般没有接口。
- 单例对象如果持有Context,就容易发生内存泄漏。
如果随便地传入一个Activity的Context,那么无论这个Activity是否还需要,它都因为Context被单例对象持有,所以Activity无法回收,只要项目活着,这个Activity就活着,所以就造成了内存泄漏。所以如果单例中需要Context,就最好传递Application的Context,因为Application的生命周期和应用一样长。