面试常问的单例模式

前言

前段时间面试的时候被问到单例模式没答上,这个些许有些尴尬,的确没有去关注过。只是依稀记得曾经在网上看到过相关资料,记得懒汉式、饿汉式等几个名词。虽说作为一个两年经验的开发不熟悉设计模式也无可厚非,但是对于比较简单、常用的单例模式,我认为还是很有必要掌握的,况且作为一个有架构师梦想的程序员,必须要更加超前。希望本篇文章可以对还不熟悉单例模式的朋友有所帮助。

单例模式场景介绍

单例模式含义就是让程序中某个类运行时只存在一个对象。比如数据库连接池不会反复创建、Spring 同一个容器中一个单例 Bean 的生成和使用。

单例模式主要解决的问题是一个全局使用的类,不会被频繁的创建和销毁,从而提升代码的整体性能。

饿汉式

见名知意,比如人饿了,那首先就要准备好丰盛的食物饱餐一顿。饿汉式的思想就是在类加载的过程中就创建好目标对象,运行时调用方法获取实例时直接返回已经实例化好的对象。

public class HungrySingleton {

    private static final HungrySingleton SINGLETON = new HungrySingleton();
    
    /**
     * 单例模式有一个特点,不允许外部直接创建对象,私有构造不让外部实例化
     */
    private HungrySingleton() {}
    
    public static HungrySingleton getInstance() {
        return SINGLETON;
    }
}

缺点

上述方式只要 JVM 加载了 HungrySingleton 类,不管我们有没有调用 getInstance 方法,都会初始化成员变量创建实例,这样就额外占用了不需要的内存空间,也会拖慢程序启动的时间。

也许你会觉得这一个对象能占用多少空间呀?上述只是简单的例子,如果说这个对象内部还有其他成员变量呢,举个较夸张的例子

public class HungrySingleton {

    private static final HungrySingleton singleton = new HungrySingleton();
    byte[] arr1 = new byte[1024*1024];
    byte[] arr2 = new byte[1024*1024];
    byte[] arr3 = new byte[1024*1024];
    byte[] arr4 = new byte[1024*1024];
    /**
     * 省略...
     */
}

这么一看这个对象占用的内存空间就不小了吧,一旦实例化,这些成员变量都会被初始化…所以通常我们推荐使用懒汉式

懒汉式

大家应该都知道 Spring 的懒加载吧,Spring 的懒加载是指我们不希望这个 Bean 在 Spring 容器启动时就创建,而是在我们运行时真正的用到了这个 Bean ,才会创建实例放入 Spring 容器。

懒汉式就是这样的思想,在运行时调用 getInstance 方法才会创建对象返回,这样就解决了饿汉式方案占用不必要的内存空间和拖慢程序启动时间的问题。

public class LazySingleton {

    private static LazySingleton singleton;
    private LazySingleton(){}

    public static LazySingleton getInstance(){
        if(singleton == null){
            return new LazySingleton();
        }
        return singleton;
    }
}

上述代码就是最简单的懒汉式单例模式代码,看似挺好,但是却隐藏着两个重要问题。

多线程环境下的问题

业务量稍微大一点的项目都离不开并发,假设多个线程同时访问 getInstance 方法,可能会出现线程 A 判断 singleton == nulltrue 然后执行实例化,但是实例化过程还没结束,线程 B 此时也判断 singleton == nulltrue 又执行实例化,那么就会产生多个对象,这违背了单例模式初衷。 所以我们需要加锁来解决这个问题

public static LazySingleton getInstance() {
    synchronized (LazySingleton.class) { //也可以把 synchronized 关键字加在方法上
        if (singleton == null) {
            return new LazySingleton();
        }
    }
    return singleton;
}

上述代码就能解决多线程环境下可能会创建出多个 LazySingleton 实例的问题,锁住当前类的 Class 对象,这样在执行这个方法内部代码时,同一时间只有一个线程能够持有锁执行。不过美中不足的是,每个线程进入该方法都首要获取锁,这样对于性能有损耗。我们可以再优化一下

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

这样在加锁之前先判断一下 singleton 能够显著避免第一种无论如何都要获取锁带来的性能损耗,不过由于指令重排序的存在,上述代码在极限情况下还是可能会出现问题。

指令重排造成 NPE 问题

要谈这个问题就要先了解其他两个概念

  • 指令重排序

为了提高程序运行性能,编译器和处理器可能会对既定的代码指令执行顺序进行重新排序。

什么意思呢?举个例子比如以下代码

a = 3;
b = 2;
a = a + 1;

编译过后,一行代码会对应一个或多个指令,说白了就是本来应该是按照从上到下的顺序执行,但是如果发生重排序,它实际的执行顺序可能是这样的

a = 3;
a = a + 1;
b = 2;

这个重排序要细说的话内容还比较多,以后会专门出一篇文章,总之现在能理解这个重排序是干嘛的就行了。

  • 对象实例化的内部过程
    理解了重排序,我们再来看看对象实例化的步骤。通常我们实例化一个对象的代码 Object obj = new Object(); 它底层并不是一个原子操作。查看这行代码的字节码,它明显分为多个步骤完成的,查阅官方字节码文档就知道这些指令做了什么事情
 0: new           #2        //1. 加载类(如果需要); 2.堆空间开辟一块内存)          
 3: dup                     //操作数栈里面的引用复制一份(这个不需要关心)
 4: invokespecial #1        //调用构造器(这一步完成之后对象那块堆内存才是完整的)     
 7: astore_1                //将操作数栈顶的引用存到局部变量表

上面注释简单列出了这几个指令做的事情(实际细节上比这要复杂的多),也就是说正常情况下的执行顺序是 0,4,7,但是由于重排序的存在可能会导致执行顺序为 0,7,4,再看懒汉式代码,假设第一个线程过来 7 执行结束,4 还未结束,那么切换到第二个线程由于 7 被执行完, if (singleton == null) 将会被判定为 false ,代码就直接返回了还没有被初始化完成的 singleton 实例,所以可能会报 NullPointerException(NPE) 异常

/**
 * 懒汉式代码
 * */
public static LazySingleton getInstance() {
    if (singleton == null) { //由于 astore_1 执行完,singleton 将被判定为非 null 直接 return
        synchronized (LazySingleton.class) {
            if (singleton == null) {
                return new LazySingleton();
            }
        }
    }
    return singleton;
}

值得注意的是上述字节码的例子 obj 是线程私有的,只是想举个例子让大家理解这个 new 的过程分为很多步,懒汉式 singleton 变量是全局变量,实际上字节码指令大体上是相同的,都会发生指令重排现象。

当然解决这个问题要比理解这个问题简单的多,我们都知道 Java 中直接使用 volatile 关键字修饰共享变量即可防止指令的重排序。

private static volatile LazySingleton singleton;

这样上述懒汉式代码就是真正安全的了。

静态内部类实现单例

由于 JVM 的类加载是懒加载,一个类真正被用到才会去进行加载,利用这个特性我们可以使用静态内部类的方式实现懒汉式。由于 JVM 虚拟机在加载类的时候可以保证多个线程并发访问的正确性,即使不用 synchronized 关键字,也能实现懒汉式的线程安全问题。

public class InnerClassSingleton {
    /**
     * 静态内部类
     */
    private static class SingletonHolder {
        private static final InnerClassSingleton SINGLETON = new InnerClassSingleton();
    }

    private InnerClassSingleton() {}

    public static InnerClassSingleton getInstance() {
        return SingletonHolder.SINGLETON;
    }
}

这种方式不需要使用 synchronized 关键字就能实现懒汉式的线程安全,是开发中比较推荐使用的一种方式。

值得注意的是,其实只是我们没有显示加锁,在类加载器的 loadClass(...) 方法源码中还是使用了 synchronized 来实现线程安全。

使用反射破坏单例

你可能发现了前面说的几种方案在没有恶意干预的情况下是可以在开发中应用的,但由于 Java 中强大的反射机制存在,我们可以写代码去破坏单例。就以静态内部类实现方式为例

public static void main(String[] args) throws Exception {
    InnerClassSingleton instance = InnerClassSingleton.getInstance();//正常的对象
    Constructor<InnerClassSingleton> constructor = InnerClassSingleton.class.getDeclaredConstructor();
    constructor.setAccessible(true);
    InnerClassSingleton innerClassSingleton = constructor.newInstance();//反射创建的对象
    System.out.println(instance == innerClassSingleton);//false,不是同一个对象
}

上述代码就可以破坏这种单例的实现,不过这是比较严谨的考虑,正常情况下也不会有哪个憨批吃饱了撑的去这么玩…如果说是为了防止应用程序被有心人找到漏洞入侵放入恶意代码搞破坏确实是有可能的,曾经就在网上看到一个案例是利用一个接口还是页面植入了一段服务器端的代码…

用枚举防止反射破坏

由于前几种方案都存在可以被反射暴力破解的问题,所以 《Effective Java》 一书的作者提出了一种枚举方案。观察 Constructor 类的 newInstance(Object... args) 方法源码可以发现这么一行

if ((clazz.getModifiers() & Modifier.ENUM) != 0)
    throw new IllegalArgumentException("Cannot reflectively create enum objects");

这里 if 操作就是判断如果你当前用反射创建的对象是枚举类型,那么就给你抛异常,不允许用反射创建枚举对象。这特么不正好解决了我们的问题吗?而且使用枚举实现单例,代码极其优美

/**
 * 枚举实现单例,代码极其优美
 */
public enum EnumSingleton {
    INSTANCE;

    public void whateverToDo() {
        System.out.println("无论做什么");
    }
}

狡猾的枚举构造

有意思的是如果你使用上面的反射案例测试能否破坏枚举单例,那么你可能真的会遇到报错,但不是 newInstance(Object...args)方法报的,而是报这个 NoSuchMethodException 错误。这是因为枚举真正的构造方法实际上是隐藏的,并不是反编译体现的无参构造,网上很多人文章或者视频说枚举的构造方法无法通过反射调用,其实并非如此。只是常用的反编译方法无法查看到真正的枚举构造方法的形参列表,这个需要使用更为专业的软件去查看。

针对上面的例子只要使用下面这段代码就能拿到正确的 Constructor 对象,这样使用反射去调用 newInstance() 方法才会抛出预期的不允许使用反射创建枚举对象的异常。

Constructor<EnumSingleton> constructor = EnumSingleton.class.getDeclaredConstructor(String.class,int.class);
constructor.setAccessible(true);
EnumSingleton singleton = constructor.newInstance();

枚举实现单例的局限

由于枚举本身就隐继承了 Enum 类,又因为 Java 不支持多继承,所以在存在继承的场景下这种方式不适用。另外,枚举也是在类加载阶段完成对象的创建,这一点和饿汉式类似。

结语

本篇文章虽然只是在谈一个小小的单例模式实现方案,但是仔细阅读却涉及到了很多值的深入挖掘的底层知识。例如 类加载、对象初始化详细过程、指令重排序与禁用重排序、字节码指令、线程安全、JMM 内存模型 等。

如果这篇文章对你有帮助,记得点赞加关注。你的支持就是我继续创作的动力!

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
面试常问设计模式有很多,以下是一些常见的设计模式: 1. 单例模式(Singleton Pattern):确保一个类只有一个实例,并提供一个全局访问点。 2. 工厂模式(Factory Pattern):定义一个用于创建对象的接口,由子类决定实例化哪个类。 3. 抽象工厂模式(Abstract Factory Pattern):提供一个创建一系列相关或相互依赖对象的接口,而无需指定具体类。 4. 建造者模式(Builder Pattern):将一个复杂对象的构建与其表示分离,使得同样的构建过程可以创建不同的表示。 5. 原型模式(Prototype Pattern):通过复制现有对象来生成新对象,避免了使用new关键字创建对象。 6. 适配器模式(Adapter Pattern):将一个类的接口转换成客户端所期望的另一个接口。 7. 装饰器模式(Decorator Pattern):动态地给对象添加额外的职责,同时又不改变其结构。 8. 观察者模式(Observer Pattern):定义了对象之间的一对多依赖关系,当一个对象状态发生改变时,所有依赖它的对象都会得到通知并自动更新。 9. 策略模式(Strategy Pattern):定义了一系列的算法,并将每个算法封装起来,使它们可以互相替换。 10. 模板方法模式(Template Method Pattern):定义了一个算法的骨架,将一些步骤延迟到子类中实现。 这些只是一些常见的设计模式,具体还会根据面试的要求和职位不同而有所变化。在面试中重要的是理解每个设计模式的原理、适用场景以及优缺点,并能够灵活运用到实际问题中。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值