vlambda博客
学习文章列表

单例模式的5种写法及分析

一、饿汉模式

public class Singleton {
// 私有构造函数 private Singleton(){}
// 单例对象 private static Singleton singleton = new Singleton();
// 静态工厂 public static Singleton getInstance(){ return singleton; }}



  1. 通过私有的构造函数,防止其他处进行new操作,从而创建多个对象,保证一个类只能构建一个对象。

  2. singleton是该类的单例对象,初始值主动构建对象单例对象Singleton。

  3. 静态工厂方法getInstance(),通过静态工厂方法,直接返回单例对象。


饿汉模式从类初始化,就创建了单例对象,如果该对象在系统中从未使用过,就浪费了资源,没有实现懒加载的作用。


二、懒汉模式


public class Singleton {
// 私有构造函数 private Singleton(){}
// 单例对象 private static Singleton singleton = null;
// 静态工厂 public static Singleton getInstance(){ if(singleton == null){ singleton = new Singleton(); } return singleton; }}


  1. 通过私有的构造函数,防止其他处进行new操作,从而创建多个对象,保证一个类只能构建一个对象。

  2. singleton是该类的单例对象,初始值为null,需要创建对象时,在通过静态工厂进行创建。

  3. 静态工厂方法getInstance(),通过静态工厂方法,获取单例对象。


相比饿汉式,懒汉式实现了懒加载的作用,当使用到该单例对象时,才会去创建对象,实现按需加载。但是懒汉式存在着多线程的安全问题,在多线程的环境下,当singleton对象还是空时,用可能A,B两个线程同时访问getInstance()方法,并同时进入到 if(singleton == null) 判断语句中,执行了 new Singleton() 操作,这样singleton就被创建了两次。


三、双重检验机制


public class Singleton {
// 私有构造函数 private Singleton(){}
// 单例对象 private static Singleton singleton = null;
// 静态工厂 public static Singleton getInstance(){ if(singleton == null){ synchronized (Singleton.class){ if(singleton == null){ singleton = new Singleton(); } } } return singleton; }}


  1. 为了防止在多线程环境下,多次构建对象的情况,在new Singleton() 之前添加 synchronized 同步锁,锁住整个对象。

  2. 当singleton对象还是空时,A,B两个线程同时访问getInstance()方法,并同时通过 if(singleton == null) 判断语句,只用一个线程获取到同步锁,另一个线程只能等待,当构建单例对象完成后,释放同步锁,另一个线程获取同步锁后,再次判断单例对象是否为空,此时已由上一个线程创建完,单例对象不为空,不通过判断语句。如果不做第二次判断的话,当两个线程都进入了第一次判读,另一个线程获得同步锁后,还会再次创建单例对象。


但是,双重检验机制也非绝对的安全,有可能因为JVM编译器的指令重排的原因,返回一个没有初始化完成的单例对象。


分析:


先来描述A,B两个线程同时执行 getInstance() 方法,A先获得锁,先创建了 singleton 对象,B进入第二次 if(singleton == null) 判断语句,singleton 对象已经不为空,判断不通过,返回 singleton 对象。


上面的流程看似没有任何问题,但是这里面涉及到了JVM编译器的指令重排,情况可能就会有不一样的结果。


假如执行创建对象的语句 singleton = new Singleton(); 会被JVM编译器编译成以下的指令:


1)分配对象的内存空间;

2)初始化对象;


这种情况下的指令顺序,对于上述的流程,是没有问题的,但是,由于经过JVM和CPU的优化,指令有可能重排成下面的顺序:


1)分配对象的内存空间;

2)初始化对象;


当A线程执行完1)3)步时,此时单例对象还未完成初始化,但是已经不为null了,B线程进入到 if(singleton == null) 判断语句,判断结果为false,返回一个还没有初始化的单例对象,从而会引发报错。


我们可以在单例对象前加入一个volatile,防止指令重排。


改进版


public class Singleton {
// 私有构造函数 private Singleton(){}
// 单例对象 private volatile static Singleton singleton = null;
// 静态工厂 public static Singleton getInstance(){ if(singleton == null){ synchronized (Singleton.class){ if(singleton == null){ singleton = new Singleton(); } } } return singleton; }}


JMM(内存模型)允许编译器在生成指令顺序的时候,可以插入特定类型的内存屏障来禁止指令重排序。volatile关键字就是内存屏障。当编译器在生成指令顺序的时候,发现了volatile,就直接忽略掉。不再重排序了。当使用volatile关键字修饰后,JVM的指令始终保持以下的顺序:


1)分配对象的内存空间;

2)初始化对象;


四、内部静态类


public class Singleton {
private static class LazyHolder { private static final Singleton INSTANCE = new Singleton(); }
private Singleton (){}
public static Singleton getInstance() { return LazyHolder.INSTANCE; }}


INSTANCE对象初始化的时机并不是在单例类Singleton被加载的时候,而是在第一次调用getInstance方法时,虚拟机才会加载静态内部类LazyHolder并初始化INSTANCE 。只有一个线程可以获得对象的初始化锁,其他的线程是无法进行对象的初始化,保证对象的唯一性。因此这种实现方式是利用classloader的加载机制来实现懒加载,并保证构建单例的线程安全。


但是,内部静态类的方式,也存在着问题:无法防止反射来重复构建对象。


反射示例:


public class Main {
public static void main(String[] args) {
try { // 获得构造器 Constructor con = Singleton.class.getDeclaredConstructor(); // 设置为可访问 con.setAccessible(true); // 构造两个不同的对象 Singleton test1 = (Singleton) con.newInstance(); Singleton test2 = (Singleton) con.newInstance(); // 验证是否是不同对象 System.out.println(System.identityHashCode(test1)); System.out.println(System.identityHashCode(test2)); System.out.println(test1.equals(test2)); }catch (Exception e) { e.printStackTrace(); }

}
}


控制台结果:

18450665811018937824false


从结果上看,毫无疑问,这是两个不同的对象。


五、枚举方式


public enum SingletonEnum { INSTANCE;}


使用枚举的方式实现单例模式,可以利用枚举的语法糖,JVM会阻止反射创建对象,而且创建的线程是安全的,任何情况下都是单例的。


枚举类对象被反序列化的时候,保证反序列的返回结果是同一对象。


原理:

  • 枚举类隐藏了私有的构造函数

  • 枚举类的域是相应类型的一个实例对象


但是,枚举类实现的单例,并非懒加载,其对象在枚举类加载的时候就被初始化。


使用枚举的方式实现单例模式是《Effective Java》中推荐的单例模式之一,但是在实际项目中比较少使用,主要是因为可读性并不高,而且在项目中,比较少的情况下利用反射去构建单例对象。



©白色的野骆驼

bug与你同在