一文彻底搞懂各个姿势的单例模式

一、什么是单例模式

单例模式(Singleton Pattern)是 Java 中最简单的设计模式之一。这种类型的设计模式属于创建型模式,它提供了一种创建对象的最佳方式。

这种模式涉及到一个单一的类,该类负责创建自己的对象,同时确保只有单个对象被创建。这个类提供了一种访问其唯一的对象的方式,可以直接访问,不需要实例化该类的对象。

注意:

  1. 单例类只能有一个实例。
  2. 单例类必须自己创建自己的唯一实例。
  3. 单例类必须给所有其他对象提供这一实例。

说白了就是在内存中,该类只存在一个实体对象!主要解决类的频繁地创建与销毁!

二、单例模式的类型

1. 饿汉式

类一旦加载,就把单例初始化完成。即在加载类时就创建实例,需要用的时候直接拿来用

public class HungryDemo {
   
    // 1. 直接初始化对象,分配内存
    private static HungryDemo demo = new HungryDemo();

    // 单例模式都是私用构造:确保只有一个对象
    private HungryDemo(){}
   
    private static HungryDemo getInstance(){
        return demo;
    }

    public static void main(String[] args) {
        // 2.通过 getInstance() 方法获取该类的唯一实例对象
        HungryDemo demo1 = HungryDemo.getInstance();
        HungryDemo demo2 = HungryDemo.getInstance();
        System.out.println(demo1);
        System.out.println(demo2);
    }
}

执行结果:实例对象是同一个!

一文彻底搞懂各个姿势的单例模式_第1张图片

总结:执行效率高,但浪费内存!因为类加载就初始化了实例对象,分配了内存空间,如果没有使用此类,就会造成空间的浪费 

——因为饿汉式单例模式,会造成空间浪费,所以延伸了懒汉式单例模式

2. 懒汉式

延时加载。比较懒,只有当调用getInstance的时候,才初始化这个单例。

public class LazyDemo {
    private static LazyDemo demo ;

    private LazyDemo(){
        // 3. 打印当前线程,检查对象创建情况
        System.out.println(Thread.currentThread().getName());
    }

    private static LazyDemo getInstance(){
        // 1.判断对象是否为空,如果空,则进行第一次初始化
        if(demo == null){
            demo = new LazyDemo();
        }
        return demo;
    }
    public static void main(String[] args) {
        // 2. 模拟10个线程同时调用getInstance获取单例对象
        for (int i = 0; i < 10; i++) {
            new Thread(()->{
                LazyDemo.getInstance();
            }).start();
        }
    }
}

执行结果:每次执行结果不一致,存在多个线程获取到不是同一个实例对象,因此,在多线程下,懒汉式的单例模式是线程不安全的!

一文彻底搞懂各个姿势的单例模式_第2张图片

要想解决懒汉式在多线程下保证线程安全,可以使用synchronized加锁:

// 1.在方法上加锁
private synchronized static LazyDemo getInstance(){
      if(demo == null){
         demo = new LazyDemo();
      }
      return demo;
 }

执行结果:只有一个线程能够进入初始化操作方法,加锁后确保了多线程下保证线程安全

一文彻底搞懂各个姿势的单例模式_第3张图片

这样虽然解决了问题,但因为用到了synchronized,会导致很大的性能开销,并且加锁其实只需要在第一次初始化的时候用到,即可以放在代码块中,从而降低锁的粒度。

如下:

private static LazyDemo getInstance(){
      // 1. 第一次检查(非同步)
      if(demo == null){
          synchronized(LazyDemo.class){
              // 2. 第二次检查(同步)
              if(demo == null){
                 demo = new LazyDemo();
              }
          }
      }
      return demo;
 }

执行出来的结果看着像是线程安全的,只有一个线程能够进入初始化对象的方法

——为什么要检查2次对象判空判断?
答:双重检查锁(DCL:Double Check Lock)。先判断对象是否已被初始化,再决定是否加锁。如果多个线程同时通过了第一次检查,其中线程A首先通过了第二次检查并实例化了对象,释放了锁,其他通过了第一次检查的线程获得锁后,因为有第二次的检查(线程A已初始化了对象使其不为null),故不会继续执行实例化!

思考:为什么上述我说执行结果看着是线程安全,双重检查就一定是线程安全的吗?

答:不一定!双重检查锁同时体现了同步中的独占性可见性同等的重要性。上述代码中只展现出了独占性,在极端的情况下,双重检查锁还是存在非线程安全的,学习过并发编程的同学可能已经看出了猫腻了:

demo = new LazyDemo();

上述初始化实例这行代码,并非是一个原子操作,实际上它分为三步操作:

  1. 分配内存空间 
  2. 执行构造方法,初始化对象
  3. 把这个对象指向这个空间

正常执行步骤是按照上述:123顺序执行,因为存在编译器优化导致指令重排序的现象,就会导致实际执行的顺序与编写代码的顺序不一致情况。

比如线程A先进来执行,经过指令重排执行的顺序为132,线程A执行完13步骤时(2还未执行),此时线程B拿到执行权,执行到第一次检查 if 对象判空时(注意:在线程A没执行完释放锁之前,线程B是无法进入到synchronized同步块中的),因线程A已完成内存的分配(未初始化),故线程B不满足进入if ,而是直接return返回了demo对象,线程B最终拿到的demo对象是没有进行初始化操作的,即空对象!

针对上述举例说明的问题,我们可以通过Java关键字volatile修饰,从而可以禁止指令重排现象!

// 增加volatile
private volatile static LazyDemo demo ;

注意:我在上篇文章中讲解 Java内存模型中详细介绍过了volatile,感兴趣的同学可翻阅!

注:同步块能够保证多个线程有序的(即同步)执行块中的代码,但并不能避免块中的代码发生重排序。

3. 静态内部类

单例的实现,不仅是懒汉和饿汉式,还可以通过静态内部类来实现,如下:

public class HolderDemo {

    private HolderDemo(){}

    private static HolderDemo getInstance(){
        return InnerClass.demo;
    }

    // 定义静态内部类,返回对象实体
    private static class InnerClass{
        private static HolderDemo demo = new HolderDemo();
    }
}

——单例模式真的安全吗?

三、不同姿势的反射破坏单例模式

单例模式虽然增加了DCL双重检查,能够避免多线程下单例模式的唯一性!但是学过反射的同学可能知道,反射也是可以破坏单例模式的!

姿势一:getIntance() + 反射

代码示例:

public class LazyDemo {
    private volatile static LazyDemo demo ;

    // 私有无参构造
    private LazyDemo(){

    }

    // DCL双重检查
    private static LazyDemo getInstance(){
        // 1. 第一次检查(非同步)
        if(demo == null){
            synchronized(LazyDemo.class){
                // 2. 第二次检查(同步)
                if(demo == null){
                    demo = new LazyDemo();
                }
            }
        }
        return demo;
    }

    // 反射破坏
    public static void main(String[] args) throws Exception {
        // 1.通过getInstance()方法获取
        LazyDemo demo1 = LazyDemo.getInstance();

        // 反射获取私有的无参构造函数
        Constructor constructor = LazyDemo.class.getDeclaredConstructor(null);
        constructor.setAccessible(true); // 跳过权限检查

        // 2.通过反射调用构造函数初始化一个对象
        LazyDemo demo2 = constructor.newInstance();
        System.out.println(demo1);
        System.out.println(demo2);
    }
」

执行结果:一个通过反射调用构造函数生成的,一个是getInstance()提供的,产生2个不同实例!

一文彻底搞懂各个姿势的单例模式_第4张图片

 结论:一旦类能够被反射获取,那么就可以对类进行随意操作!就无法保证单例模式的安全!即便加上了DCL双重检查,还是无法避免道高一尺魔高一丈!

——那么如何解决呢?

DCL中我们通过synchronized类锁的方式达到同步机制的,锁的是class对象,在内存中只有一个class,所以我们可以在构造函数中也加上锁!

 private LazyDemo(){
    // 加锁,判断对象是否为空,如果不为空,抛出异常!
    synchronized(LazyDemo.class){
        if(demo != null){
            throw new RuntimeException("禁止使用反射破坏单例!");
        }
   }
}

执行结果:多次初始化实例将抛出异常,双重检测升级为三重检测,一定程度上避免反射破坏

一文彻底搞懂各个姿势的单例模式_第5张图片

姿势二:反射 + 反射

代码示例:

public class LazyDemo {
    private volatile static LazyDemo demo ;

    // 私有构造
    private LazyDemo(){
        // 加锁,判断对象是否为空,如果不为空,抛出异常!
        synchronized(LazyDemo.class){
            if(demo != null){
                throw new RuntimeException("禁止使用反射破坏单例!");
            }
        }
    }

    // DCL双重检查
    private static LazyDemo getInstance(){
        ...
    }

    // 反射破坏
    public static void main(String[] args) throws Exception {
    
        // 1. 通过反射获取私有的无参构造函数
        Constructor constructor = LazyDemo.class.getDeclaredConstructor(null);
        constructor.setAccessible(true); // 跳过权限检查

        // 2.通过反射调用构造函数初始化2个对象,demo1、demo2
        LazyDemo demo1 = constructor.newInstance();
        LazyDemo demo2 = constructor.newInstance();

        System.out.println(demo1);
        System.out.println(demo2);
    }
」

执行结果:通过反射构造2个实例对象,结果实例对象不是同一个!也破坏了单例模式的唯一性!

一文彻底搞懂各个姿势的单例模式_第6张图片

解决:我们在构造函数中定义一个中间变量,来判断调用构造函数的次数 ,达到一次调用目的!

// 1.使用中间变量限制多次调用构造函数
private static boolean isTransfer = false;

private LazyDemo(){
      
        synchronized(LazyDemo.class){
            // false 说明是第一次调用
            if(isTransfer == false){
                isTransfer=true;
            // true 说明是第二次调用(实例已经存在了),抛出异常
            }else{
                throw new RuntimeException("禁止使用反射破坏单例!");
            }
        }
}

 执行结果:通过中间变量,也能达到构造函数限制被调用一次,只初始化一个实例的目的!

一文彻底搞懂各个姿势的单例模式_第7张图片

——天网恢恢疏而不漏

通过中间变量就能够完美解决反射对单例模式的破坏吗???? 你错了!私有的构造函数能够通过反射获取,难道私有的成员变量就不能通过反射获取吗! 

代码如下:

 public static void main(String[] args) throws Exception {

        // 1. 通过反射获取私有的无参构造函数
        Constructor constructor = LazyDemo.class.getDeclaredConstructor(null);
        constructor.setAccessible(true); // 跳过权限检查

        // 2.通过反射调用构造函数初始化2个对象
        LazyDemo demo1 = constructor.newInstance();


        // 已知demo1初始化执行了newInstance(),私有参数isTransfer 会由false变为true
        // 3.通过反射获取私有成员变量 isTransfer
        Field field = LazyDemo.class.getDeclaredField("isTransfer");
        field.setAccessible(true);

        // 我们把isTransfer参数的值,重新设置为false,这样demo2的初始化就能够成功!
        field.set(demo1,false);


        LazyDemo demo2 = constructor.newInstance();
        System.out.println(demo1);
        System.out.println(demo2);
    }

执行结果:是的!你没有看错!初始化了2个实例对象,单例模式还是被破坏了!私有变量也可以通过反射获取,并且可以随意修改值

一文彻底搞懂各个姿势的单例模式_第8张图片

——终极大招

思考:难道就没有办法保证单例模式的唯一性了吗?

通过上述代码可以得知,反射初始化对象,实际上是调用了newInstance() 方法,来看下其源码:

一文彻底搞懂各个姿势的单例模式_第9张图片

从源码上分析,红框标出来的部分:不能通过反射破坏枚举类!

也就是说,如果你的类是一个枚举类,那么就无法通过反射获取此类,更无法破坏!

验证:枚举类是否可以进行反射获取?

1、定义枚举类

public enum EnumDemo {
    INSTANCE;

    private static EnumDemo getInstance(){
        return INSTANCE;
    }
}

2、定义测试类

通过定义一个外部类,来测试枚举类是否能够通过反射获取到

class Test{
    public static void main(String[] args) throws Exception {
        // 1.通过枚举获取实体
        EnumDemo demo1 = EnumDemo.INSTANCE;

        // 通过反射获取私有的无参构造函数
        Constructor constructor = EnumDemo.class.getDeclaredConstructor(null);
        constructor.setAccessible(true);
        // 2.通过反射获取实体
        EnumDemo demo2 = constructor.newInstance();

        System.out.println(demo1);
        System.out.println(demo2);
    }
}

运行结果:提示没有找到此方法????

一文彻底搞懂各个姿势的单例模式_第10张图片

一文彻底搞懂各个姿势的单例模式_第11张图片一文彻底搞懂各个姿势的单例模式_第12张图片一文彻底搞懂各个姿势的单例模式_第13张图片一文彻底搞懂各个姿势的单例模式_第14张图片

分析大法:

根据我们上述查看的 newInstance() 源码,如果通过反射获取枚举类,理应抛出:Cannot reflectively create enum objects  错误! 但是这里却报的是没有找到该构造方法

通过target/classes 文件夹下,查看生成的class文件,或者通过 javap -p xxx.class 查看:一文彻底搞懂各个姿势的单例模式_第15张图片

一文彻底搞懂各个姿势的单例模式_第16张图片

结论:通过上述字节码文件可以看出,该类是有无参构造函数的 !! ??

字节码文件中显示的是有构造函数的,但是进行反射的时候,为什么还提示我们没有找到这个方法?接下来继续使用反编译查看java源码:

在线Java反编译:Java decompiler online

——使用简单,直接将 jar 包和 class 文件拖到页面即可。(注:这里使用的是 jad方式)

得到的反编译的.java源码如下:

一文彻底搞懂各个姿势的单例模式_第17张图片

哦吼!通过源码可看到EnumDemo继承了Enum枚举类,并且实现了一个有参数的构造函数,并不是class文件看到的  无参构造函数!!(看来万恶的根源在源头,可恶!!)

OK!我们已经知晓了构造函数是有参数的,那我们继续修改上述通过反射获取的构造函数中的代码,在方法里传入两个类型即可: getDeclaredConstructor(String.class,int.class)

// 通过反射获取私有的有参构造函数
Constructor constructor = EnumDemo.class.getDeclaredConstructor(String.class,int.class);

此时我们再来看一下反射的结果:get !  触发了无法通过反射获取枚举类的错误!

四、总结

反射的存在也会单例模式的不安全!如果需要保证单例模式的唯一性,可以使用枚举类!

你可能感兴趣的:(并发编程,单例模式,java,反射)