深挖单例模式 | 草台班子

LOADING

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

深挖单例模式


在Java运行时中,有些较为庞大复杂的类,如果频繁的创建和销毁对象,会造成系统的性能下降。
为 了解决这个问题,我们可以应用单例模式

什么是单例模式

单例模式,顾名思义,就是一个类只有一个实例。在Java中,单例模式有通常两种实现方式(在深挖之前我是这么觉得的):饿汉式和懒汉式。他们的实现思想可以从以下三点入手:

  1. 是否线程安全
  2. 是否懒加载
  3. 能否反射破坏

饿汉式单例模式

饿汉式单例模式,是在类加载时就创建单例对象,这种方式比较简单,但缺点也很明显,就是在类加载时就完成实例化,如果类中存在资源耗费的操作,则会造成资源浪费。

public class Hungry {
    private  Hungry(){}
 
    private final static Hungry HUNGRY = new Hungry();
    
    public static Hungry getInstance(){
        return HUNGRY;
    }
    
}

懒汉式单例模式

懒汉式单例模式,是在第一次调用getInstance()方法时才创建单例对象,这种方式比较常用,但也存在线程安全问题。

public class Lazy {
    private Lazy(){}
 
    private static Lazy lazy;
    
    public static Lazy getInstance(){
        if(lazy == null){
            lazy = new Lazy();
        }
        return lazy;
    }
    
}

但在多线程情况下,以上代码会出现问题,因为在多线程环境下,可能会创建多个实例对象。为了解决这个问题,我们可以加锁,常见的是使用双重检查锁定。

双检锁(常用的)

public class Lazy {
    private Lazy(){}
 
    private static Lazy lazy;
    
    public static Lazy getInstance(){
        if(lazy == null){
            synchronized (Lazy.class){//第一道检查
                if(lazy == null){//第二道检查
                    lazy = new Lazy();
                }
            }
        }
        return lazy;
    }
    
}

双检锁看似已经解决了线程安全问题,但其实还是有问题。 详情可参考happens-before原则

因为lazy = new Lazy()并不是一个原子性操作,在指令层面,这个操作可以分为三步

  1. 分配内存空间
  2. 执行构造方法,初始化对象
  3. 把对象指向这个空间
    而在真正执行时,虚拟机为了效率可能会对这三步操作进行重排,比如,***步骤2和步骤3可以交换顺序***。

所以灾难就发生了。

在多线程环境下,假设我有两个线程A和B,如果步骤2和步骤3交换顺序,在A执行完3还没来得及执行2的时候,
lazy已经不是null了,这时B恰好走到了if(lazy == null)这一步,
此时B就会直接返回一个未初始化的对象,导致程序出错。

为了解决这个问题也不难,只需要在private static Lazy lazy中加上volatile关键字即可,它的主要作用是确保变量的可见性和防止指令重排序

双检锁唯一的缺点可能就是实现起来较为繁琐,那有没有既满足了懒加载,也满足了线程安全同时效率高且简洁的单例模式呢?

静态内部类

静态内部类在程序启动的时候不会加载,第一次被调用时才会加载

public class Holder {
    private static class HolderInstance{
        private static final Holder INSTANCE = new Holder();
    }
    
    private Holder() {}

    public static Holder getInstance(){
        return HolderInstance.INSTANCE;
    }
    
}

但是以上的所有写法都是不安全的,都可以通过反射来破解!

反射破坏单例模式

    public static void main(String[] args) throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {
        Lazy instance = Lazy.getInstance();
        Constructor<Lazy> lazyConstructor = Lazy.class.getDeclaredConstructor(null);
        lazyConstructor.setAccessible(true);
        Lazy instance2 = lazyConstructor.newInstance();
        System.out.println(instance);
        System.out.println(instance2);
        System.out.println(instance == instance2);
    }

利用反射来突破访问控制,创建一个私有构造函数的实例,最终运行的结果如下
img.png
可见“成功”突破了单例模式,创建了两个不同实例
但反射的本质上还是通过将无参构造器暴露出来,然后调用newInstance()方法来创建对象,这就使得单例模式被破坏了。
想要解决也可以,在无参构造中也加把锁就行了

 private Lazy(){
        synchronized (Lazy.class){
            if(lazy != null){
                throw new RuntimeException("不要通过反射破坏单例!")
            }
        }
    }

问题看似又被解决了

但是道高一尺魔高一丈,如果我在反射中不使用getInstance()方法,而是直接使用构造器new一个对象,会怎么样呢?

    public static void main(String[] args) throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {
//        Lazy instance = Lazy.getInstance(); 不使用getInstance()方法
        Constructor<Lazy> lazyConstructor = Lazy.class.getDeclaredConstructor(null);
        lazyConstructor.setAccessible(true);
        Lazy instance2 = lazyConstructor.newInstance();
        System.out.println(instance);
        System.out.println(instance2);
        System.out.println(instance == instance2);
    }

“锁”再次被攻破了
img.png
不要灰心,我们仍可以通过添加新字段,即再次“加锁”来解决,但是这样一层层下去,代码会越来越复杂。而再牢固的锁,也会被撬开。我们不妨从底层入手,看看到底怎样才能彻底根除反射破坏

反射的底层实现

反射的底层实现是通过调用java.lang.reflect.Constructor类的newInstance()方法来创建对象。
img.png
通过底层代码可以看出,枚举类型是无法通过反射创建对象的,因为枚举类型是编译时就已经确定了,所以在编译时就已经确定了构造器,而反射是在运行时才确定构造器的。

验证反射是否可以破坏枚举

public enum Single {
    INSTANCE;
    public Single getInstance(){
        return INSTANCE;
    }
}

class Test{
    public static void main(String[] args) throws InvocationTargetException, InstantiationException, IllegalAccessException, NoSuchMethodException {
        Single s1 = Single.INSTANCE;
        Constructor<Single> declaredConstructor = Single.class.getDeclaredConstructor(null);
        declaredConstructor.setAccessible(true);
        Single s2 = declaredConstructor.newInstance();
        System.out.println(s1 == s2);
    }
}

运行一下,程序确实抛出异常,但却不是因为枚举无法通过反射创建对象的异常”Cannot reflectively create enum objects”,而是找不到无参构造,这又是为什么呢?
img.png

虚假的无参构造

class文件告诉我们,枚举中只存在一个无参构造器,而上面的运行结果却是找不到无参构造器。那么枚举的构造器到底是什么?
img.png

再往下一层

最后在Enum.java的源码中发现,枚举实际上是重写了父类的签名为(String name,int ordinal)的构造函数,而在编译时,编译器会自动添加一个无参构造器,所以枚举的构造器实际上是有参的。
再次修改代码,验证枚举的构造器是否可以被反射破坏

public enum Single {
    INSTANCE;
    public Single getInstance(){
        return INSTANCE;
    }
}

class Test{
    public static void main(String[] args) throws InvocationTargetException, InstantiationException, IllegalAccessException, NoSuchMethodException {
        Single s1 = Single.INSTANCE;
        Constructor<Single> declaredConstructor = Single.class.getDeclaredConstructor(String.class,int.class);
        declaredConstructor.setAccessible(true);
        Single s2 = declaredConstructor.newInstance();
        System.out.println(s1 == s2);
    }
}

如愿以偿
img.png

参考:寒食君
狂神说Java

写于2024-09-19深夜,既时和华哥又聊了很多,深有感触

附:单例模式的应用场景

单例模式的应用场景有很多,比如:

  1. 数据库连接池
  2. 日志对象
  3. 线程池
  4. 应用配置对象
  5. 应用缓存对象
  6. 注册表对象
  7. 全局配置对象
  8. 应用上下文对象
  9. 全局状态对象