挑战一文搞懂带你搞懂单例模式,面试手撕双重检查锁定单例模式不害怕!

188 阅读9分钟

大家好,我是程序员牛肉。

最近在刷牛客的时候,发现现在的面试官出笔试题都已经不局限在Hot100,大把大把的同学在面试的时候被考到了与设计模式相关的笔试题。

而在其中,手撕双重检查锁定下的单例模式出现的频率最高。

图片

因此我们用这篇文章来介绍一下设计模式中的单例模式,从最基础的什么是单例模式一路讲到手撕双重校验锁下的单例模式。让你真正理解为什么我们在使用单例模式的时候要使用双重校验锁。

先说说什么是单例模式吧:

在我们的项目开发中,对于一些类我们全局只要一个就足够了。在这种情况下,我们需要保证在全局中这个类不会被重复创建,始终只有一个。无论我们何时何地访问它,得到的都是同一个实例。

而这种设计模式就是单例模式。他在Java中是一个很常见的设计思路,我们常见的Drivemanager其实就是单例模式:

图片

而单例模式的设计从整体上来讲分为两种类型:懒汉式和饿汉式。

饿汉式:

饿汉式单例模式在类加载时就创建实例。这种方式的特点是类加载时立即初始化实例。由于他在类加载的时候就去初始化实例,因此天生就是线程安全的。

[jvm对于类的加载会加类锁,所以多线程的情况下也可以保证实例化阶段是一个线程在执行]

饿汉式从具体的代码层面来讲,可以分为静态变量创建和静态代码块创建:

/**
 * 饿汉式 静态变量类型
 */
public class HungrrySingleton {
    //私有构造方法
    private HungrrySingleton(){};

    //全局静态变量
    private static HungrrySingleton instance = new HungrrySingleton();

    //提供方法访问对应的单例对象
    public static HungrrySingleton getInstance(){
        return instance;
    }

}
/**
 * 饿汉式 静态代码块类型
 */
public class HungrrySingletonStaticblock{
    //私有构造方法
    private HungrrySingletonStaticblock(){}

    //在成员位置创建该类
    private static HungrrySingletonStaticblock instance;
    static {
        instance = new HungrrySingletonStaticblock();
    }
    public static HungrrySingletonStaticblock getInstance(){
        return instance;
    }
}

懒汉式:

懒汉式单例模式并不会在当前类创建的时候就去创建实例,而是第一次使用的时候才会去创建实例,这种思路可以在一定程度上减少空间的浪费。

代价是由于没有把实例的创建放到类加载阶段,因此单纯的懒汉式单例模式不是线程安全的。

/**
 * 懒汉式(存在线程不安全)
 */
public class lazzySingleton {
    //私有构造方法
    private lazzySingleton(){}

    //声明对象
    private static lazzySingleton instance;

    //对外提供访问方式
    public static lazzySingleton getInstance(){
        if (instance == null){
            instance = new lazzySingleton();
        }
        return instance;
    }
}

由于在这一过程中,我们并没有对getInstance方法加锁,因此可能会出现线程安全性的问题。

假设我们有两个线程:线程A和线程B

线程 A 和 线程 B 同时调用 getInstance()。两个线程都看到 instance 为 null,因此都通过了 if (instance == null) 检查。两个线程都进入了 if 块,分别执行 instance = new lazzySingleton();,导致创建了两个不同的 lazzySingleton 实例。

怎么解决这个问题?太好解决了,直接给getInstance方法加锁就完事了。

于是我们的懒汉式单例模式进入2.0阶段:

class lazzySingletonSync{
    //私有构造方法
    private lazzySingletonSync(){};
    //声明对象
    private static lazzySingletonSync instance;
    //提供方法访问对应的单例对象
    public static synchronized lazzySingletonSync getInstance(){
        if (instance == null){
            instance = new lazzySingletonSync();
        }
        return instance;
    }}

可是如此简单粗暴的加锁会带来一个问题:明明只有第一次创建这个实例的时候需要加锁,可是我们每一次进入这个方法都要去争夺这个锁。

如果你只是使用这个实例的话,加锁干什么?这不是白白耗费性能嘛。于是我们进行了以下优化:只在第一次创建这个实例的时候加锁。

于是我们迎来了懒汉式的3.0版本:

/**
 * 懒汉式(线程安全 双重校验锁)
 */
class lazzySingletonDoubleCheck{
    //私有构造方法
    private lazzySingletonDoubleCheck(){
    }
    //声明对象
    private static  volatile lazzySingletonDoubleCheck instance;
    //提供方法访问对应的单例对象
    public static lazzySingletonDoubleCheck getInstance(){
        if (instance == null)//第一重判断
        {
            synchronized (lazzySingletonDoubleCheck.class){
                if (instance == null)//第二重判断
                {
                    instance = new lazzySingletonDoubleCheck();
                }
            }
        }
        return instance;
    }}

在这里解释一下为什么要使用volatile关键字:

现代处理器和编译器为了优化性能,可能会对指令执行顺序进行优化,即指令重排序。在创建对象的过程中,new Singleton() 不是一个原子操作,实际上可以分为三个步骤:

  • 为对象分配内存。

  • 调用构造函数,初始化对象。

  • 将对象引用赋值给变量。

由于指令重排序,步骤 2 和步骤 3 可能会被交换。这样,其他线程可能会在对象尚未完全初始化时看到一个非空引用,从而导致程序出现不可预测的行为。

[当一个线程执行到步骤 2 时,instance 已经指向分配的内存空间,但对象还没有被完全初始化。此时,如果另一个线程调用 getInstance(),它会看到 instance 不为 null,并返回这个尚未完全初始化的对象。]

当你能够理解我们上述讲的东西,你也就理解了什么是”双重检查锁定单例模式“。

而除了这种基于手动加锁的形式来实现单例模式之外,我们其实还有其他更好玩的手段:

1.基于静态内部类来构造单例模式

/**
 * 懒汉式 (静态内部类方式)
 */
class lazzySingletonInnerClass{
    //私有构造方法
    private lazzySingletonInnerClass(){}
    //静态内部类
    private static class InnerClass{
        private static final lazzySingletonInnerClass instance = new lazzySingletonInnerClass();
    }
    //提供方法访问对应的单例对象
    public static lazzySingletonInnerClass getInstance(){
        return InnerClass.instance;
    }
}

静态内部类有两个特点:

  • 延迟加载:静态内部类在外部类加载时并不会被立即加载,只有在需要时才加载,从而实现延迟加载。

  • 线程安全:JVM 在加载类时会保证类加载的线程安全性,因此静态内部类方案天然地是线程安全的。

多吓人,静态内部类竟然完美符合懒汉式单例模式的要求。而下面这个玩的更花。

2.基于枚举类实现单例模式

**
 * 枚举类。
 */
enum EnumSingleton{
    INSTANCE;
}

我只能称这个设计为逆天设计。枚举类型的单例实现非常简洁,只需定义一个枚举类型,并声明一个枚举常量即可。并且不需要显式地编写线程安全的代码,因为枚举类型本身是线程安全的。

其实关于单例模式的创建方式也就讲完了,我们最后讲一下如何破坏单例模式。

其实也就两种方法:序列化和反射。序列化我就不讲了,没啥意思。大家可以在网上自己搜一搜,这篇我就只讲反射了。

Java的反射真的太邪恶了。你就算把构造模式设置为私有又能怎么样?我直接用反射修改你的可见性之后调用你。

import java.lang.reflect.Constructor;

public class ReflectionTest {
    public static void main(String[] args) {
        try {
            // 获取Singleton类的构造方法
            Constructor<Singleton> constructor = Singleton.class.getDeclaredConstructor();
            // 设置为可访问
            constructor.setAccessible(true);
            // 创建新的实例
            Singleton instance1 = constructor.newInstance();
            Singleton instance2 = constructor.newInstance();

            System.out.println(instance1);
            System.out.println(instance2);
            System.out.println(instance1 == instance2); // 输出 false,表示是不同的实例
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

那我们要如何预防反射来破坏单例模式呢?网上目前的主流手段是加标志位来标识当前实例是否有被创建过。

public class Singleton {
    private static Singleton instance;
    private static boolean instanceCreated = false;

    private Singleton() {
        if (instanceCreated) {
            throw new RuntimeException("Singleton instance already created!");
        }
        instanceCreated = true;
    }

    public static Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

在我看来这个设计有点脱裤子放屁。我管你什么标志位不标志位的,你看我用反射修改不修改你就完事了。

标识位记录当前是否有被初始化是吧?我直接用反射给你赋值False。

public class Singleton {
   // 获取 Singleton 类的 Class 对象
    Class<Singleton> singletonClass = Singleton.class;
            
   // 获取 instanceCreated 字段
    Field instanceCreatedField = singletonClass.getDeclaredField("instanceCreated");
            
   // 设置字段为可访问
     instanceCreatedField.setAccessible(true);
            
  // 将 instanceCreated 设置为 false
    nstanceCreatedField.set(nullfalse);
}

所以我个人不建议你在面试的时候用“加标志位”来回答面试官的“如何防止反射破坏单例模式”。

那到底要怎么做才能防止反射破坏单例模式呢?

简单!还记得我们前面提到的基于枚举实现单例模式嘛?你试试用反射给我修改一个枚举看看:

/**
 * 枚举类。
 */
enum EnumSingleton{
    INSTANCE;
}

那话说到这里了,枚举到底是怎么防止邪恶的Java反射机制来修改的?我们看一看源码:

点开Enum的源码,其实就可以发现一个知识点:Enum没有无参构造函数。

图片

因此应该用反射手动的去获取对应的带参数的构造方法:

Constructor<Singleton> constructor = 
singletonClass.getDeclaredConstructor(String.class, int.class);

但其实这样还不行,当我们使用构造器来构造新对象的时候,要使用到newInstance方法:

  Singleton instance1 = constructor.newInstance();

 让我们点到这个newInstance源码中去看一看,会发现这个方法又调用了newInstanceWithCaller方法:

图片

初见端倪了,这里有一个方法进行了判断,之后抛出了一个异常是:“不能反射枚举对象”。

那么这个16384是什么呢?为什么要和当前类的Modifiers搞&运算?Modifiers又是什么?

在Java中,Modifiers 是一个与反射(Reflection)相关的主题,涉及到类、接口、方法和字段的访问修饰符。这些修饰符定义了代码的访问级别和行为特性。更多的详情可以看java.lang.reflect.Modifier这个包。

在这个包中我们找到了答案:

图片

原来枚举类的Modifiers值就是16384。也就是说那段代码的意思是判断当前对象的Modifiers值是不是16384,如果是的话就说明当前类是个枚举类,直接抛出异常,这也是为什么不能对枚举类进行反射的直接原因。

今天关于“手撕双重检查锁定下的单例模式”就介绍到这里了,希望我的文章可以帮到你。后续我也会陆续把所有的设计模式介绍完。

关于单例模式或者设计模式你有什么想说的嘛?欢迎在评论区留言。

关注我,带你了解更多技术干货。

19ae0dc40b60c8d75dd22f42156665e.jpg