面试官:我们来简单聊聊的单例?那我可有话说了
Posted 初一十五啊
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了面试官:我们来简单聊聊的单例?那我可有话说了相关的知识,希望对你有一定的参考价值。
文章的初衷
问个问题,饿汉式单例的缺点是调用时可能会造成内存消耗。那么能讲下,它到底是如何消耗内存的吗?里面的原理是什么呢?
如果你对这个问题存疑,那么我推荐你看这篇文章,看完之后你会发现,单例可能真的不像你想的那么简单!
本文目的是对知识的一个总结,俗话说的好,好记性不如烂笔头。经常总结,也能帮助总结知识点增强记忆。另一个目的是希望能够科普单例所涉及的知识点。
希望读了本文之后,再遇到面试官问你单例的相关知识点时,你能够胸有成竹,让他对你刮目相看。
几个小问题
- 单例模式有几种写法
- 饿汉式如何保证线程安全
- 类加载的过程都有什么,能介绍下每个阶段都做了什么嘛
- volatile都有什么作用,什么是指令重排序
- 静态内部类单例是如何做到线程安全的。它的缺点是什么
- 为什么说枚举占内存,为什么枚举不能被反射
看到这里,你可能会说,这几个问题有的和单例也没关系啊!的确,这里有些问题和单例是没关系,但是有没有一种可能,面试官压根就不是想单纯的问你单例的写法,这些单例引申出的知识点,才是他真正的目的。
单例的写法
首先是上面的第一个问题,单例的写法。 关于单例的写法,这里不再多做介绍了,网上太多的文章了。这里直接说答案,一共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;
饿汉式缺点:
上面的例子中,有点极端,但是确很好的体现了饿汉式的缺点。
- 上面的例子中有一个aaa数组,它会在test()方法中被使用。
- 当我调用HungrySingle.info();这个静态方法时。会执行HungrySingle的类加载
- 执行类加载时,会执行private static final HungrySingle S_HUNGRY_SINGLE = new HungrySingle();会创建对象。
- 由于创建对象,会创建aaa数组,即使我现在没有调用test,不需要使用这个数组
- 最终饿汉式单例,就会因为调用了一个static方法而创建对象,从而申请不必要的内存,导致浪费性能。
面试官可能的问题:
由于类加载时就会创建对象,会造成内存浪费。
比如例子中,只要创建HungrySingle的对象,会平白无故的创建50m内存的数组。那么什么时候会创建对象呢?由于S_HUNGRY_SINGLE是static 修饰的,所以一执行类加载就会创建对象。
这里就可能就包含面试官想考你的知识点了:
- 你能描述下类加载的过程嘛?
- 什么时候会执行类加载?
什么是类加载
首先饿汉式涉及到的第一个知识点,就是类加载。
类加载是什么呢,当我们使用一个类的时候,首先要做的是把这个类,也就是我们java文件编译出的.class文件,加载到虚拟机之中。
类加载分为5个基本步骤:
- 加载:将class文件以二进制字节流的方式加载到内存中
- 验证:验证字节码的安全性,以防有人篡改字节码
- 准备:静态变量赋默认初始值(int类型初始值为0,引用类型为null等),static final修饰的常量在这一步直接赋值(static final修饰的基本数据类型会将结果编译到字节码中)
- 解析:将符号引用转换为直接引用
- 初始化:执行static代码块,初始化static变量,该步骤即为clinit。
经过类加载之后,该类的相关数据会被保存在,方法区中存放类型信息的位置。另外,类的生命周期还包括使用和卸载,此处讲的是类的加载过程,所以没有把这两个生命周期写入。
什么时候会执行类加载呢?
当JVM执行HungrySingle这个类的相关代码的时候,第一件事情就是去查看方法区中是否存在该类的信息。如果存在,证明已经加载过HungrySingle,如果不存在,那么就执行HungrySingle的类加载。
以上面代码为例:一旦加载该类,由于static final HungrySingle S_HUNGRY_SINGLE = new HungrySingle(); 的缘故就会在类加载的初始化阶段创建对象。又因为创建对象的时候会创建数组byte[] aaa,这样就造成了性能浪费。
涉及的知识点:
- 什么时候会执行类加载
- 类加载的过程
- static修饰的变量什么时候进行赋值初始化
不推荐的原因:
类加载时会创建该类对象,可能会无意中创建一些占用内存的对象或者数组,造成性能浪费。
懒汉式
懒汉式单例,可以避免上面饿汉式中,调用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这个关键字,那就不是一篇文章能够讲清楚的,这里只提一下,他可能涉及到的知识:
- 在多线程中,通过锁不同的对象,来保证线程的执行顺序。
- 锁的目标,可以是对象,方法,以及class类。
- 在字节码中,通过ACC_SYNCHRONIZED,以及monitorenter和 monitorexit来实现。
- 锁的四种状态,无锁,偏向锁,轻量级锁,重量级锁
- 如何实现上述这四种锁(这四种锁究竟是如何实现的)
- 锁的升级过程(markword中如何记录偏向锁,轻量级锁,重量级锁)
这里着重介绍下字节码中如何实现,以及锁的升级过程(文章最后的图片)。
这个是上面懒汉式的字节码,可以看到synchronized修饰方法时,在字节码中变成了ACC_SYNCHRONIZED标记。后面还会看到synchronized修饰对象时,字节码中变成monitorenter和monitorexit字节码指令。
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"
懒汉式涉及的知识点:
- 懒汉式如何保证线程安全
- synchronized相关知识
不推荐的原因:
每次获取对象时,都要获取对象锁。浪费性能。
双重检查
如果在面试过程中,被面试官问到双重检查单例。那么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的时候,就意味着他可能要问你下面几个问题:
- 对volatile熟悉嘛?
- 这里的volatile起到了什么作用?
- 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
指令重排序:
这里要先普及一个知识,什么是指令重排序:
指令重排序:编译器在不改变单线程程序的执行结果的前提下,可以将指令进行重新排序,以提高执行效率。
正常执行顺序
我们从字节码中看到三个操作,①②③,正常情况下,字节码中我们代码的执行顺序是:
- ①申请内存创建对象,此时该示例中的price只赋了默认值0
- ②执行构造方法,此时a会赋值成代码中的8。
- ③将sDCLSingle实例对象指向①中创建的内存,这就意味着此时的sDCLSingle对象不为null。
上面的执行顺序也是正常的默认的执行顺序。
重排序后的执行顺序
但是有正常的执行顺序,就意味着一定会有不正常的执行顺序。
如果sDCLSingle中不使用volatile修饰的情况下,编译器就可能为了优化,从而进行指令重排序。顺序就可能从①②③,变成①③②。
假设出现极端的状况,指令变成了①③②。同时出现了两个线程A和B同时执行getInstance操作。 第一个A线程在执行new对象时,由于指令重排序,正好执行到了①③操作。这时由于③操作给sDCLSingle赋了值,导致sDCLSingle对象不为null,但是由于没有执行②,所以sDCLSingle对象中的price=0。恰巧这时的线程B执行了getInstance方法。由于sDCLSingle不为null,所以线程B直接获取了,尚未执行初始化操作的sDCLSingle对象。本来price为8000,但是由于该对象还没有执行操作②没有设置初始值,线程B中的price为0。如果这是一个付款操作,那就变成了本来8000块的商品,变成了0元购,这属于妥妥的事故现场啊。
为了避免这种情况,我们的volatile就出场了,volatile的两大特性:
- 禁止指令重排序
- 保证内存的可见性
禁止指令重排序,这点可以理解为,为了保证上述代码在编译时顺序永远是①②③,而不会变成①③②。禁止编译器进行指令重排序,以避免上述的情况。
DCL涉及的知识点:
- volatile的相关知识
- 什么是指令重排序
volatile知识的图解
静态内部类
静态内部类这种单例,它之所以是线程安全的。原因就是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());
通过运行上述代码,可以输出图片中的语句。因此可以证明,我们可以通过反射获取静态内部类单例的对象实例。这与我们单例的概念不符合。因此这也算是他的一个缺点。不过话说回来,这只是较真的一种行为,毕竟都已经使用单例了,那我们肯定不会通过反射来获取实例。
静态单例的考点:
- 我们可以通过反射,获取静态内部类单例的对象实例。即反射可以执行私有方法。
- JVM本身会保证,类加载时的线程安全性。
枚举单例
枚举单例,相对于上面几种,可能是知道的比较少的一种单例写法了。相比于前面几种方式,它的优点是,完美解决了反射获取对象实例的这一行为。
/**
* @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);
进击的面试官
这时候,聪明的面试官可能就要开启追问模式了:
- 你能不能跟我说说,为什么枚举无法通过反射获取实例呢?
- 枚举的本质到底是什么呢
- 枚举的缺点是什么,为什么性能优化时,会建议使用注解来代替枚举
要回答第一个问题,看完下面这段,反射的相关代码你就该知道答案了
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的一个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推荐使用注解来取代枚举的原因。因为,每一个枚举编译之后都会生成一个实例对象。而反观注解,它的基本类型是什么,他在内存中就占多少内存。
回顾下枚举单例知识点
如果面试官提到枚举单例的话,那么他可能想跟你聊的不是枚举单例,而是枚举的实质,大概就是下面这几个问题:
- 枚举单例相比于静态类单例的优点是什么嘞
- 枚举的实质是什么
- 为什么性能优化里,会出现注解替代枚举的说法,其原因是什么。
单例总结
简简单单的五种单例写法里,暗藏了多少杀机,看完这篇文章之后,我想面试官应该再也不想问你单例问题了。当然也有例外,如果他非要让你讲一下,synchronized在硬件方面是如何实现的。听我一句劝,快跑,这个面试官可能是派大星,因为他大概率不是个正常人>.<
话说回来,再看下,单例都涉及哪些知识点:
- JVM加载类的机制
- volatile 原理
- synchronized 相关知识
- 静态内部类是如何保证线程安全的
- 反射机制,枚举的真实面目,以及枚举消耗内存的原因
面试中单例的问题
现在你能回答下图中的问题了吗,如果全能回答上来的话,那么恭喜你。如果还有一些疑问的话,你可能需要再看一遍 >.<
2022年最后想说的
今天是2022年12月31日,即将过去的这一年里,我们经历了太多,俄乌战争经济寒冬,互联网大批裁员,疫情解封全员小洋人。在这种情况下,我们只能不断的学习,来充实自己。希望在新的一年里,大家都能找到更好的工作,生活的更加开心。
锁升级的过程
末尾引用网上的一张锁膨胀过程的图片,感兴趣的可以看一下。
android 知识点归整
Android 性能调优系列:https://0a.fit/dNHYY
Android 车载学习指南:https://0a.fit/jdVoy
Android Framework核心知识点笔记:https://0a.fit/acnLL
Android 音视频学习笔记:https://0a.fit/BzPVh
Jetpack全家桶(含Compose):https://0a.fit/GQJSl
Kotlin 入门到精进:https://0a.fit/kdfWR
Flutter 基础到进阶实战:https://0a.fit/xvcHV
Android 八大知识体系:https://0a.fit/mieWJ
Android 中高级面试题锦:https://0a.fit/YXwVq
以上是关于面试官:我们来简单聊聊的单例?那我可有话说了的主要内容,如果未能解决你的问题,请参考以下文章