LOADING

加载过慢请开启缓存 浏览器默认开启

设计模式专题-单例模式

2023/4/10 设计模式

介绍

单例模式(Singleton)是一种常用的设计模式。在Java应用中,单例对象能保证在一个JVM中,该对象只有一个实例存在。这样的模式有几个好处:

1、某些类创建比较频繁,对于一些大型的对象,这是一笔很大的系统开销。

2、省去了new操作符,降低了系统内存的使用频率,减轻GC压力。

单例模式如果使用不当,就容易引起线程安全问题。

  • 饿汉式不存在线程安全问题,但是它一般不被使用,因为它会浪费内存的空间
  • 懒汉式会合理使用内存空间,只有第一次被加载的时候,才会真正去创建对象。但是这种方式存在线程安全问题。

懒汉式单例模式的实现方式有三种:

  • 双重检查锁方式(DCL)

  • 静态内部类方式

  • 枚举方式

单例模式的应用场景:

  • 多线程的线程池的设计一般是采用单例模式。
  • Web应用的配置文件的读取。
  • 数据库连接池的设计。
  • 在Spring中创建的Bean实例默认都是单例模式存在的。
  • 在Spring MVC框架中,每个控制器对象也是单例。

单例模式的两种常见方式

1. 饿汉式单例

public class Student1 {
// 1:构造私有
  private Student1() {
  }

  // 2:成员变量初始化本身对象
  private static Student1 student = new Student1();


  // 3:对外提供公共方法获取对象
  public static Student1 getSingletonInstance() {
    return student;
  }
}

2. 懒汉式单例

public class Student5 {
  private static Student5 student = null ; 
  // 1:构造私有
  private Student5() {
  }
  public static Student5 getSingletonInstance() {
    if(student == null ){
      student = new Student5();
    }  
    return student;
  }
}



public class Student5 {
  private Student5() {
  }
  /*
   * 此处使用一个内部类来维护单例 JVM在类加载的时候,是互斥的,所以可以由此保证线程安全问题
   */
  private static class SingletonFactory {
    private static Student5 student = new Student5();
  }
  /* 获取实例 */
  public static Student5 getSingletonInstance() {
    return SingletonFactory.student;
  }
}

两种常见实现方式优劣对比分析

1. 时间和空间复杂度方面

  • 饿汉式是空间换时间,每当类加载时就会创建出来,会存在一定的空间浪费。

  • 懒汉式是时间换空间,每次获取实例都需要判断是否需要创建。

2. 线程安全方面

  • 饿汉式不存在线程安全问题,但是它一般不被使用,因为它会浪费内存的空间
  • 懒汉式会合理使用内存空间,只有第一次被加载的时候,才会真正去创建对象。但是这种方式存在线程安全问题。因此,就有了三种解决线程安全问题的方式如下。

基于上述实现方式的优化实现

1. 双重检查锁(DCL)

public class DoubleCheckLockSingleton {
    private static volatile DoubleCheckLockSingleton instance;

    private DoubleCheckLockSingleton() {
        if(instance != null){
           // gun
        }
    }

    public static DoubleCheckLockSingleton getInstance() {
        if (instance == null) {
            synchronized (DoubleCheckLockSingleton.class) {
                if (instance == null) {
                    instance = new DoubleCheckLockSingleton();
                }
            }
        }
        return instance;
    }

    public void tellEveryone() {
        System.out.println("This is a DoubleCheckLockSingleton " + this.hashCode());
    }
    private Object readResolve() {
    }
}leCheckLockSingleton " + this.hashCode());
    }
}

2. 静态内部类

public class StaticInnerHolderSingleton {
    // 静态内部类
    private static class SingletonHolder {
        private static final StaticInnerHolderSingleton INSTANCE = 
                            new StaticInnerHolderSingleton();
    }

    private StaticInnerHolderSingleton() {}

    public static StaticInnerHolderSingleton getInstance() {
        return SingletonHolder.INSTANCE;
    }

    public void tellEveryone() {
        System.out.println("This is a StaticInnerHolderSingleton" + this.hashCode());
    }
}

3. 枚举(最佳实现方式)

public enum EnumSingleton {
    INSTANCE;

    public void tellEveryone() {
        System.out.println("This is an EnumSingleton " + this.hashCode());
    }
}

在Java圣经《Effective Java》中,Joshua Bloch大佬如是说:

A single-element enum type is often the best way to implement a singleton.

为什么说枚举是(一般情况下)最好的Java单例实现呢?他也做出了简单的说明:

It is more concise, provides the serialization machinery for free, and provides an ironclad guarantee against multiple instantiation, even in the face of sophisticated serialization or reflection attacks.

大意就是,枚举单例可以有效防御两种破坏单例(即让单例产生多个实例)的行为:反射攻击与序列化攻击。言外之意就是前两种单例方式都会被破坏。那么我们就拿平时最常用的双重检查锁方式开刀来试试看。

单例破坏与防范

反射攻击

直接上代码:

public class SingletonAttack {
    public static void main(String[] args) throws Exception {
        reflectionAttack();
    }

    public static void reflectionAttack() throws Exception {
        //通过反射,获取单例类的私有构造器
        Constructor constructor = DoubleCheckLockSingleton.class.getDeclaredConstructor();
        //设置私有成员的暴力破解
        constructor.setAccessible(true);
        // 通过反射去创建单例类的多个不同的实例
        DoubleCheckLockSingleton s1 = (DoubleCheckLockSingleton)constructor.newInstance();
        // 通过反射去创建单例类的多个不同的实例
        DoubleCheckLockSingleton s2 = (DoubleCheckLockSingleton)constructor.newInstance();
        s1.tellEveryone();
        s2.tellEveryone();
        System.out.println(s1 == s2);
    }
}

执行结果如下:

This is a DoubleCheckLockSingleton 1368884364
This is a DoubleCheckLockSingleton 401625763
false

这种方法非常简单暴力,通过反射侵入单例类的私有构造方法并强制执行,使之产生多个不同的实例,这样单例就被破坏了。

序列化攻击

这种攻击方式只对实现了[Serializable](// 通过反射去创建单例类的多个不同的实例)接口的单例有效,但偏偏有些单例就是必须序列化的。现在假设DoubleCheckLockSingleton类已经实现了该接口,上代码:

public class SingletonAttack {
    public static void main(String[] args) throws Exception {
        serializationAttack();
    }

    public static void serializationAttack() throws Exception {
        // 对象序列化流去对对象进行操作
        ObjectOutputStream outputStream = new ObjectOutputStream(new FileOutputStream("serFile"));
        //通过单例代码获取一个对象
        DoubleCheckLockSingleton s1 = DoubleCheckLockSingleton.getInstance();
        //将单例对象,通过序列化流,序列化到文件中
        outputStream.writeObject(s1);

     // 通过序列化流,将文件中序列化的对象信息读取到内存中
        ObjectInputStream inputStream = new ObjectInputStream(new FileInputStream(new File("serFile")));
        //通过序列化流,去创建对象
        DoubleCheckLockSingleton s2 = (DoubleCheckLockSingleton)inputStream.readObject();
        s1.tellEveryone();
        s2.tellEveryone();

        System.out.println(s1 == s2);
    }
}

执行结果如下:

This is a DoubleCheckLockSingleton 777874839
This is a DoubleCheckLockSingleton 254413710
false

为什么会发生这种事?长话短说,在ObjectInputStream.readObject()方法执行时,其内部方法readOrdinaryObject()中有这样一句话:

//其中desc是类描述符
obj = desc.isInstantiable() ? desc.newInstance() : null;

也就是说,如果一个实现了Serializable/Externalizable接口的类可以在运行时实例化,那么就调用newInstance()方法,使用其默认构造方法反射创建新的对象实例,自然也就破坏了单例性。要防御序列化攻击,就得将instance声明为transient,并且在单例中加入以下语句:

private Object readResolve() {
    return instance;
}

这是因为在上述readOrdinaryObject()方法中,会通过卫语句desc.hasReadResolveMethod()检查类中是否存在名为readResolve()的方法,如果有,就执行desc.invokeReadResolve(obj)调用该方法。readResolve()会用自定义的反序列化逻辑覆盖默认实现,因此强制它返回instance本身,就可以防止产生新的实例。

对反射的防御

我们直接将上述reflectionAttack()方法中的双重检查锁方式的类名改成EnumSingleton并执行,会发现报如下异常:

Exception in thread "main" java.lang.NoSuchMethodException: me.lmagics.singleton.EnumSingleton.<init>()
    at java.lang.Class.getConstructor0(Class.java:3082)
    at java.lang.Class.getDeclaredConstructor(Class.java:2178)
    at me.lmagics.singleton.SingletonAttack.reflectionAttack(SingletonAttack.java:35)
    at me.lmagics.singleton.SingletonAttack.main(SingletonAttack.java:19)

这是因为所有Java枚举都隐式继承自Enum抽象类,而Enum抽象类根本没有无参构造方法,只有如下一个构造方法:

    protected Enum(String name, int ordinal) {
        this.name = name;
        this.ordinal = ordinal;
    }

那么我们就改成获取这个有参构造方法,即:
Constructor constructor = EnumSingleton.class.getDeclaredConstructor(String.class, int.class);
结果还是会抛出异常:

Exception in thread "main" java.lang.IllegalArgumentException: Cannot reflectively create enum objects
    at java.lang.reflect.Constructor.newInstance(Constructor.java:417)
    at me.lmagics.singleton.SingletonAttack.reflectionAttack(SingletonAttack.java:38)
    at me.lmagics.singleton.SingletonAttack.main(SingletonAttack.java:19)

来到Constructor.newInstance()方法中,有如下语句:

    if ((clazz.getModifiers() & Modifier.ENUM) != 0)
        throw new IllegalArgumentException("Cannot reflectively create enum objects");

可见,JDK反射机制内部完全禁止了用反射创建枚举实例的可能性。

对序列化的防御

如果将serializationAttack()方法中的攻击目标换成EnumSingleton,那么我们就会发现s1和s2实际上是同一个实例,最终会打印出true。这是因为ObjectInputStream类中,对枚举类型有一个专门的readEnum()方法来处理,其简要流程如下:

  • 通过类描述符取得枚举单例的类型EnumSingleton;
  • 取得枚举单例中的枚举值的名字(这里是INSTANCE);
  • 调用Enum.valueOf()方法,根据枚举类型和枚举值的名字,获得最终的单例。

这种处理方法与readResolve()方法大同小异,都是以绕过反射直接获取单例为目标。不同的是,枚举对序列化的防御仍然是JDK内部实现的。

综上所述,枚举单例确实是目前最好的单例实现了,不仅写法非常简单,并且JDK能够保证其安全性,不需要我们做额外的工作。

反射攻击和反序列化问题是两种最常见的破坏单例模式的手段,前者利用反射机制修改构造函数的可见性然后强行创建一个新的实例,后者是将原先的对象序列化后再反序列化从而生成一个新的实例