单例形式
文章的初衷
问个问题,饿汉式单例的缺陷是调用时或许会形成内存耗费。那么能讲下,它究竟是怎么耗费内存*…的呢?里边的原理**是什么呢?
假如你对这个问题存疑,那么我引荐你看这篇文章,并且或许单例真的不像你想的那么简略!
本文意图是对常识的一个总结,俗语说的好,好记性不如烂笔头。常常总结,也能帮助总结常识点增强回忆。另一个意图是期望能够科普单例所触及的常识点。
期望读了本文之后,再遇到面试官问你单例的相关常识点时,你能够胸中有数,让他对你刮目相看。
几个小问题
- 单例形式有几种写法
- 饿汉式怎么确保线程安全
- 类加载的进程都有什么,能介绍下每个阶段都做了什么嘛
- 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的两大特性:
- 禁止指令重排序
- 确保内存的可见性
禁止指令重排序,这点能够了解为,为了确保上述代码在编译时次序永远是①②③,而不会变成①③②。禁止编译器进行指令重排序,以防止上述的状况。
volatile的可见性,这儿不做过多描绘,感兴趣的同学能够查看下小虎牙童鞋的这篇volatile的文章或者上B站看下马士兵教师的多线程的免费课程。讲的比较具体。
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日,行将过去的这一年里,咱们阅历了太多,俄乌战役经济隆冬,互联网大批裁员,疫情解封全员小洋人。在这种状况下,咱们只能不断的学习,来充实自己。期望在新的一年里,大家都能找到更好的作业,生活的愈加开心。
规划形式连接
23种规划形式的相关代码,现在还差几种有时间会补全。Github23种规划形式的demo
锁晋级的进程
末尾引证网上的一张锁膨胀进程的图片,感兴趣的能够看一下。