设计模式——单例模式(Singleton Pattern)

1.概述

单例模式(Singleton Pattern):确保某一个类只有一个实例,而且自行实例化并向整个系统提供这个实例,这个类称为单例类,它提供全局访问的方法。单例模式是一种创建型模式,是一种简单实用又复杂的设计模式。

2.实现

懒汉式和饿汉式都是一种比较形象的称谓。

  1. 懒汉式,既然比较懒,装载类的时候不创建对象,会一直等到要用到对象实例的时候才会去创建,这种技术又称为“延迟加载(Lazy Load)技术”。
  2. 饿汉式,既然比较饿,装载对象的时候就去创建对象实例。

2.1懒汉式

  1. 私有化构造方法:要想在运行期间控制某一个类的实例只有一个,首要的任务就要控制创建实例的地方,即不能随随便便就可以实例化对象。我们可以将构造器私有化,这样就能禁止类的外部直接使用new来创建对象。
    private LazySingleton(){
    
    ...}
  1. 提供获取实例的方法:将构造函数的可见性改为private后,虽然外部不能再使用new来创建对象,但是在LazySingleton的内部还是可以创建对象的,同时为了让外界访问到这个唯一实例,可以提供一个方法来返回类的实例,同时该方法要加上static,直接通过类来调用对象。因为这个方法是static的,那么属性也要被迫变成static的(这里并没有用到static的特性)。
	 private static LazySingleton lazySingleton = null;
	
	    public static LazySingleton getInstance(){
    
    
	        if(lazySingleton == null){
    
    
	            lazySingleton = new LazySingleton();
	        }
	        return lazySingleton;
	    }

2.1.1代码

public class LazySingleton {
    
    

    private static LazySingleton lazySingleton = null;

    private LazySingleton(){
    
    
     
    }

    public static LazySingleton getInstance(){
    
    
        if(lazySingleton == null){
    
    
            lazySingleton = new LazySingleton();
        }
        return lazySingleton;
    }
}

2.1.2多线程下的问题

在单线程情况下,上面的代码是没有问题的,但在多线程的环境下,是不安全的。我们假设thread0thread1同时来到了if(lazySingleton == null),因为此时lazySingleton是null,两个线程都通过了条件判断,开始new操作,这样一来,lazySingleton就被实例化了两次。
在这里插入图片描述
我们写两个类通过多线程debug的方式进行测试。

public class T implements Runnable {
    
    
    @Override
    public void run() {
    
    
        LazySingleton lazySingleton = LazySingleton.getInstance();
        System.out.println(Thread.currentThread().getName()+"  "+lazySingleton);
    }
}
public class Test {
    
    
    public static void main(String[] args) throws Exception{
    
    

        Thread t1 = new Thread(new T());
        Thread t2 = new Thread(new T());
        t1.start();
        t2.start();
        System.out.println("program end");
    }
}

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
由上图可知,lazySingleton实例化了两次。我们只需要在方法中加上synchronized即可。这样当thread0进入getInstance()时,thread1就处于阻塞状态,解决了线程安全问题。

  public synchronized static LazySingleton getInstance(){
    
    ...}

2.1.3 DoubleCheck双重检查

上面的代码虽然解决了线程安全的问题,但是每次调用getInstance()时都需要进行线程锁定判断,在多线程高并发环境中,将会导致系统性能大大降低。事实上,上述代码无须对整个getInstance()方法进行锁定,只需要锁定代码lazySingleton = new LazySingleton();即可。同时要注意,因为这个方法是静态方法,存在方法区并且整个JVM只有一份,所以要加类锁,即synchronized (LazyDoubleCheckSingleton.class){...}

   //LazyDoubleCheckSingleton 与上面LazySingleton作用一样
   public static LazyDoubleCheckSingleton getInstance(){
    
    
        if(lazyDoubleCheckSingleton == null){
    
    
            //类锁
            synchronized (LazyDoubleCheckSingleton.class){
    
    
                lazyDoubleCheckSingleton = new LazyDoubleCheckSingleton();
            }
        }
        return lazyDoubleCheckSingleton;
    }

上面代码看似解决了问题,但事实并非如此,使用上面代码来创建单例对象,还是会存在单例对象不唯一的情况。
假设某一瞬间,thread0thread1同时来到了if(lazySingleton == null),因为此时lazySingleton是null,两个线程都通过了条件判断,由于实现了synchronized加锁机制,thread0进入synchronized锁定的代码中执行操作,thread1处于阻塞状态,必须等到线程A执行完毕后才能进入synchronized锁定的代码。但是thread0执行完,thread1并不知道实例已经创建完成,将会继续创建实例,产生了多个单例对象,需要进一步改进,在synchronized锁定的代码里再进行一次if(lazySingleton == null),这种方式就称为双重检查锁定。同时由于JVM编译器的指令重排机制,同样会出现问题。这并不是百分百发生的,但既然存在安全隐患,我们就需要解决它。
当进行lazyDoubleCheckSingleton = new LazyDoubleCheckSingleton();时,JVM会进行下面的1,2,3操作(instancelazyDoubleCheckSingleton),其中2,3的操作顺序可能颠倒。在这里插入图片描述
假设线程0要按照1,3,2的顺序进行初始化对象,恰好在1,3步完成synchronized锁释放后,线程1进入synchronized代码块中,此时instance不为null,线程1比线程0首先访问了未初始化好的对象。我们只需要在instance对象前面增加一个修饰符volatile,就可以始终保持1,2,3的初始化顺序。这样在线程1看来,instance对象的引用要么指向null,要么指向一个初始化完成的instance,从而保证了安全。完整代码如下:

public class LazyDoubleCheckSingleton {
    
    

    private volatile static LazyDoubleCheckSingleton lazyDoubleCheckSingleton = null;

    private LazyDoubleCheckSingleton(){
    
    

    }

    public static LazyDoubleCheckSingleton getInstance(){
    
    
        if(lazyDoubleCheckSingleton == null){
    
    
            synchronized (LazyDoubleCheckSingleton.class){
    
    
                if(lazyDoubleCheckSingleton == null){
    
    
                    lazyDoubleCheckSingleton = new LazyDoubleCheckSingleton();
                    //1.分配内存给这个对象
                    //3.设置lazyDoubleCheckSingleton 指向刚分配的内存地址
                    //2.初始化对象
                    //intra-thread semantics
                    ---------------//3.设置lazyDoubleCheckSingleton 指向刚分配的内存地址
                }
            }
        }
        return lazyDoubleCheckSingleton;
    }
}

2.1.4反射攻击解决方案

在Java语言中,不仅仅可以通过new关键字直接创建对象,还可以通过反射机制创建对象。比如我们可以通过反射获取类中的属性,方法,构造器。即使这些的访问权限是private,我们也可以通过setAccessible()启动和禁用访问安全检查的开关,参数值为true则指示反射的对象在使用时应该取消Java语言访问检查,那么我们就可以对这个对象为所欲为了。
比如:

public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException, NoSuchFieldException {
    
    
        Constructor<LazySingleton> c = LazySingleton.class.getDeclaredConstructor();
        //参数值为true则指示反射的对象在使用时应该取消Java语言访问检查
        c.setAccessible(true);
        LazySingleton instance1 = c.newInstance();
        LazySingleton instance2 = LazySingleton.getInstance();
        System.out.println(instance1);
        System.out.println(instance2);
        System.out.println(instance1 == instance2);
    }

打印结果如下,很明显这两个对象是不一样的。
在这里插入图片描述
我们可以在私有构造器里面进行判断,如果lazySingleton对象已经实例化了,就抛出异常。

 private LazySingleton(){
    
    
        if (lazySingleton != null){
    
    
            throw new RuntimeException("单例构造器禁止反射调用");
        }
    }

其实对于饿汉式这样处理完全没有问题,但是懒汉式是有问题的,比如我们先通过反射创建一个对象,创建后的lazySingleton还是null,这个时候还是可以通过getInstance()获取lazySingleton对象的,这样就两个了。
其实我们还可以通过设置标志位的方式来解决这个问题。

public class LazySingleton {
    
    
    private static LazySingleton lazySingleton = null;
    private static boolean flag =true;
    private LazySingleton(){
    
    
        if (flag){
    
    
            flag = false;
        }else {
    
    
            throw new RuntimeException("单例构造器禁止反射调用");
        }
        if (lazySingleton != null){
    
    
            throw new RuntimeException("单例构造器禁止反射调用");
        }
    }
    public synchronized static LazySingleton getInstance(){
    
    
        if (lazySingleton == null){
    
    
            lazySingleton = new LazySingleton();
        }
        return lazySingleton;
    }
  }  

2.1.5序列化破坏单例模式分析及解决方案

 public static void main(String[] args) throws Exception{
    
    
        /**
         * 序列化测试
         */
        LazySingleton instance = LazySingleton.getInstance();
        ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("singleton_file"));
        oos.writeObject(instance);

        File file = new File("singleton_file");
        ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file));
        LazySingleton newInstance = (LazySingleton) ois.readObject();

        System.out.println(instance);
        System.out.println(newInstance);
        System.out.println(instance == newInstance);
    }

当我们进行上面的序列化和反序列化操作时,每次反序列化一个序列化的实例时,都会创建一个新的实例。
在这里插入图片描述
所以当我们想将单例类变成可序列化的,仅仅在声明上加上implements Serializable 是不够的, 为了维护并保证单例,必须声明所有实例域都是瞬时( transient )的,并提供一个 readResolve 方法。

[effective Java(第三版)]
readResolve 特性允许你用 readObject 创建的实例代替另一个实例[Serialization,3.7 ]对于一个正在被反序列化的对象,如果它的类定义了一个 readResolve 方法,并且具备正确的声明,那么在反序列化之后,新建对象上的 readResolve 方法就会被调用,然后,该方法返回的对象引用将被返回,取代新建的对象 ,在这个特性的绝大多数用法中,指向新建对象的引用不需要再被保留,因此立即成为垃圾回收的对象。
序列化形式并不需要包含任何实际的数据;所有的实例域都应该被声明为瞬时的 。事实上,如果依赖 readResolve 进行实例控制,带有对引用类型的所有实例域 必须 transient , 否则,那种破釜沉舟式的攻击者,就有可能在readResolve 方法被运行之前,保护指向反序列化对象的引用

   private static transient LazySingleton lazySingleton = null;

   private Object readResolve(){
    
    
        return lazySingleton;
   }

我们只需要将代码修改为上面的部分,就可以得到预期的结果。
在这里插入图片描述
通过对源码的简单分析,其实现的原理为先通过反射创建一个反序列化后的对象,如果单例对象中定义了readResolve()方法,则对前面生成的对象进行覆盖,来保证单例。
在这里插入图片描述

2.2饿汉式

这个方案装载对象的时候就去创建对象实例。在Java中,static有两个特性:

  1. static变量在类装载的时候进行初始化
  2. 多个实例的static变量会共享同一块内存区域
    所以定义一个静态变量来存储创建好的类实例
   private final static HungrySingleton hungrySingleton = new HungrySingleton();

因为饿汉式在类加载的时候就将自己实例化,无须考虑多线程访问的问题,可以确保实例的唯一性。

2.2.1代码

public class HungrySingleton implements Serializable {
    
    
    private final static HungrySingleton hungrySingleton = new HungrySingleton();
   
    private HungrySingleton(){
    
    
    }
    public static HungrySingleton getInstance(){
    
    
        return hungrySingleton;
    }
}

2.2.2反射攻击解决方案

同样因为类加载的时候就将自己初始化好了,所以当反射攻击的时候,只需要进行if (hungrySingleton != null)的操作即可。

 private HungrySingleton(){
    
    
        if (hungrySingleton != null){
    
    
            throw new RuntimeException("单例构造器禁止反射调用");
        }
    }

2.3静态内部类

饿汉式单例类不不能实现延迟加载,不管将来用不用,它始终占据内存;而懒汉式单例类安全控制繁琐麻烦,而且性能也会受到影响。即将要介绍的这种方式,能够将二者的缺点克服而兼顾优点,这种解决方案被称为Lazy initialization holder class (IoDH)模式.
首先介绍一下静态内部类的属性

  1. static修饰的成员式内部类,它的对象与外部类对象不发生依赖关系,其相当于其外部类的成员。
  2. 外部类初次加载,会初始化静态变量、静态代码块、静态方法,但不会加载内部类和静态内部类
  3. 调用静态内部类的变量时,外部类并没有加载。时间与外部类是否加载以及加载时间无关,静态内部类只有被调用时才会被加载,从而实现了延迟加载。
    由静态内部类的属性就可以知道,只要不使用这个静态内部类,那就不会创建对象实例,从而实现了延迟加载和线程安全。

2.3.1代码

public class StaticInnerClassSingleton {
    
    
    private static class InnerClass{
    
    
        private static StaticInnerClassSingleton staticInnerClassSingleton = new StaticInnerClassSingleton();
    }
    public static StaticInnerClassSingleton getInstance(){
    
    
        return InnerClass.staticInnerClassSingleton;
    }
    private StaticInnerClassSingleton(){
    
    
    }
}

当第一次调用getInstance()时,它第一次读取InnerClass.staticInnerClassSingleton,导致InnerClass内部类得到初始化,而这个类在装载并被初始化的时候,会初始化它的静态域。从而创建了StaticInnerClassSingleton 的实例,由于是静态的属性,因此只会在虚拟机装载类的时候初始化一次,并由JVM来保证它的线程安全性。

2.3.2反射攻击解决方案

与饿汉式处理方式基本一致

   private StaticInnerClassSingleton(){
    
    
        if (InnerClass.staticInnerClassSingleton != null){
    
    
            throw new RuntimeException("单例构造器禁止反射调用");
        }
    }

2.4枚举单例

《effective Java》第三版 中提到: 单元素枚举类型经常成为实现 Singleton的最佳方法。 由于在平时的开发过程中,枚举类用的并不多,所以提前总结一下枚举类的一些重要用法。

  1. 使用 enum 定义的枚举类默认继承了 java.lang.Enum类,因此不能再继承其他类
  2. 枚举类的构造器只能使用 private 权限修饰符
  3. 枚举类的所有实例必须在枚举类中显式列出(, 分隔 ; 结尾)。列出的实例系统会自动添加 public static final 修饰
  4. 必须在枚举类的第一行声明枚举类对象
    常用方法:
  • values():返回枚举类型的对象数组。该方法可以很方便地遍历所有的枚举值。
  • valueOf(String str):可以把一个字符串转为对应的枚举类对象。要求字符串必须是枚举类对象的“名字”。如不是,会有运行时异常:IllegalArgumentException
  • toString():返回当前枚举类对象常量的名称

2.4.1代码

public enum EnumInstance {
    
    

    INSTANCE;

    private Object data;

    public void setData(Object data) {
    
    
        this.data = data;
    }

    public Object getData() {
    
    
        return data;
    }

    public static EnumInstance getInstance(){
    
    
        return INSTANCE;
    }

}

2.4.2原理剖析

2.4.2.1序列化测试

 public static void main(String[] args) throws IOException, ClassNotFoundException, NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
    
    

        /**
         * 验证 枚举类如何防止序列化创建对象
         */
        EnumInstance instance = EnumInstance.getInstance();
        instance.setData(new Object());

        ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("singleton_file"));
        oos.writeObject(instance);

        File file = new File("singleton_file");
        ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file));
        EnumInstance newInstance = (EnumInstance) ois.readObject();

        System.out.println(instance.getData());
        System.out.println(newInstance.getData());
        System.out.println(instance.getData() == newInstance.getData());
    }

结果:
在这里插入图片描述
通过结果可以看出,枚举类单例模式可以很好的解决序列化问题。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
由上面的源码分析及查询资料可知,在序列化的时候Java仅仅是将枚举对象的name属性输出到结果中,反序列化的时候则是通过java.lang.EnumvalueOf方法根据名字查找枚举对象。同时,编译器是不允许任何对这种序列化机制的定制的,因此禁用了writeObjectreadObject等方法。

2.4.2.2反射攻击测试

   public static void main(String[] args) throws Exception{
    
    
        Constructor<EnumInstance> c = EnumInstance.class.getDeclaredConstructor(String.class, int.class);
        c.setAccessible(true);
        EnumInstance instance1 = c.newInstance("mazouri", 1);
    }

在这里插入图片描述
查看源码发现,如果发现枚举类型通过反射构造对象,是会抛出异常的。
在这里插入图片描述

2.4.2.3 JAD反编译

// Decompiled Using: FrontEnd Plus v2.03 and the JAD Engine
// Available From: http://www.reflections.ath.cx
// Decompiler options: packimports(3) 
// Source File Name:   EnumInstance.java

package com.mazouri.design.pattern.creational.singleton.lazy5;


public final class EnumInstance extends Enum
{
    
    

    public static EnumInstance[] values()
    {
    
    
        return (EnumInstance[])$VALUES.clone();
    }

    public static EnumInstance valueOf(String name)
    {
    
    
        return (EnumInstance)Enum.valueOf(com/mazouri/design/pattern/creational/singleton/lazy5/EnumInstance, name);
    }

    private EnumInstance(String s, int i)
    {
    
    
        super(s, i);
    }

    public void setData(Object data)
    {
    
    
        this.data = data;
    }

    public Object getData()
    {
    
    
        return data;
    }

    public static EnumInstance getInstance()
    {
    
    
        return INSTANCE;
    }

    public static final EnumInstance INSTANCE;
    private Object data;
    private static final EnumInstance $VALUES[];

    static 
    {
    
    
        INSTANCE = new EnumInstance("INSTANCE", 0);
        $VALUES = (new EnumInstance[] {
    
    
            INSTANCE
        });
    }
}

反编译结果可知:自己定义的枚举属性INSTANCE会在前面自动加上 public static final,同时会在静态代码块中进行初始化,而静态代码块在类加载的时候就会被初始化,所以是线程安全的。

关于枚举是不是懒加载,我也不清楚,StaciOverflow上面说是。

总的来说,使用枚举来实现单例模式会非常简洁,而且提供了序列化的机制,并由JVM从根本上提供保障,绝对防止多次实例化,并且天然线程安全,是更简洁,高效,安全的方式。

猜你喜欢

转载自blog.csdn.net/weixin_46215617/article/details/112688212