面试官,你要跟我聊单例?那我可有话说了

1,197 阅读18分钟

单例模式

文章的初衷

问个小问题,饿汉式单例的缺点是调用时可能会造成内存消耗。那么能讲下,它到底是如何消耗内存的吗?里面的原理是什么呢?

如果你对这个问题存疑,那么我推荐你看这篇文章,看完之后你会发现,单例模式可能并不像你想的那么简单!

本文目的是对知识的一个总结,俗话说的好,好记性不如烂笔头。经常总结,也能帮助总结知识点增强记忆。另一个目的是希望能够小小的科普一下,单例所涉及的知识点。

希望读了本文之后,再遇到面试官问你单例的相关知识点时,你能够胸有成竹,让他对你刮目相看

几个小问题

  1. 单例模式有几种写法
  2. 饿汉式如何保证线程安全
  3. 类加载的过程都有什么,能介绍下每个阶段都做了什么嘛
  4. volatile都有什么作用,什么是指令重排序
  5. 静态内部类单例是如何做到线程安全的。它的缺点是什么
  6. 为什么说枚举占内存,为什么枚举不能被反射

看到这里,你可能会说,这几个问题有的和单例也没关系啊!的确,这里有些问题和单例是没关系,但是有没有一种可能,面试官压根就不是想单纯的问你单例的写法,这些单例引申出的知识点,才是他真正的目的

单例的写法

首先是上面的第一个问题,单例的写法。 关于单例的写法,这里不再多做介绍了,网上太多的文章了。这里直接说答案,一共5种写法。这里贴一篇垃圾科普文单例模式,今天你用了嘛

直入主题

下面我们依据每种写法,来看一下它们的优缺点,以及使用时可能碰到的问题。

1. 饿汉式

我们先来看一下,最简单的单例写法饿汉式。
饿汉式的优点是:写法简单,且线程安全。那么它的缺点是什么呢。

看下面的代码就知道了。对比其他文章的饿汉式,我往里加了一些比较极端的代码,为了方便理解。

/**
 * @author jtl
 * @date 2021/7/20 11:14
 * 饿汉式单例
 * 优点:线程安全的
 * 缺点:由于类加载时就会创建对象。会造成内存浪费。
 */

public class HungrySingle {
    private static final HungrySingle S_HUNGRY_SINGLE = new HungrySingle();
    // 只要加载HungrySingle类,就会创建50M内存的数组。
    private byte[] aaa = new byte[1024*1024*50];
    
    private HungrySingle(){
    }
    
    public static HungrySingle getInstance(){
        return S_HUNGRY_SINGLE;
    }
    // 调用test方法时,使用aaa数组
    public void test(){
        for (byte data:aaa){
            data = 127;
        }
    }
    
    public static int info(){
        return 2;
    }
}

饿汉式缺点:

上面的例子中,有点极端,但是确很好的体现了饿汉式的缺点。

  1. 假设,我们当前的业务逻辑中没有创建过HungrySingle这个对象。

  2. 现在我的业务逻辑,需要调用HungrySingle.info(); 这个 静态方法

  3. 由于调用了HungrySingle这个类的静态方法,因此将会执行HungrySingle的类加载

  4. 由于执行类加载,会对代码中的static变量赋值,且会执行static静态代码块。因此会执行private static final HungrySingle S_HUNGRY_SINGLE = new HungrySingle();所以会创建对象

  5. 由于创建对象,导致HungrySingle类中的非静态变量也初始化。所以该类会创建aaa数组,即使我现在没有调用test,不需要使用这个数组

  6. 最终饿汉式单例,就会因为调用了一个static方法而创建对象,从而申请不必要的内存,导致浪费性能

面试官可能的问题:

饿汉式浪费内存的原因是:由于饿汉式的写法,倒置类加载时会创建对象,所以会造成内存浪费。

比如例子中,只要创建HungrySingle的对象,会平白无故的创建50m内存的数组。那么什么时候会创建对象呢?由于S_HUNGRY_SINGLE是static 修饰的,所以一执行类加载就会创建对象。

这里就可能就包含面试官想考你的知识点了:

  1. 你能描述下类加载的过程嘛?
  2. 什么时候会执行类加载?

什么是类加载

首先饿汉式涉及到的第一个知识点,就是类加载
类加载是什么呢,当我们使用一个类的时候,首先要做的是把这个类,也就是我们java文件编译出的.class文件,加载到虚拟机之中。
类加载分为5个基本步骤:

  1. 加载:将class文件二进制字节流的方式加载到内存中
  2. 验证:验证字节码的安全性,以防有人篡改字节码
  3. 准备:静态变量默认初始值(int类型初始值为0,引用类型为null等),static final修饰的常量在这一步直接赋值(static final修饰的基本数据类型会将结果编译到字节码中)
  4. 解析:将符号引用转换为直接引用
  5. 初始化:执行static代码块,初始化static变量,该步骤即为clinit

经过类加载之后,该类的相关数据会被保存在,方法区中存放类型信息的位置。另外,类的生命周期还包括使用和卸载,此处讲的是类的加载过程,所以没有把这两个生命周期写入。

什么时候会执行类加载呢?

当JVM执行HungrySingle这个类的相关代码的时候,第一件事情就是去查看方法区中是否存在该类的信息。如果存在,证明已经加载过HungrySingle,如果不存在,那么就执行HungrySingle的类加载。

以上面代码为例:一旦加载该类,由于static final HungrySingle S_HUNGRY_SINGLE = new HungrySingle(); 的缘故就会在类加载的初始化阶段创建对象。又因为创建对象的时候会创建数组byte[] aaa,而这个aaa数组又需要几十m的内存,因此造成了性能浪费。

涉及的知识点:

  1. 什么时候会执行类加载
  2. 类加载的过程
  3. static修饰的变量什么时候进行赋值初始化

不推荐的原因:

类加载时会创建该类对象,可能会无意中创建一些占用内存的对象或者数组,造成性能浪费。


2. 懒汉式

懒汉式单例,可以避免上面饿汉式中,调用static方法时就创建对象的这个缺点。同时通过增加synchronized关键字,保证了线程安全性

下面是懒汉式的写法

/**
 * @author jtl
 * @date 2021/7/20 11:41
 * 懒汉式
 * 优点:不会造成内存浪费
 * 缺点:不加synchronized 会造成线程安全问题
 *       加 synchronized 会造成性能浪费。
 *
 */

public class LazySingle {
    private static LazySingle sLazy ;

    private LazySingle(){
        System.out.println("懒汉式:"+Thread.currentThread().getName());
    }

    public static synchronized LazySingle getInstance(){
        if (sLazy==null){
            sLazy = new LazySingle();
        }

        return sLazy;
    }
}

懒汉式的缺点

懒汉式通过synchronized修饰getInstance方法,来保证多个线程同时调用getInstance时,不会在内存中创建多个LazySingle对象,即保证了它的线程的安全性。但是由于每次调用都会获取锁,所以会造成性能上的损耗。

面试官的切入点

面试官可能在问你懒汉式的同时,让你介绍一下synchronized关键字的相关知识点。 一旦提到synchronized这个关键字,那就不是一篇文章能够讲清楚的,这里只提一下,他可能涉及到的知识:

  1. 在多线程中,通过锁不同的对象,来保证线程的执行顺序。
  2. 锁的目标,可以是对象,方法,以及class类。
  3. 在字节码中,通过ACC_SYNCHRONIZED,以及monitorenter和 monitorexit来实现。
  4. 锁的四种状态,无锁,偏向锁,轻量级锁,重量级锁
  5. 如何实现上述这四种锁(这四种锁究竟是如何实现的)
  6. 锁的升级过程(markword中如何记录偏向锁,轻量级锁,重量级锁)

这里着重介绍下字节码中如何实现,以及锁的升级过程(文章最后的图片)。

这个是上面懒汉式的字节码,可以看到synchronized修饰方法时,在字节码中变成了ACC_SYNCHRONIZED标记。后面还会看到synchronized修饰对象时,字节码中变成monitorentermonitorexit字节码指令。

  public static synchronized single.LazySingle getInstance();
    descriptor: ()Lsingle/LazySingle;
    flags: (0x0029) ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED
    Code:
      stack=2, locals=0, args_size=0
         0: getstatic     #33                 // Field sLazy:Lsingle/LazySingle;
         3: ifnonnull     16
         6: new           #34                 // class single/LazySingle
         9: dup
        10: invokespecial #39                 // Method "<init>":()V
        13: putstatic     #33                 // Field sLazy:Lsingle/LazySingle;
        16: getstatic     #33                 // Field sLazy:Lsingle/LazySingle;
        19: areturn
      LineNumberTable:
        line 21: 0
        line 22: 6
        line 25: 16
      StackMapTable: number_of_entries = 1
        frame_type = 16 /* same */
}
SourceFile: "LazySingle.java"

懒汉式涉及的知识点:

  1. 懒汉式如何保证线程安全
  2. synchronized相关知识

不推荐的原因:

每次获取对象时,都要获取对象锁。浪费性能。


3. 双重检查式单例

如果在面试过程中,被面试官问到双重检查单例。那么volatile一定会成为一个考点。 我们先看一下下面这段代码

/**
 * @author jtl
 * @date 2021/7/20 11:46
 * 双重检查模式单例
 * 优点:线程安全
 * 缺点:反射可以破坏单例
 * 注意:需加volatile,因为 new操作本身不是线程安全的。重排序会出现问题
 */

public class DCLSingle {
    private static volatile DCLSingle sDCLSingle;
    private int price = 8000;
    private DCLSingle() {
        System.out.println("双重检查模式:" + Thread.currentThread().getName());
    }

    public static DCLSingle getInstance() {
        if (sDCLSingle == null){
            synchronized (DCLSingle.class){
                if (sDCLSingle ==null){
                    sDCLSingle = new DCLSingle();
                }
            }
        }

        return sDCLSingle;
    }
}

面试的切入点

DCL(Double Check Lock),这个模式其实是推荐的一种模式。它既保证了线程安全性,又保证了延时加载(创建对象)。但是这里有一个关键字volatile,当面试官问你DCL的时候,就意味着他可能要问你下面几个问题:

  1. 这里的volatile可以去掉嘛?
  2. 此处volatile起到了什么作用?
  3. volatile还有其他的功能吗?

在讲volatile前,想问大家一个问题,当我们执行new关键字,创建一个对象的时候。在JVM中或者说在字节码层面究竟是什么样的? 如果你跟我说,我这天天都在写功能,谁会在意字节码什么样啊。那么也没问题,你没看过,那我给你准备好了。下面这段就是上面那段代码的字节码。让我们一起看一下。

下面是上面DCL单例中getInstanch方法的字节码。让我们看下当执行new对象操作的时候。字节码中到底都有哪些指令。

{
  public static single.DCLSingle getInstance();
    descriptor: ()Lsingle/DCLSingle;
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=2, args_size=0
         0: getstatic     #41                 // Field sDCLSingle:Lsingle/DCLSingle;
         3: ifnonnull     37
         6: ldc           #42                 // class single/DCLSingle
         8: dup
         9: astore_0
        10: monitorenter                      // monitorenter指令获取锁
        11: getstatic     #41                 // 将sDCLSingle压入操作数栈
        14: ifnonnull     27
        17: new           #42                 // ① 申请内存创建对象
        20: dup
        21: invokespecial #47                 // ② 执行构造方法 Method "<init>":()V
        24: putstatic     #41                 // 将sDCLSingle压出操作数栈
        27: aload_0                           //  赋值给sDCLSingle
        28: monitorexit                       // monitorexit指令释放锁
        29: goto          37
        32: astore_1
        33: aload_0
        34: monitorexit
        35: aload_1
        36: athrow
        37: getstatic     #41                 // Field sDCLSingle:Lsingle/DCLSingle;
        40: areturn
      Exception table:
         from    to  target type
            11    29    32   any
            32    35    32   any
}

指令重排序:

这里要先普及一个知识,什么是指令重排序
指令重排序:编译器在不改变单线程程序的执行结果的前提下,可以将指令进行重新排序,以提高执行效率。

正常执行顺序

我们从上面的字节码中看到三个操作,①②③,正常情况下,字节码中我们代码的执行顺序是:

  1. ①申请内存创建对象,此时该示例中的price只赋了默认值0
  2. ②执行构造方法,此时a会赋值成代码中的8。
  3. ③将sDCLSingle实例对象指向①中创建的内存,这就意味着此时的sDCLSingle对象不为null

上面的执行顺序也是正常的默认的执行顺序。

重排序后的执行顺序

但是有正常的执行顺序,就意味着一定会有不正常的执行顺序。

如果sDCLSingle中不使用volatile修饰的情况下,编译器就可能为了优化,从而进行指令重排序。顺序就可能从①②③,变成①③②

极端情况下出现的事故

假设之前代码没使用volatile,恰巧出现了指令重排序,倒置编译后代码的指令顺序变成了①③②。此时出现了两个线程A和B同时执行getInstance操作。

  1. 第一个A线程在执行new对象时,正好执行到了①③操作。这时由于③操作给sDCLSingle赋了值,导致sDCLSingle对象不为null,此时由于没有执行②,sDCLSingle对象中的price=0。
  2. 恰巧这时的线程B也执行了getInstance方法。由于sDCLSingle不为null,所以线程B直接获取了,尚未执行初始化操作的sDCLSingle对象。
  3. 本来price为8000,但是由于该对象还没有执行操作②,没有设置初始值,导致线程B中的price=0。 如果这是一个付款操作,那么本来8000块的IPad Pro,变成了0元购,这可比拼多多砍的一刀狠多了,妥妥的事故线程啊。

为了避免这种情况,我们的volatile就出场了,volatile的两大特性:

  1. 禁止指令重排序
  2. 保证内存的可见性

禁止指令重排序,这点可以理解为,为了保证上述代码在编译时顺序永远是①②③,而不会变成①③②。禁止编译器进行指令重排序,以避免上述的情况。

volatile的可见性,这里不做过多描述,感兴趣的同学可以查看下小虎牙童鞋的这篇volatile的文章或者上B站看下马士兵老师的多线程的免费课程。讲的比较详细。

DCL涉及的知识点:

  1. volatile的相关知识
  2. 什么是指令重排序

volatile知识的图解

image.png

4. 静态内部类

静态内部类这种单例,它之所以是线程安全的。原因就是JVM加载类的时候是线程安全的,我们在调用getInstance方法时,会加载Inner内部类,由于JVM保证了同一时间只能有一个线程加载相同的类,所以静态内部类是线程安全的。

当我们调用HolderSingle.test1()方法时,即使执行类加载,由于不会创建HolderSingle对象,因此也不存在饿汉式单例的缺点。

/**
 * @author jtl
 * @date 2021/7/20 11:49
 * 静态内部类单例
 * 优点:线程安全,因为类加载时是线程安全的
 * 缺点:反射可以破坏单例
 */

public class HolderSingle {
    private HolderSingle(){
        System.out.println("静态内部类单例:"+Thread.currentThread().getName());
    }
    
    public static HolderSingle test1() {
        System.out.println( "---测试代码---");
    }

    public static HolderSingle getInstance() {
        return Inner.sHolder;
    }

    private static class Inner{
        private static final HolderSingle sHolder = new HolderSingle();
    }
}

但是静态内部类,也有一个缺点,那就是可以通过反射来创建它的对象实例。一起看下面的代码。

/**
 * @author jtl
 * @date 2021/7/20 14:29
 * 单例模式测试Test
 */

class Client {
    public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
        // 静态内部类,通过反射获取实例
        // 获取HolderSingle类的构造器
        Constructor<HolderSingle> holderConstructor = HolderSingle.class.getDeclaredConstructor();
        // 获取权限,可以执行private方法
        holderConstructor.setAccessible(true);
        // 执行构造器,创建对象
        HolderSingle holder = holderConstructor.newInstance(null);
        System.out.println("单例对象:" + holder+"---hashCode:"+holder.hashCode());
    }
}

image.png 通过运行上述代码,可以输出图片中的语句。因此可以证明,我们可以通过反射获取静态内部类单例的对象实例。这与我们单例的概念不符合。因此这也算是他的一个缺点。
不过话说回来,这只是较真的一种行为,毕竟都已经使用单例了,那我们肯定不会通过反射来获取实例。

静态单例的考点:

  1. 我们可以通过反射,获取静态内部类单例对象实例。即反射可以执行私有方法
  2. JVM本身会保证,类加载时的线程安全性

5. 枚举单例

枚举单例,相对于上面几种,可能是知道的比较少的一种单例写法了。相比于前面几种方式,它的优点是,完美解决了反射获取对象实例的这一行为。

/**
 * @author jtl
 * @date 2021/7/20 11:57
 * 枚举单例模式,可防反射
 */

enum EnumSingle {
    INSTANCE;
}

如果我们运行下面这段代码,想通过反射来获取枚举的对象实例,会出现图片中的这种情况。

/**
 * @author jtl
 * @date 2021/7/20 14:29
 * 单例模式测试Test
 */

class Client {
    public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
        // 测试枚举类单例,无法通过反射获取, Cannot reflectively create enum objects
        Constructor<EnumSingle> enumSingleConstructor = EnumSingle.class.getDeclaredConstructor(String.class, int.class);//枚举的构造函数是有参的
        enumSingleConstructor.setAccessible(true);
        EnumSingle enumSingle = enumSingleConstructor.newInstance(null);
    }
}

image.png

进击的面试官

这时候,聪明的面试官可能就要开启追问模式了:

  1. 你能不能跟我说说,为什么枚举无法通过反射获取实例呢?
  2. 枚举的本质到底是什么呢
  3. 枚举的缺点是什么,为什么性能优化时,会建议使用注解来代替枚举

要回答第一个问题,看完下面这段,反射的相关代码你就该知道答案了

public final class Constructor<T> extends Executable {
    private ConstructorAccessor acquireConstructorAccessor() {
        Constructor<?> root = this.root;
        ConstructorAccessor tmp = root == null ? null : root.getConstructorAccessor();
        if (tmp != null) {
            constructorAccessor = tmp;
        } else {
            // 类型为枚举时,直接抛异常
            if ((clazz.getModifiers() & Modifier.ENUM) != 0)
                throw new IllegalArgumentException("Cannot reflectively create enum objects");

            tmp = reflectionFactory.newConstructorAccessor(this);

            if (VM.isJavaLangInvokeInited())
                setConstructorAccessor(tmp);
        }

        return tmp;
    }
}

看完上面的代码,你就知道为啥Enum不能反射了吧,不是它不想,而是Java它根本不允许啊。

Enum的真实面目

紧接着一起来探讨下,Enum的真实面目: 让我们看下,上述代码的字节码,你会惊奇的发现,好好的一个枚举,在编译之后变成了一个继承Enum的一个class类。

openjdk version "19.0.1" 2022-10-18
OpenJDK Runtime Environment (build 19.0.1+10-21)
OpenJDK 64-Bit Server VM (build 19.0.1+10-21, mixed mode, sharing)
haohao@192 single % javap EnumSingle.class
Compiled from "EnumSingle.java"
final class single.EnumSingle extends java.lang.Enum<single.EnumSingle> {
  public static final single.EnumSingle INSTANCE;
  public static single.EnumSingle[] values();
  public static single.EnumSingle valueOf(java.lang.String);
  static {};
}

让我们再来看一下Enum这个抽象类,究竟是何方神圣。这就是为什么,我们在写枚举的时候可以直接调用name等方法的原因。

public abstract class Enum<E extends Enum<E>>
        implements Constable, Comparable<E>, Serializable {
    private final String name;
    private final int ordinal;
    
    protected Enum(String name, int ordinal) {
        this.name = name;
        this.ordinal = ordinal;
    }
    
    public final String name() {
        return name;
    }
    
    public final int ordinal() {
        return ordinal;
    }
    
    public String toString() {
        return name;
    }
    
    public final boolean equals(Object other) {
        return this==other;
    }
}

枚举中的小心思: 让我们将目光转移回编译出的Enum代码。细心的小伙伴,可能已经发现了。这里的INSTANCE是一个static final修饰的对象啊。这是不是就意味着,每有一个枚举就代表了在编译之后会出现一个对象。这当然比注解占内存了。

这就是为什么Google推荐使用注解来取代枚举的原因。
因为,每一个枚举编译之后都会生成一个实例对象
而反观注解,虽然它编译之后也是一个继承Annotation类的interface,但是它内部的变量,属于什么基本类型,他在内存中就占多少内存,而不是再创建一个对象。(byte,int等基本数据类型在内存中占多少内存,你还记得嘛)。

枚举编译:

枚举变成了一个classimage.png

注解编译后

注解变成了一个interfaceimage.png

回顾下枚举单例知识点

如果面试官提到枚举单例的话,那么他可能想跟你聊的不是枚举单例,而是枚举的实质,大概就是下面这几个问题:

  1. 枚举单例相比于静态类单例的优点是什么嘞
  2. 枚举的实质是什么
  3. 为什么性能优化里,会出现注解替代枚举的说法,其原因是什么。

单例总结

简简单单的五种单例写法里,暗藏了多少杀机,看完这篇文章之后,我想面试官应该再也不想问你单例问题了。当然也有例外,如果他非要让你讲一下,synchronized在硬件方面是如何实现的。听我一句劝,快跑,这个面试官可能是派大星,因为他大概率不是个正常人>.<

话说回来,再看下,单例都涉及哪些知识点:

  1. JVM加载类的机制
  2. volatile 原理
  3. synchronized 相关知识
  4. 静态内部类是如何保证线程安全的
  5. 反射机制,枚举的真实面目,以及枚举消耗内存的原因

面试中单例的问题

现在你能回答下图中的问题了吗,如果全能回答上来的话,那么恭喜你。如果还有一些疑问的话,你可能需要再看一遍 >.< 面试中单例的问题.png

2022年最后想说的

今天是2022年12月31日,即将过去的这一年里,我们经历了太多,俄乌战争经济寒冬,互联网大批裁员,疫情解封全员小洋人。在这种情况下,我们只能不断的学习,来充实自己。希望在新的一年里,大家都能找到更好的工作,生活的更加开心。

设计模式连接

23种设计模式的相关代码,目前还差几种有时间会补全。Github23种设计模式的demo

锁升级的过程

末尾引用网上的一张锁膨胀过程的图片,感兴趣的可以看一下。

Java锁的膨胀过程.png