你不知道的单例模式


一、单例模式是什么?

单例模式,也叫单子模式,是一种常用的软件设计模式,属于创建型模式的一种。
在java当中,创建一个类的对象,为了确保这个对象是唯一的,以符合某些特定的场景,就需要使用到单例模式,全局唯一

二、单例的类型

1.饿汉式

饿汉式,顾名思义,就很饿,上来就想要new 对象。
这里说下实现单例模式的固定的思路:

  • 类构造函数私有化
  • 静态该类对象属性(私有化)
  • 静态方法

下面用代码举例说明:

饿汉式单例模式

package com.lhh.signalpattern;

public class Hungry {
    
    

    private Hungry() {
    
    
    }

    private static final Hungry hungry = new Hungry();

    public static Hungry getInstance() {
    
    
        return hungry;
    }
}

优点:比较方便简单,直接调用即可。
缺点:因为一开始就new对象,容易造成内存空间的浪费,占据系统资源,特别是假如我现在在该类里面再额外创建一个很大的空间,比如像这样:

private byte[] data1 = new byte[1024 * 1024];
private byte[] data2 = new byte[1024 * 1024];
private byte[] data3 = new byte[1024 * 1024];
private byte[] data4 = new byte[1024 * 1024];

懒汉式单例模式
废话不多说,懒汉式就是不直接new 对象,而是有一个判断的过程,只有当需要的时候,我才创建。

package com.lhh.signalpattern;

public class Lazy {
    
    
    private Lazy() {
    
    

    }

    private static Lazy lazy = null;

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

优点:只有等到真正要用的时候,也就是调用getInstance方法的时候,再创建,否则不创建,节省了一定的内存空间。
缺点:在单线程下是没问题的,但是在多线程的环境下,会出现异常,也就是拿到多个不同的对象,违背了单例模式的原则。

懒汉式单例模式为什么会出现多个对象呢?
本质上还是因为在方法中的代码不是原子性的,也就是存在多行代码,那么就有可能出现这样一种情况:
假设现在有两个线程,a,b,首先我们让a线程进入了getInstance方法,进入了if判断条件,进入之后,还没等执行创建对象lazy = new Lazy()这行代码,该线程的cpu执行权被b线程拿去了,拿去了之后,另外一个线程也进入了if判断,并且成功创建了对象,而此时,不巧的的是,a线程刚好又得到了执行权,因为也创建了一个对象,那么这过程当中,就相当于创建了两个不同的对象,违背的单例模式的原则,因而说在多线程环境下,懒汉式单例模式是不安全的,需要进行某种加锁的策略。

升级版懒汉式单例模式(DCL懒汉式)
那有人可能就要说了,加一个锁可以解决这个问题,确实,是可以解决多线程环境下引发的问题,且看代码。

package com.lhh.signalpattern;

public class Lazy {
    
    
    private Lazy() {
    
    

    }

    private static Lazy lazy = null;

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

我们使用多线程来测试一下:

class Demo {
    
    
    public static void main(String[] args) {
    
    
        for (int i = 0; i < 10; i++) {
    
    
            new Thread(() -> {
    
    
                System.out.println(Lazy.getInstance());
            }).start();
        }
    }
}

执行结果如下:
在这里插入图片描述
但是仅仅是这样,还是会存在一个问题,也就是关乎原子性的问题,上面的代码当中,lazy=new Lazy()并不是一个原子性操作,真正的执行过程是这样子的:
在这里插入图片描述
据于此,就存在线程并不能保证new对象这个操作按照顺序的执行,可能会因为按照非常规顺序执行,引发问题,比如我现在有两个线程,第一个线程是按照132执行的,那么可能存在当第一个线程执行到13的时候,轮到了第二个线程执行,由于第一个线程已经指向了一个没有经过对象初始化的空间,那么第二个线程可能得到的就是一个没有构造的空间,就产生问题了。所以为了避免这种问题的发生,就应该需要使用到java当中的关键字volatile关键字,确保不发生指令重排,避免问题的发生。

///标准的双重检测锁模式,也称为DCL懒汉式。
public class Lazy {
    
    
    private Lazy() {
    
    

    }
	//加上volitile关键字
    private volatile static Lazy lazy = null;

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

但是尽管看起来dcl单例模式已经如此完善了,它还是有一个缺点,就是不能对付java中的反射技术,在文章的第三部分存在的问题当中我们再来讨论。

静态内部类单例模式

package com.lhh.signalpattern;

public class OuterClass {
    
    
    private OuterClass(){
    
    

    }

    public static OuterClass getInstance(){
    
    
        return InnerClass.outer_class;
    }

    public static class InnerClass{
    
    
        private static final OuterClass outer_class = new OuterClass();
    }
}

优点:外部类加载时并不需要立即加载内部类,内部类不被加载则不去初始化outer_class实例,故而不占内存.

枚举实现单例

public enum EnumSingle {
    
    
    INSTANCE;
    public EnumSingle getInstance(){
    
    
        return INSTANCE;
    }
}
//测试运行,使用反射拿到对象
class Demo2{
    
    
    public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
    
    
        EnumSingle instance = EnumSingle.INSTANCE;
        Constructor<EnumSingle> declaredConstructor = EnumSingle.class.getDeclaredConstructor();
        declaredConstructor.setAccessible(true);
        EnumSingle enumSingle = declaredConstructor.newInstance();
        System.out.println(instance);
        System.out.println(enumSingle);
    }
}

运行发现错误,错误如下:
在这里插入图片描述
在这里插入图片描述
但是按照某某说的,计算机程序的执行结果是不可能骗我们的,那么很有可能terminal终端反编译结果也有问题,那么我们借助专业的反编译的工具再来查看,可以发现结果如下:
在这里插入图片描述
至此,问题就解决了,在测试程序里面需要加上两个对应类型的class类,代码如下:

class Demo2{
    
    
    public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
    
    
        EnumSingle instance = EnumSingle.INSTANCE;
        Constructor<EnumSingle> declaredConstructor = EnumSingle.class.getDeclaredConstructor(String.class,int.class);//特别要注意这里,加上String.class与int.class,不然报错。
        declaredConstructor.setAccessible(true);
        EnumSingle enumSingle = declaredConstructor.newInstance();
        System.out.println(instance);
        System.out.println(enumSingle);
    }
}

再次测试运行,发现确实不能使用反射创建对象,报错,符合预期。
在这里插入图片描述
最后说下枚举类型的单例优点:枚举实例创建是线程安全的,在任何情况下,它都是一个单例。

三、存在的问题

接下来,我们来深度剖析一下,dcl双重检测锁单例模式的一些问题。
因为我们可以使用反射破解类,所以有必要针对一下该问题,进行一些探讨,以及一些解决方案:

//DCL双重检测锁单例实现代码
public class Lazy {
    
    
    private Lazy() {
    
    

    }

    private volatile static Lazy lazy = null;

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

接下来我们使用一个测试,利用反射技术破坏单例模式:

private static void test1() throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
    
    
        Lazy lazy = Lazy.getInstance();

        Constructor<Lazy> declaredConstructor = Lazy.class.getDeclaredConstructor(null);
        //暴力破解,具备访问私有权限的能力
        declaredConstructor.setAccessible(true);
        Lazy lazy1 = declaredConstructor.newInstance();

        System.out.println(lazy);
        System.out.println(lazy1);
    }

执行的结果不同,破坏成功.
在这里插入图片描述

那么针对上述问题,我们提供了反破解思路,因为反射拿到的是构造函数,通过构造函数来创建对象,那么我们就可以在构造函数里面加一些操作,比如锁判断,加入一个标志位。

public class Lazy {
    
    
    private static boolean flag = false;

    private Lazy() {
    
    
        synchronized(Lazy.class){
    
    
            if(flag==false){
    
    
                flag=true;
            }else{
    
    
                throw new RuntimeException("不要企图通过反射创建对象");
            }
        }

    }

    private volatile static Lazy lazy = null;

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

像这样的话,就可以防止反射创建对象。
在这里插入图片描述
可以尽管如此,还是可以做到再次破坏,如何实现呢?假如你现在知道了有一个标志位在起作用,那么我可以先拿到你这个标志位,使用了反射创建了一次对象之后,我就把你这个值重新改过来,那么我就可以再次用反射来创建一个新的对象了,具体操作过程如下代码所示:

private static void test1() throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException, NoSuchFieldException {
    
    
        //获取字段,以便后续手动修改
        Field flag = Lazy.class.getDeclaredField("flag");
        flag.setAccessible(true);

        Constructor<Lazy> declaredConstructor = Lazy.class.getDeclaredConstructor(null);
        //暴力破解,具备访问私有权限的能力
        declaredConstructor.setAccessible(true);
        Lazy lazy1 = declaredConstructor.newInstance();
        System.out.println(flag.getName());
        //再次使用反射创建对象
        flag.set(lazy1,false);
        Lazy lazy2 =declaredConstructor.newInstance();

        System.out.println(lazy2);
        System.out.println(lazy1);
    }

在这里插入图片描述

总结

所以说,单例模式里面的学问也是挺深的了。
要我推荐使用的话,一般情况下,不建议使用懒汉方式,建议使用饿汉方式。只有在要明确实现 lazy loading 效果时,才会使用静态内部类方式。如果涉及到反序列化创建对象时,可以尝试使用枚举方式。如果有其他特殊的需求,可以考虑使用第 3 种双检锁方式。

猜你喜欢

转载自blog.csdn.net/qq_41486775/article/details/113307426