关于单例模式,之前好几次都想把单例模式的几种形式学习下,记录下来。之前在面试当中也有问到过单例模式,只记得当时自己写了一种,面试官不是很满意。最近自己找了几篇关于单例模式的文章,认真拜读了下。总结了下单例模式的几种形式,以及每种形式的优点和弊端。
1.饿汉式
public class Singleton{
private Singleton(){}
private static final Singleton single=new Singleton();
public static Singleton getInstance(){
return single;}
}
饿汉式在加载类时就创建了对象,是典型的空间换时间。
上述这种实现方法叫饿汉式,从代码中可以看到,只能通过Singleton.getInstance()方法获得Singleton的实例,而这个实例是静态对象,并且在声明的时候就初始化了,这就保证了Singleton对象的唯一性,以后不再改变。类加载慢,但是获取对象速度快,线程安全。
2.懒汉式
public class Singleton{
private Singleton(){}
private static Singleton single=null;
public static synchronized Singleton getInstance(){
if(single==null){
single=new Singleton();
}
return single;
}
}
懒汉模式在每次获取实例时都会进行判断,是典型的时间换空间。getInstance()方法中添加了synchronized关键字,也就是上面所说的在多线程下保证单例对象唯一性的手段。但是即使instance已经被初始化,每次调用getInstance()方法都会进行同步,浪费不必要的资源,这也就是懒汉模式的最大问题。因此这种模式一般不建议使用。
synchronized保证一个时间内只能有一个线程得到执行,另一个线程必须等待当前线程执行完才能执行,使得线程安全。缺点是每次调用getInstance方法都进行同步,造成了不必要的同步开销。
3.双重校验(Double Check Lock),简称DCL模式
public class Singleton {
private static volatile Singleton single = null;
private Singleton() {}
public static Singleton getInstance() {
//第一层校验,为了避免不必要的同步
if (single == null) {
synchronized (Singleton.class) {
//第二层校验,实例null的情况下才创建
if (single == null)
single = new Singleton();
}
}
return single;
}
}
这种方式它的亮点在于Singleton.getInstance()方法中对single进行了两层判空,第一层判空是为了避免不必要的同步,第二层判空是为了在single为null的情况下才创建实例。
DCL失效问题:假设线程A执行到single=new Singleton()语句,这看上去像是一句代码,实际上它并不是一个原子操作,它大致做了三件事情:
(1)给single的实例分配内存
(2)调用Singleton的构造函数,初始化成员字段。
(3)将single对象指向分配的内存空间(此时single就不是null)
但是由于在多个线程并发执行时,初始化成员变量和对象实例化顺序会被打乱。因此执行顺序可能是1-2-3,也可能是1-3-2,如果是后者,并且3执行完毕、2未执行之前被切换到B线程上,这时的single因为已经在线程A内执行过了第三点,single已经是非空了,所以线程B直接取走single,再使用就会出错,这就是DCL失效问题。
或者再详细点的解释:
instance=new Singleton();
它并不是一个原子操作。事实上,它可以“抽象”为下面几条JVM指令:
memory = allocate(); //1.分配对象的内存空间
initInstance(memory); //2.初始化对象
instance = memory; //3.设置instance指向刚分配的内存地址
上面的操作2依赖于操作1,但是操作3并依赖于操作2,所以JVM可以以优化为目的对它们进行重排序,经过重排序后如下:
memory = allocate(); //1.分配对象的内存空间
instance = memory; //3.设置instance指向刚分配的内存地址(此时对象还未初始化)
initInstance(memory); //2.初始化对象
可以看到指令重排之后,操作3排在了操作2之前,即引用instance指向内存memory时,这端崭新的内存还未初始化。此时,如果另一个线程调用getInstance方法,由于instance已经指向了一块内存空间,从而if条件判断为false,方法返回instance引用,用户得到了没有完成初始化的“半个”实例。
解决这个问题,只需要将instance声明为volatile变量:
private static volatile Singleton instance;
也就是说,在只有DCL没有volatile的懒加载单例模式中,仍然存在着并发陷阱。
4.静态内部类单例模式
public class Singleton {
private Singleton() {}
public static Singleton getInstance() {
return SingletonHolder.single;
}
private static class SingletonHolder {
private static final Singleton single = new Singleton();
}
/**
*为了杜绝对象在反序列化重新生成对象,则重写Serializable的私有方法
/
private Object readResolve() throws ObjectStreamException{
return SingletonHolder.single;}
}
下面是借鉴了一位大神的文章 https://blog.csdn.net/mnb65482/article/details/80458571
这种是推荐使用的单例模式实现方式。当第一次加载Singleton类时并不会初始化,只有在第一次调用getInstance()时才会初始化。这种方式不仅能够保证线程安全,也能保证单例对象的唯一性,同时也延长了单例的实例化。上面的代码重写了readResolve()方法,这是因为通过序列化可以将一个单例的实例对象写到磁盘,然后读回来,从而获得一个实例。即使构造函数是私有的,反序列化时依然可以通过特殊的途径去创建类的一个新的实例,相当于调用该类的构造函数。反序列化提供了一个特别的构造函数,类中具有一个私有的、被实例化的方法readResolve(),这个方法可以让开发人员控制对象的反序列化。重写该方法返回SingletonHolder.single,而不是默认的生成新的实例,从而保持单例。
静态内部类的优点是:外部类加载时并不需要立即加载内部类,内部类不被加载则不去初始化INSTANCE,故而不占内存。即当SingleTon第一次被加载时,并不需要去加载SingleTonHoler,只有当getInstance()方法第一次被调用时,才会去初始化INSTANCE,第一次调用getInstance()方法会导致虚拟机加载SingleTonHoler类,这种方法不仅能确保线程安全,也能保证单例的唯一性,同时也延迟了单例的实例化。
那么,静态内部类又是如何实现线程安全的呢?首先,我们先了解下类的加载时机。
类加载时机: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()方法,调用的是SingleTonHoler.INSTANCE,取的是SingleTonHoler里的INSTANCE对象,跟上面那个DCL方法不同的是,getInstance()方法并没有多次去new对象,故不管多少个线程去调用getInstance()方法,取的都是同一个INSTANCE对象,而不用去重新创建。当getInstance()方法被调用时,SingleTonHoler才在SingleTon的运行时常量池里,把符号引用替换为直接引用,这时静态对象INSTANCE也真正被创建,然后再被getInstance()方法返回出去,这点同饿汉模式。那么INSTANCE在创建过程中又是如何保证线程安全的呢?在《深入理解JAVA虚拟机》中,有这么一句话:
虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。如果在一个类的<clinit>()方法中有耗时很长的操作,就可能造成多个进程阻塞(需要注意的是,其他线程虽然会被阻塞,但如果执行<clinit>()方法后,其他线程唤醒之后不会再次进入<clinit>()方法。同一个加载器下,一个类型只会初始化一次。),在实际应用中,这种阻塞往往是很隐蔽的。
故而,可以看出INSTANCE在创建过程中是线程安全的,所以说静态内部类形式的单例可保证线程安全,也能保证单例的唯一性,同时也延迟了单例的实例化。
那么,是不是可以说静态内部类单例就是最完美的单例模式了呢?其实不然,静态内部类也有着一个致命的缺点,就是传参的问题,由于是静态内部类的形式去创建单例的,故外部无法传递参数进去,例如Context这种参数,所以,我们创建单例时,可以在静态内部类与DCL模式里自己斟酌。
5.使用容器
public class SingletonManager {
private SingletonManager() {}
private static Map<String, Object> instanceMap = new HashMap<>();
public static void registerInstance(String key, Object instance) {
if (!instanceMap.containsKey(key)) {
instanceMap.put(key, instance);
}
}
public static Object getInstance(String key) {
return instanceMap.get(key);
}
}
public class Singleton {
Singleton() {}
public void doSomething() {
Log.d("wxl", "doSomeing");
}
}
使用:
SingletonManager.registerInstance("Singleton", new Singleton());
Singleton single = (Singleton) SingletonManager.getInstance("Singleton");
single.doSomething();
6.枚举
public enum SingletonEnum {
INSTANCE;
public void doSomething() {
Log.d("wxl", "SingletonEnum doSomeing");
}
}
使用:
SingletonEnum.INSTANCE.doSomething();