【设计模式】单例模式

217 阅读5分钟

模式介绍

单例对象的类只能允许一个实例存在。

  1. 单例类只有一个实例对象;
  2. 该单例对象必须由单例类自行创建;
  3. 单例类对外提供一个访问该单例的全局访问点。

实现

立即加载 / “饿汉模式”

public class HungrySingleton {

    private static final HungrySingleton instance = new HungrySingleton();

    private HungrySingleton() {
    }

    public static HungrySingleton getInstance() {
        return instance;
    }
}

饿汉模式的特点是初始化类的时候就创建好了单例,保证在调用 getInstance 方法之前单例已经存在了。由于这个类在整个生命周期中只会被加载一次,因此只会创建一个实例,即能够充分保证单例。

优点: 比较简单,类加载的时候就完成实例化。避免了线程同步问题。

缺点: 在类加载的时候就完成实例化,没有达到Lazy Loading的效果。如果自始至终都未使用过这个实例,则会造成资源的浪费。

延迟加载 / “懒汉模式”

线程不安全

public class LazySingleton {

    // 将自身实例化对象设置为一个属性,并用static修饰
    private static LazySingleton instance;

    // 构造方法私有化
    private LazySingleton() {
    }

    // 静态方法返回该实例
    public static LazySingleton getInstance() {
        if(instance == null) {
            instance = new LazySingleton();
        }
        return instance;
    }
}

image.png

线程不安全原因:如上图,多线程情况下,线程A和线程B同时访问 getInstance() 方法。因为 instance 是空,所以两个线程同时通过了条件判断,开始执行 new 操作,这样 instance 就被构建了两次。

优点: 实现起来比较简单,当类 LazySingleton 被加载的时候,静态变量 static 的 instance 未被创建并分配内存空间,当 getInstance 方法第一次被调用时,初始化 instance 变量,并分配内存,因此在某些特定条件下会节约了内存。

缺点: 在多线程环境中,这种实现方法是完全错误的,根本不能保证单例的状态。

线程安全

public class LazySingleton {

    // 将自身实例化对象设置为一个属性,并用static修饰
    private static LazySingleton instance;

    // 构造方法私有化, private 避免类在外部被实例化
    private LazySingleton() {
    }

    // 静态方法返回该实例,加synchronized关键字实现同步
    public static synchronized LazySingleton getInstance() {
        if(instance == null) {
            instance = new LazySingleton();
        }
        return instance;
    }
}

优点:在多线程情形下,保证了“懒汉模式”的线程安全。

缺点:众所周知在多线程情形下,synchronized 方法通常效率低,显然这不是最佳的实现方案。

DCL双检查锁机制

public class DCLSingleton {
    
    private volatile static DCLSingleton instance = null;  // 单例对象
    
    // 私有构造函数
    private DCLSingleton() {
    }  
    
    // 静态工厂方法
    public static DCLSingleton getInstance() {
        if (instance == null) {      // 双重检测机制
            synchronized (DCLSingleton.class){  // 同步锁
                if (instance == null) {     // 双重检测机制
                    instance = new DCLSingleton();
                }
            }
        }
        return instance;
    }
}

1.为了防止 new DCLSingleton() 被执行多次,因此在 new 操作之前加上 Synchronized 同步锁,锁住整个类(注意,这里不能使用对象锁)。

2.进入 Synchronized 临界区以后,还要再做一次判空。因为当两个线程同时访问的时候,线程A构建完对象,线程B也已经通过了最初的判空验证,不做第二次判空的话,线程B还是会再次构建 instance 对象。

  • 第一步 线程A和线程B同时执行 getInstance() 方法

image.png

  • 第二步 线程B获取不到锁,线程A初始化对象

image.png

  • 第三步 线程A结束

image.png

  • 第四步 线程B进行判空校验

image.png

  • 第五步 线程B结束

image.png

volatile

假如没有用 volatile 修饰,会出现啥漏洞?

这里涉及到了JVM编译器的指令重排

比如java中简单的一句 instance = new DCLSingleton(),会被编译器编译成如下JVM指令:

memory =allocate(); //1:分配对象的内存空间

ctorInstance(memory); //2:初始化对象

instance =memory; //3:设置instance指向刚分配的内存地址

但是这些指令顺序并非一成不变,有可能会经过JVM和CPU的优化,指令重排成下面的顺序:

memory =allocate(); //1:分配对象的内存空间

instance =memory; //3:设置instance指向刚分配的内存地址

ctorInstance(memory); //2:初始化对象

当线程A执行完1,3时,instance 对象还未初始化,但已经不再指向null。此时如果线程B抢占到CPU资源,执行 if(instance == null)的结果会是false,从而返回一个没有初始化完成的instance对象

静态内部类

public class StaticInnerSingleton {
    private static class StaticInner {
        private static final StaticInnerSingleton INSTANCE = new StaticInnerSingleton();
    }

    private StaticInnerSingleton (){
    }

    public static StaticInnerSingleton getInstance() {
        return StaticInner.INSTANCE;
    }
}
  1. 外部无法访问静态内部类 StaticInner,通过调用 StaticInnerSingleton.getInstance() 获取单例对象INSTANCE。

  2. 此方式并非是在类加载的时候就将 INSTANCE 对象初始化,而是在调用 getInstance() 方法的时候。因此这种实现方式是利用ClassLoader的加载机制来实现懒加载,并保证线程安全。

静态内部类的实现方式依然无法解决利用反射重复创建实例的问题。如下:

public class CrackStaticInner {
    public static void main(String[] args) {
        //获得构造器
        Constructor con = null;
        //构造两个不同的对象
        StaticInnerSingleton singleton1 = null;
        StaticInnerSingleton singleton2 = null;
        try {
            con = StaticInnerSingleton.class.getDeclaredConstructor();
            //设置为可访问
            con.setAccessible(true);

            singleton1 = (StaticInnerSingleton)con.newInstance();
            singleton2 = (StaticInnerSingleton)con.newInstance();
        } catch (NoSuchMethodException e) {
            e.printStackTrace();
        } catch (InstantiationException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        } catch (InvocationTargetException e) {
            e.printStackTrace();
        }
        //验证是否是不同对象
        System.out.println("singleton1与singleton2是否是相同实例: " + singleton1.equals(singleton2));
    }
}

image.png

我们发现,最终的结果是false,两者实例并不是同一个。那如何才能解决反射打破单例的情况出现呢?

枚举

public enum SingletonEnum {
    INSTANCE;
}

public class EnumSingleton {
    public static void main(String[] args) {
        //获得构造器
        Constructor con = null;
        //构造两个不同的对象
        SingletonEnum singleton1 = null;
        SingletonEnum singleton2 = null;
        try {
            con = SingletonEnum.class.getDeclaredConstructor();
            //设置为可访问
            con.setAccessible(true);

            singleton1 = (SingletonEnum)con.newInstance();
            singleton2 = (SingletonEnum)con.newInstance();
        } catch (NoSuchMethodException e) {
            e.printStackTrace();
        } catch (InstantiationException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        } catch (InvocationTargetException e) {
            e.printStackTrace();
        }
        //验证是否是不同对象
        System.out.println("singleton1与singleton2是否是相同实例: " + singleton1.equals(singleton2));
    }
}

执行,在获取构造器的时候,程序抛出异常:

image.png

使用枚举实现单例:

public enum SingletonEnum {
    INSTANCE;

    public void doSomething() {
        System.out.println("doSomething");
    }
}

public class EnumSingleton {
    public static void main(String[] args) {
        SingletonEnum.INSTANCE.doSomething();
    }
}

直接通过 SingletonEnum.INSTANCE.doSomething() 的方式调用即可。方便、简洁又安全。