单例模式的几种形式

   关于单例模式,之前好几次都想把单例模式的几种形式学习下,记录下来。之前在面试当中也有问到过单例模式,只记得当时自己写了一种,面试官不是很满意。最近自己找了几篇关于单例模式的文章,认真拜读了下。总结了下单例模式的几种形式,以及每种形式的优点和弊端。

  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的情况下才创建实例。

我们会发现这里使用了volatile关键字,因为多个线程并发时初始化成员变量和对象实例化顺序可能会被打乱,这样就出错了,volatile可以禁止指令重排序。双重校验虽然在一定程度解决了资源的消耗和多余的同步,线程安全问题,但在某些情况还是会出现双重校验失效问题,即DCL失效。 重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。

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();
枚举单例模式,我们可能使用的不是很多,但《Effective Java》一书推荐此方法,说“单元素的枚举类型已经成为实现Singleton的最佳方法”。不过Android使用enum之后的dex大小增加很多,运行时还会产生额外的内存占用,因此官方强烈建议不要在Android程序里面使用到enum,枚举单例缺点也很明显。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值