架构师的成长之路 —— 深度剖析单例模式(一定会颠覆你的认知)

单例模式是我们最最常用的一种设计模式,我觉得但凡接触过Java的朋友一定或多或少的了解过,并且单例模式在很多的面试中也是一个非常高频的考点,那么我们该怎么去分析理解让我们把这种设计模式牢牢掌握,在代码设计或者面试中成为自己的一个加分项。那么这篇文章将深入剖析单例模式,肯定会让你对单例模式有一个颠覆性认知和质的飞跃。废话不多说Do It !!

1.饿汉单例模式:

我们先来看一下饿汉模式,这个应该是单例模式的一个入门,那么我们来聊一下饿汉模式的2种写法,分别对比一下他们的优缺点,然后来再进行分析和优化

1.1 普通饿汉模式:

我们普通饿汉模式的写法一共就分三步:

  1. 私有化构造方法
  2. 使用public static final 修饰,在类加载的时候加载这个对象实例
  3. 提供一个对外访问点

是不是特别的简单,那么我们来分析一下这个代码优缺点:
首先普通饿汉模式的优点:它的效率很高,当类加载的时候就会初始化对象实例,他是一个线程安全的单例。
缺点就是他会浪费内存资源,就像spring一样,我们需要将很多的been交给spring管理,但是我们没有使用的时候spring不可能将这个been初始化到内存,那样性能极其低下。

public class HungrySingleton {
    //1.私有化构造方法
    private HungrySingleton(){}

    //2.使用public static final 修饰,在类加载的时候加载这个对象实例
    private static final HungrySingleton instance = new HungrySingleton();

    //3.提供一个对外访问点
    public static HungrySingleton getInstance(){
        return instance;
    }
}

1.2静态代码块的饿汉模式:

那么我们使用静态代码块实现单例的写法一共需要4步:

  1. 私有化构造方法
  2. 定义一个静态常量实例,先不去进行赋值
  3. 使用一个静态代码块将常量进行赋值
  4. 提供一个公共访问点

那么我们分析一下这个饿汉单例,他跟我们普通饿汉单例其实是没有任何区别的,都是在类加载的时候对常量进行赋值。个人觉得优点:可以装一下逼,毫无卵用。
那么它的缺点跟普通饿汉一样,会造成内存资源的浪费,那么这个时候我们该怎么解决这个问题,那么就需要引出下一个单例模式,懒汉式单例模式。

public class HungryStaticSingleton {
    //1.私有化构造方法
    private HungryStaticSingleton(){}

    //2.定义一个静态常量实例,先不去进行赋值
    private static final HungryStaticSingleton INSTANCE;

    //3.使用一个静态代码块将常量进行赋值
    static {
        INSTANCE = new HungryStaticSingleton();
    }

    //4.提供一个公共访问点
    public static HungryStaticSingleton getInstance(){
        return INSTANCE;
    }
}

2.懒汉单例模式:

针对饿汉单例模式的缺点我们引出了懒汉单例模式,那么懒汉单例模式会是最完美的单例模式吗?我们就来分析一下

2.1 普通懒汉单例模式:

这个就是普通的懒汉单例模式它的实现步骤分成四步:

  1. 1.私有化构造方法
  2. 定义一个静态变量来接受对象实例,先不去赋值
  3. 提供一个公共访问点
  4. 判断当前这个静态变量是否有值,如果有值我们就直接返回这个实例,没有值我们就去创建一个对象并赋值

那么我们来分析一下普通的懒汉单例模式的优缺点,它的优点就是解决了一个内存浪费的问题,当我们调用getInstance的时候才去创建一个对象实例。那么***它的缺点也很明显就是存在线程安全问题***。那么我们下面来深度分析一下他究竟为什么存在线程安全问题。

public class LazySimpleSingleton {
    
    //1.私有化构造方法
    private LazySimpleSingleton(){}
    
    //2.定义一个静态变量来接受对象实例,先不去赋值
    private static LazySimpleSingleton instance;
    
    //3.提供一个公共访问点
    public static LazySimpleSingleton getInstance(){
        //4.判断当前这个静态变量是否有值,如果有值我们就直接返回这个实例,没有值我们就去创建一个对象并赋值
        if(instance == null){
            instance = new LazySimpleSingleton();
        }
        return instance;
    }
}

2.1.1 懒汉式单例模式的线程安全问题:

首先第一步:我们创建一个类实现Runnable接口,重写Run方法,在run方法里面调用我们的普通饿汉单例的getInstance()方法。并打印。
在这里插入图片描述第二步:我们写一个测试类,在main方法里面开启两个线程。
在这里插入图片描述第三步:分别在我们的懒汉单例类,测试类,线程类中打上断点并开启线程模式进行测试。

2.1.2 进行测试

首先我们不去管任何问题进行测试,看一下结果如何:

第一次测试结果:感觉我们的单例模式没问题,很完美。
在这里插入图片描述第二次测试结果:卧槽,两个对象完全不一样,什么情况。
在这里插入图片描述
那么我们来分析一下为什么会出现不一样的测试结果。

我们现在分别来分析一下到底是如何出现相同结果,出现相同结果的时候又到底是不是线程安全的。

出现相同的结果:

1.确实是单例模式的,两个线程并没有发生线程安全问题,都是按照顺序分别执行的。
这个我们就不去分析了,很简单就是两个线程没有发生冲突,分别执行。
2.还有一种情况就是发生了线程安全问题,线程2覆盖了线程1的地址,得出的结果是一个伪结果

那么我们来好好分析一下这样的情况。 我们已经在测试类,线程类和懒汉类中打上了线程模式的断点,那么我们来模拟一下它是如何进行覆盖的。

第一步我们使用debug模式运行我们的测试类:我们来看一下t1和t2是否都抢到了cpu的时间片。下图所示就是两个线程都抢到了时间片而且他们的状态都是Running。
在这里插入图片描述第二部我们首先切换到线程1,让线程1创建一个实例,但是不去进行打印,然后我们来记一下这个对象实例的地址499
在这里插入图片描述

第三步我们切换到线程2,然线程2也去创建一个实例,这个时候他将500覆盖了之前的499.
在这里插入图片描述第四步我们再次去切换线程1.看到我们打印的对象地址变成了500
在这里插入图片描述第五步我们来看一下这两个线程的输出结果是相等的,但是我们知道这个结果其实是伪数据,是线程2覆盖了线程1的地址,得到了相同的地址值,其实它的内部还是发生了线程安全问题。
在这里插入图片描述

出现不同的结果:

1.其实就是两个线程发生了线程安全问题,那么我们就来模拟一下结果不同的时候。

第一步还是启动debug模式,分别切换到线程1和线程2,让他们都进入单例类中的判断内

第二步切换到线程1中直接将线程1执行完成打印结果,这个时候我们看到线程1得到的地址是@456575b9
在这里插入图片描述
第三步我们切换到线程2,将代码执行完毕,查看结果,很明显因为发生了线程安全问题所以得到了两个不同的对象,违背了单例模式的设计初衷
在这里插入图片描述

那么现在这个问题我们已经发现了,那么该怎么去解决呢?这个时候就要引入懒汉模式的第二种写法,通过加锁的方式来保证线程安全。

2.2 加锁的懒汉单例模式:

非常非常简单就是在公共访问点上加synchronized 关键字来保证线程安全问题。那么我们来分析一下这个代码的优缺点,优点很明显就是可以保证线程安全问题了,缺点:但是我们知道synchronized 是一个重量级的锁,这样会很影响性能,
那么我们如何对这段代码进行优化呢?这个时候就要引入一个新的懒汉单例模式——双重校验锁

public class LazyLockSingleton {

    //1.私有化构造方法
    private LazyLockSingleton(){}

    //2.定义一个静态变量来接受对象实例,先不去赋值
    private static LazyLockSingleton instance;

    //3.提供一个公共访问点
    public synchronized static LazyLockSingleton getInstance(){
        //4.判断当前这个静态变量是否有值,如果有值我们就直接返回这个实例,没有值我们就去创建一个对象并赋值
        if(instance == null){
            instance = new LazyLockSingleton();
        }
        return instance;
    }
}

2.3 双重校验锁懒汉单例模式:

双重校验锁的写法如下所示,这个对新手来说一点也不友好,可读性太差

我们来分析一下它为什么要加入两层判断:

第一层的if判断是:我们为了提高程序的性能可以将多个线程都先进入到getInstance中,然后通过判断我的静态变量是否已经被赋值。如果我们的静态变量没有赋值那么我们就创建对象并赋值,如果这个静态变量已经存在值了,那么我们就直接进行返回。

第二层if判断的意思是:我们先假设如果没有这一层判断会发生什么情况,线程1和线程2都进入到了这个方法中,首先线程1进行判断静态变量没有进行赋值,那么线程一就继续进行操作,创建对象并赋值。线程2这个时候因为有Synchronized修饰,所以线程2只能进行等待,只有线程1进行创建和赋值,但是线程1对静态变量进行赋值之后并没有进行打印这个时候线程2进入到方法中,创建值,那线程2就会重新将对象覆盖线程1创建的对象。跟我们之前不加锁的效果一样, 同样是没有保证线程安全问题。那么我们再加一层if判断,如果已经有线程对静态变量赋值,不管它有没有进行打印,线程2再进入方法时都会再一次进行判断,这样就有效的解决了线程安全问题。这个就是双重校验锁的原理。

那么我们来总结一下双重校验锁的优缺点***双重校验锁的形式就可以很好的保证了线程安全问题,也提高了程序的性能那么它的一个缺点就是代码变得可读性很差,不优美。***
我们其实一直都在寻找一个很好的解决单例的一种办法,那么有没有一种很优雅,可读性很高又线程安全的方式呢?那么我们来说一下下一种懒汉单例模式——静态内部类的方式实现懒汉模式。

public class LazyDoubleCheckSingleton {

    //1.私有化构造方法
    private LazyDoubleCheckSingleton(){}

    //2.定义一个静态变量来接受对象实例,先不去赋值
    private static LazyDoubleCheckSingleton instance;

    //3.提供一个公共访问点
    public static LazyDoubleCheckSingleton getInstance(){
        //4.判断当前这个静态变量是否有值,如果有值我们就直接返回这个实例,没有值我们就去创建一个对象并赋值
        if(instance == null){
            synchronized (LazyDoubleCheckSingleton.class){
                if(instance == null){
                    instance = new LazyDoubleCheckSingleton();
                }
            }
        }
        return instance;
    }
}

2.3 静态内部类的懒汉单例模式:

我们先来看一下代码,这个时候肯定会有人问,这个和饿汉模式一样呀,都是对这个实例对象赋值,其实这个方式是巧妙的运用了Java语法的特点,我们来分析一下:一个类被加载的时候是生成一个字节码文件,根据下面例子,就会生成一个LazyInnerClassSingleton.class字节码文件,但是这个内部类并不会马上生成字节码文件,当我们调用getInstance方法的时候就会生成一个LazyInnerClassSingleton$InnerClass.class字节码文件,这个时候就是将这个对象实例进行赋值,那么这个静态内部类的方式就完美的解决了延时加载,线程安全,性能高的问题。那么我们来思考一下,这个方法到底是不是最佳的单例模式,它已经接近完美了,那还会出现什么问题,其实这个方式的确可以解决我们之前的问题,但是人无完人代码一样也是,其实它还有一个问题就是可以通过反射来破坏单例模式。包括上面说的4中方式都会被反射破坏

public class LazyInnerClassSingleton {
    //1.私有化构造方法
    private LazyInnerClassSingleton(){}

    //2.提供一个公共访问点
    public static LazyInnerClassSingleton getInstance(){
        return InnerClass.instance;
    }
    //3.通过静态内部类来对这个对象实例进行赋值
    private static class InnerClass{
        private static LazyInnerClassSingleton instance = new LazyInnerClassSingleton();
    }
    
}

我们来分析如何使用反射来破坏单例模式:

提示,设计模式无时无刻不在使用反射技术,如果大家对反射有一些生疏或者忘记了,那么可以先去复习学习一下。这样对设计模式或者框架的学习很有帮助。

那么我们就来一个测试类,通过反射的方式看看如何来破坏单例的,就拿静态内部类的方式来测试。

第一步我们想要通过反射创建对象首先要或者这个类的构造方法。
第二部我们通过设置权限来进行创建对象
然后我们对比创建的对象的地址值是否一样

public class InnerClassSingletonTest {
    public static void main(String[] args) {
        try {
            //通过className来获取这个类对象
            Class clazz = Class.forName("cn.xiaomin.singleton_csdn.lazysingleton.LazyInnerClassSingleton");
            //再通过这个类对象获取它的构造方法
            Constructor constructor = clazz.getDeclaredConstructor();
            //打印这个构造方法
            System.out.println(constructor);
            //设置最高权限来进行访问
            constructor.setAccessible(true);
            //分别创建两个对象,在进行对比两个对象的地址是否一样
            Object instance1 = constructor.newInstance();
            Object instance2 = constructor.newInstance();
            System.out.println(instance1 == instance2);
        }catch (Exception e){
            e.printStackTrace();
        }
    }
}

那么现在我们来看一下它的结果是否是一样的,很明显结果是false,证明我们成功的通过反射来绕过getInstance这个方法创建对象
在这里插入图片描述
那么现在问题又来了,那么还有什么方式来创建单例模式可以避免通过反射的技术来破坏单例呢?那么我们现在来讲一个注册式单例模式。

2.3 注册式单例模式——枚举单例模式:

那么我们先来看一下用枚举的方式如何实现单例模式:
这个就是一个枚举类的单例。

public enum  EnumSingleton {
    INSTANCE;
    
    private Object data;

    public Object getData() {
        return data;
    }

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

    public static EnumSingleton getInstance(){
        return INSTANCE;
    }
    
}

我们使用枚举类的单例的方式:
我们通过枚举单例中字段的get/set方法去设置值和取值

public class EnumSingletonTest {
    public static void main(String[] args) {
        EnumSingleton instance = EnumSingleton.getInstance();
        instance.setData(new Object());
    }
}

那么为什么枚举类的单例不会被反射来破坏呢?我们来通过反射来测试一下看一下通过反射来创建对象会怎么样?
我们第一步通过反射来获取这个枚举类的构造方法,我们先打印一下这个构造方法,看看结果怎么样

public class EnumSingletonTest {
    public static void main(String[] args) {
//        EnumSingleton instance = EnumSingleton.getInstance();
//        instance.setData(new Object());
        try {
            Class clazz = Class.forName("cn.xiaomin.singleton_csdn.enumsingleton.EnumSingleton");
            Constructor constructor = clazz.getDeclaredConstructor();
            System.out.println(constructor);
        }catch (Exception e){
            e.printStackTrace();
        }
    }
}

打印结果为:没有找到这个构造方法,那么我们从源码中找一下答案,看一下这个构造方法为什么没有。
在这里插入图片描述我们知道枚举类其实是继承了Enum类,那么我们来看一下Enum这个类中的构造方法是什么?很明显这个Enum类中有一个构造方法里面有两个参数,一个是String一个是int,原来是这个样子 ,那么我们重新来获取一下构造方法。
在这里插入图片描述
再一次获取构造方法:

public class EnumSingletonTest {
    public static void main(String[] args) {
//        EnumSingleton instance = EnumSingleton.getInstance();
//        instance.setData(new Object());
        try {
            Class clazz = Class.forName("cn.xiaomin.singleton_csdn.enumsingleton.EnumSingleton");
            Constructor constructor = clazz.getDeclaredConstructor(String.class,int.class);
            System.out.println(constructor);
        }catch (Exception e){
            e.printStackTrace();
        }
    }
}

我们来看一下输出结果:
在这里插入图片描述
很好,我们已经获取了构造方法,那么我们通过这个构造方法来创建对象会怎么样?

public class EnumSingletonTest {
    public static void main(String[] args) {
//        EnumSingleton instance = EnumSingleton.getInstance();
//        instance.setData(new Object());
        try {
            Class clazz = Class.forName("cn.xiaomin.singleton_csdn.enumsingleton.EnumSingleton");
            Constructor constructor = clazz.getDeclaredConstructor(String.class,int.class);
            constructor.setAccessible(true);
            Object instance = constructor.newInstance();
            System.out.println(instance);
        }catch (Exception e){
            e.printStackTrace();
        }
    }
}

我们来看一下结果:
在这里插入图片描述这个错优点意思,它竟然这么直白的去说不能通过反射来获取枚举类的对象。这个就很有意思了,所以说枚举类的单例不会被反射去破坏。

那么我们来分析一下枚举类单单例模式的优缺点都有什么?
优点:代码优雅,可读性高,线程安全,完美的解决了使用反射来破坏单例的问题。
缺点:不能创建大量的实例,消耗内存资源高

因为我们需要借鉴高人的思路,看一下大神是如何实现单例的,那么我们的思路就是通过散热spring来对比它是如何实现单例的。其实spring是借鉴了枚举的优点,然后再进行改良实现的另一种单例模式。那么我们就要引出——容器式单例模式

2.4 注册式单例模式——容器式单例模式:

因为spring中肯定有获得单例的方式,它就是借鉴了枚举类的优点进行改良的得到的。那么spring的单例模式其实就是一个容器式单例模式,通过唯一key来获取对象。
那么容器式单例的实现方式又是怎么样的呢?
这个就是容器式单例的写法,很简单,我们需要私有化构造方法,然后初始化一个容器。然后提供一个对外访问点,在getInstance中进行判断,如果我们的map中包含className,那么我们就直接将对象实例返回,如果没有没,那么我们就去创建一个对象,并把这个className唯一key存入容器中。

public class ContainerSingleton {
    private ContainerSingleton(){}
    //创建一个容器
    private static Map<String,Object> ioc = new ConcurrentHashMap<String, Object>();
    public static Object getInstance(String className){
        if(!ioc.containsKey(className)){
            ContainerSingleton instance = new ContainerSingleton();
            ioc.put(className,instance);
            return instance;
        }else {
            return ioc.get(className);
        }
    }

}

那么这种写法还会有什么问题呢?就是他的确性能很高,spring也是用的容器式单例模式,那么我们这个版本还有一个缺陷,就是线程不安全,而且会存在序列化问题。那么我们来看一下,如何将容器式单例改进成线程安全的呢?
那么我自己采取的方式就是通过双重校验锁的方式来保证线程安全问题代码如下:
我来解释一下这段代码的意思,首先我们之前学过一个双重校验锁的方式来满足线程安全问题。而且设计模式这个东西没有唯一正确答案,它是一种设计思想,因为在spring源码中,getBeen的时候其实他就是通过双重校验锁的方式来保证线程安全问题的。

public class ContainerSingleton {
    private ContainerSingleton(){}
    //创建一个容器
    private static Map<String,Object> ioc = new ConcurrentHashMap<String, Object>();
    public static Object getInstance(String className){
        Object instance = null;
        if(!ioc.containsKey(className)){
            synchronized (ContainerSingleton.class){
                if(instance == null){
                    try {
                        instance = Class.forName(className).newInstance();
                        ioc.put(className,instance);
                    }catch (Exception e){
                        e.printStackTrace();
                    }
                }
                return instance;
            }
        }else {
            return ioc.get(className);
        }
    }

}

这样我们的线程安全问题已经解决了,那么我们如何解决序列化的问题,非常简单:添加readResolve(),返回Object对象

3.那么我们来总结一下单例模式:

  1. 延时加载
  2. 线程安全
  3. 私有化构造方法
  4. 防止反射,序列化和反序列破坏

单例模式的优点:

  1. 在内存中只有一个实例减少内存开销
  2. 避免对资源的多重占用
  3. 设置全局访问点,严格控制访问

单例模式的缺点:

  1. 扩展性差
  2. 没有面向接口编程
  3. 违背了开闭原则

到这里整个单例的介绍分析已经完成了,相信大家如果真的细心看完一定会有所收获,而且我们在使用单例的时候要根据自己的业务来合理的使用单例模式,很重要的一句话——约定大于配置!!!!
好了非常感谢大家能将文章看到最后,我们一起努力,做一个技术的思考者!

猜你喜欢

转载自blog.csdn.net/weixin_45550128/article/details/107766073