为什么是叫 HotSpot JVM?
1 Java与编译相关的三个概念
-
前端编译
-
解释执行
-
编译执行
1.1、前端编译
编译器(javac)将源文件(.java)编译成java字节码文件(.class)的步骤是前端编译。
1.2、解释执行
在JVM加载字节码后,每次执行方法调用时,JVM都会将字节码翻译成机器码,然后执行机器码,这个过程叫解释执行。
解释执行为了提升启动效率,并没有在启动时将字节码全部翻译成机器码,所以启动效率较高。但是,由于字节码不能执行,要机器码才能执行,所以,执行时要进行字节码翻译,所以执行效率相对较低。
1.3、编译执行
什么是编译执行?
与解释执行相反,JVM加载字节码的时候,直接将字节码转换为机器码,在执行方法调用时直接执行机器码,不需要做翻译工作,这样的过程叫编译执行。
编译执行的问题是什么呢?
和解释执行相反,编译执行在启动时将字节码全部翻译成机器码,所以启动效率较低。但是,编译执行时省去了翻译的步骤,所以执行效率相对较高。
2 架构上 JVM 如何实现启动速度、执行速度的双优?
解释执行的特点是:启动效率高、执行效率低。
编译执行的特点是:启动效率低、执行效率高。
JVM如何实现双高呢?实现,启动效率高、执行效率也高。
2.1、如何平衡启动速度和执行的速度
为了平衡启动和执行的效率,JVM结合解释执行和编译执行的特点,进行综合和平衡,形成了一种折中的性能优化策略。
JVM以解释执行,编译执行为辅,达到启动速度和执行速度的最优化。
那些代码需要编译执行呢?热点代码。
JVM 并不对全部代码进行编译执行,仅仅对热点代码进行编译优化,这样的执行过程叫即时编译。
2.2、什么是“热点代码”(Hot Spot Code)?
当JVM发现某个方法或代码块运行特别频繁的时候,就会认为这是“热点代码”(Hot Spot Code)。
然后JIT会把部分“热点代码”翻译成本地机器相关的机器码,并进行优化,然后再把翻译后的机器码缓存起来,以备下次使用。
把翻译后的机器码缓存在哪里呢?这个机器码缓存,叫做 Code Cache。
当JVM下次遇到相同的热点代码时,跳过解释的中间环节,直接从 Code Cache加载机器码,直接执行,无需再编译。
所以,JIT总的目标是发现热点代码,热点代码变成了提升性能的关键,Java官方给自家开源的JVM取名字为hotspot JVM,也就是这么来的。
Java官方把识别“热点代码”(Hot Spot Code)这个任务,写在名字上,作为毕生的追求。
所以,JVM总的策略为:
-
对于占据大部分的不常用的代码,我们无需耗费时间将其编译成机器码,而是采取解释执行的方式运行;
-
另一方面,对于仅占据小部分的热点代码,我们则可以将其编译成机器码,并且缓存起来,后面省略编译的过程,直接从缓存当中取得机器码,从而以达到理想的运行速度。
2.3 JVM中的缓存架构
“热点代码”(Hot Spot Code)编译后,放入到 Code Cache中,当JVM下次遇到相同的热点代码时,直接从 Code Cache加载机器码,跳过中间的编译环节,无需再编译。
从架构的角度来说,缓存架构。可见,JVM和WEB应用实现高并发的手段,是非常类似的。JVM为了实现高性能和高并发,也会使用缓存架构。
CPU内部,为了提升性能,也用了缓存架构,并且是多级缓存架构。
同时,在缓存架构中, 热点数据非常重要。
在WEB应用的缓存架构中,识别热点数据(HotKey),是提升三级缓存命中率的核心环节。我们会通过有效的识别组件,识别其中的HotKey。
这种HotKey的理论和思想,在JVM中的缓存架构也是想通的。
JVM中的缓存架构中,不能什么都缓存,需要缓存的同样是他的 HotKey, 这里叫做 Hot Spot Code,仅仅换了一个名字而已。
三高架构的思想,缓存架构 都是相通的:
-
高并发WEB三级缓存架构里边,有hotkey和本地高速缓存
-
JVM架构里边有Hot Spot和 Code Cache
3 即时编译
3.1、即时编译器
JVM包含多个即时编译器,主要有C1和C2,还有个Graal (实验性的)。
多个即时编译器, 都会对字节码进行优化并生成机器码。
但是不同的即时编译器,优化的程度不同:C1会对字节码进行简单可靠的优化,C2会对字节码进行激进优化。
-
C1会对字节码进行简单可靠的优化,包括方法内联、去虚拟化、冗余消除等,编译速度较快,可以通过-client强制指定C1编译
-
C2会对字节码进行激进优化,包括分支频率预测、同步擦除等,可以通过-server强制指定C2编译
如果没有强制指定,JVM默认会使用分层编译模式。
3.2、分层编译模式
JVM不会直接启用C2,而是先通过C1编译收集程序的运行状态,再根据分析结果判断是否启用C2。
分层编译模式下, 虚拟机执行状态由简到繁、由快到慢分为5层
-
0 层,解释执行(Interpreter)
-
1 层,使用 C1 即时编译器编译执行,无profiling
-
2 层,使用 C1 即时编译器编译执行,带基本的 profiling(仅方法调用次数及循环回边执行次数的profiling)
-
3 层,使用 C1 即时编译器编译执行,带完全的 profiling
-
4 层,使用 C2 即时编译器编译执行
什么是profiling ?
profiling是C0/C1在编译过程中收集程序执行状态的过程。收集的执行状态记录为profile (概述/ 印象),包括分支跳转频率、是否出现过空值和异常等,主要用于触发C2编译。
如何实现呢?
profiling 是指在程序执行过程中,JVM织入的一些协助收集数据的辅助代码,这些织入的辅助代码,收集能够反映程序执行状态的数据。这里所收集的数据我们称之为程序的 profile。
profiling 在思想上,非常类似于 Java Agent 的字节码增强。只是 Java Agent 的字节码增强发生在 字节码 层面,profiling 的指令织入,发生在 机器码的层面。
什么是完全的 profiling?
循环回边(Loop backedge)是控制流图中的一个概念,用于描述循环结构中的特定类型边。在控制流图中,节点代表基本块(即一组连续的指令,没有控制流语句,只有入口和出口),而边代表控制流。循环回边是一种特殊的边,它从循环的一部分指向循环的开始或循环中的一个节点。
具体来说,循环回边通常指的是以下两种情况:
循环体内的直接回边:这是最常见的循环回边,它直接从循环的尾部回到循环的头部。例如,在
for
或while
循环中,当循环条件被重新评估并决定继续执行循环时,就存在一个直接回边。循环体内的间接回边:这种回边发生在循环内部的多个节点之间,不一定直接从尾部到头部。例如,在嵌套循环或具有多个入口和出口的复杂循环结构中,可能存在多个回边。
在程序分析中,识别循环回边对于理解循环的行为和结构非常重要。它可以帮助分析循环的复杂度,如循环的迭代次数,以及进行各种优化,如循环展开或循环不变代码移动。在基本的profiling中,计算循环回边的执行次数可以帮助评估循环的性能影响,特别是在优化循环密集型代码时。
除了 基本的 profiling(仅方法调用次数及循环回边执行次数的profiling)外, 还包括分支 profile(针对分支跳转字节码,包括跳转次数和不跳转次数)以及receiver type(针对成员方法调用或类检测,如checkcast,instanceof,aastore字节码)的类型profile 分层编译5层执行状态之间的关系 ,具体如下图:
第一条执行路径,指的是通常情况下,一个方法先被解释执行(level 0),然后被C1编译(level 3),再然后被得到profile数据的C2编译(level 4)
第二条执行路径,指的是编译对象非常简单的情况下,如getter和setter,虚拟机认为通过C1编译或通过C2编译并无区别,就会在3层编译后,直接由C1编译且不插入profiling代码(level 1)。
第三条执行路径,指的是C1繁忙时,JVM会在解释执行时收集profiling,而后直接由 4 层的 C2 编译。
第四条执行路径,指的是C2繁忙时,先由2层的C1编译再由3层的C1编译,这样可以减少方法在3层的执行时间,最终再交给C2执行。
注意:这个收集的动作,叫做profiling,这个收集的结果,叫做profile。
分层编译中的 0 层、2 层和 3 层都会进行 profiling,织入的一些协助收集数据的辅助代码, 收集能够反映程序执行状态的数据。
其中,最为基础的便是 2层进行的 profiling,它只需要统计方法的调用次数以及循环回边的执行次数,当统计之和超过阈值就会触发即时编译。
所以,方法的调用次数以及循环回边的执行次数 达到阈值,这部分的代码,就会被识别成为—— 热点代码。
0 层和 3 层相较于 2层复杂一些,需要收集用于 4 层 C2 编译的数据。
比如说分支跳转字节码的分支 profile(branch profile),包括跳转次数和不跳转次数,
比如 receiver 类型 profile(receiver type profile):非私有实例方法调用指令、强制类型转换 checkcast 指令、类型测试 instanceof 指令,和引用类型的数组存储 aastore 指令。
上述数据分为两大类:分支 profile 和类型 profile。
根据图片中的编译途径可知,分层编译下,无论何种情况,大概率都要进行分支 profile 和类型 profile 的收集。
但是,需要注意的是,有利必有弊:分支 profile 和类型 profile 的收集,将给应用程序带来不少的性能开销。
据统计,正是因为这部分额外的 profiling,导致的一个结果是:
3 层 C1 代码(带完全的profile)的性能比 2 层 C1 代码(带基本的 profiling)低 30%。
那么这些耗费巨大代价收集而来的 profile 具体有什么作用呢?
答案是:C2 可以根据收集得到的数据进行猜测,从而作出比较激进的优化。
当然:4层的C2代码有一个前提,就是 假设接下来的执行,同样会C1 代码按照所收集的 profile 进行。
3.3、触发即时编译的时机
当方法调用次数profile或循环次数profile达到阈值时,会触发即时编译 阈值可以通过VM选项设置
-XX:TierXInvocationThreshold
-XX:TierXMINInvocationThreshold
-XX:TierXCompileThreshold
除了和上面的这些选项值有关,即时编译的触发,还跟待编译方法的数目和编译线程的总数有关。编译线程的数量是处理器动态指定的,参数为
-XX:+CICompilerCountPerCPU
这个参数,默认开启。也可以通过VM选项强制指定编译线程总数:
-XX:+CICompilerCount=N
JVM会将这些线程以1:2的比例分配给C1和C2
3.4、去优化
去优化是当C2编译的机器码假设失败时,将即时编译切换回解释执行的过程。
在生成的机器码中,即时编译器将在假设失败的位置上插入一个陷阱(trap)。
该陷阱实际上是一条 call 指令,调用至 Java 虚拟机里专门负责去优化的方法。
与普通的 call 指令不一样的是,去优化方法将更改栈上的返回地址,不再返回即时编译器生成的机器码中。
去优化的过程相当复杂。由于即时编译器采用了许多优化方式,其生成的代码和原本的字节码的差异非常之大。
去优化的过程中,需要将当前机器码的执行状态,转换至某一字节码之前的执行状态,并从该字节码开始执行。
这便要求即时编译器在编译过程中,记录好这两种执行状态的映射。
当根据映射关系创建好对应的解释执行栈桢后,Java 虚拟机便会采用 OSR 技术,动态替换栈上的内容,并在目标字节码处开始解释执行。
此外,在调用 Java 虚拟机的去优化方法时,即时编译器生成的机器码可以根据产生去优化的原因,来决定是否保留这一份机器码,以及何时重新编译对应的 Java 方法。
-
如果去优化的原因与优化无关,即使重新编译也不会改变生成的机器码,那么生成的机器码可以在调用去优化方法时传入 Action_None,表示保留这一份机器码,在下一次调用该方法时重新进入这一份机器码。
-
如果去优化的原因与静态分析的结果有关,例如类层次分析,那么生成的机器码可以在调用去优化方法时传入 Action_Recompile,表示不保留这一份机器码,但是可以不经过重新 profile,直接重新编译。
-
如果去优化的原因与基于 profile 的激进优化有关,那么生成的机器码需要在调用去优化方法时传入 Action_Reinterpret,表示不保留这一份机器码,而且需要重新收集程序的 profile。
4 方法内联
在即时编译方法时,将目标方法的方法体取代方法调用的过程叫方法内联, 方法内联 增加了编译的代码量,但是降低了方法调用带来的入栈出栈的成本
4.1、静态方法内联
即时编译器会根据方法调用层数,目标方法的调用次数及字节码大小等决定该方法是否允许被内联
-
-XX:CompileCommand配置中的inline指令指定的方法会被强制内联,dontinline和exclude指定的方法始终不会被内联
-
@ForceInline注解的jdk内部方法会被强制内联,@DontInline注解jdk内部方法始终不会被内联
-
方法的符号引用未被解析、目标方法所在类未被初始化、目标方法是native方法,都会导致方法无法内联
-
C2默认不支持9层以上的方法调用(-XX:MaxInlineLevel),以及1层的直接递归调用(-XX:MaxRecursiveInlineLevel)
-
自动拆箱总会被内联,Throwable类的方法不能被其他类内联等
4.2、动态方法内联
在Java中,所有非静态和非最终的实例方法默认都是虚方法。
即时编译器需要将动态绑定的虚方法转化为直接调用,才能进行方法内联,这样的过程叫虚方法的去虚化
-
根据字节码生成的IR图确定调用者类型的过程叫基于类型推导的完全去虚化
-
根据JVM中已加载的类找到接口的唯一实现的过程叫基于类层次分析的完全去虚化
-
根据编译时收集的类型profile,依次匹配方法调用者的动态类型与profile中的类型
5 逃逸分析
当方法内部定义的对象被外部代码引用时,称为该对象逃逸,JVM对对象的分析过程叫逃逸分析
根据逃逸分析,即时编译器会在编译过程中对代码做如下优化:
-
锁消除:当一个锁对象只被一个线程加锁时,即时编译器会把锁去掉
-
栈上分配:当一个对象没有逃逸时,会将对象直接分配在栈上,随着线程回收,由于JVM的大量代码都是堆分配,所以目前JVM不支持栈上分配,而是采用标量替换
-
标量替换:当一个对象没有逃逸时,会将当前对象打散成若干局部变量,并分配在虚拟机栈的局部变量表中
6 即时编译的其他优化
字段读取优化:缓存多次读取的数据,减少出入栈次数
public String register(User user,String username,String password){
user.username = username;
return user.username + password;
}
class User{
private String username;
}
public String register(User user,String username){
String s = user.username; //user.username被缓存成了s
s = username;
return s + password;
}
字段存储优化:将被覆盖的赋值操作优化掉,减少无用的入栈
private void test(){
int a = 1;
a = 2;
}
private void test(){
int a = 2;//a=1被优化掉了
}
循环无关代码外提:避免重复执行表达式,减少出入栈次数
private void test(String s){
String password;
for (int i=0;i<10;i++){
password = s.replaceAll("/","");
System.out.println(i);
}
}
private void test(String s){
String password = s.replaceAll("/","");//与循环无关的代码被编译器外提了
for (int i=0;i<10;i++){
System.out.println(i);
}
}
循环展开:将相同的循环逻辑多次重复在一次迭代中,以减少循环次数
private void test(int[] arr){
int sum=0;
for (int i=0;i<8;i++){
sum +=arr[i];
}
}
private void test(int[] arr){
int sum=0;
for (int i=0;i<8;i+=2){//循环次数减少
sum +=arr[i];
sum +=arr[i+1];//重复循环体内相同逻辑
}
}
循环的自动向量化:对循环中地址连续的数组操作,会按顺序批量出入栈(这段是伪代码)
private void test(int[] arr1,int[] arr2){
for (int i=0;i<arr1.length;i++){
arr1[i] = arr2[i];
}
}
private void test(int[] arr1,int[] arr2){
for (int i=0;i<arr1.length;i+=4){
arr1[i:i+4] = arr2[i:i+4];//可以看成是在循环展开的基础上,将多个数组一块出入栈
}
}
Java中String对象的大小?
空String占用的空间
当前内存大小是在默认开启压缩指针的条件下
-
对象头 12
-
char[]数组引用 4
-
int 类型 hash数据大小 4
-
loss due to the next object alignment 对齐填充 4
-
总结:24
String类中的成员变量。
/** The value is used for character storage. */
private final char value[];
/** Cache the hash code for the string */
private int hash; // Default to 0
/** use serialVersionUID from JDK 1.0.2 for interoperability */
private static final long serialVersionUID = -6849794470754667710L;
非空String占用的空间
当前内存大小是在默认开启压缩指针的条件下
-
对象头 12
-
char[]数组引用 4
-
int 类型 hash数据大小 4
-
loss due to the next object alignment 对齐填充 4
-
总结:24
强引用、软引用、弱引用、虚引用
软引用 和 弱引用, 在当今最火缓存caffeine源码中使用了。
弱引用, 在ThreadLocalMap源码中使用了。
虚引用 ,在 JDK 的 堆外内存 源码当中,使用了。
Java 4种引用的级别由高到低依次为:强引用 > 软引用 > 弱引用 > 虚引用
引用类型 | 被垃圾回收时间 | 用途 | 生存时间 |
---|---|---|---|
强引用 | 从来不会 | 对象的一般状态 | JVM停止运行时终止 |
软引用 | 在内存不足时 | 对象缓存 | 内存不足时终止 |
弱引用 | 在垃圾回收时 | 对象缓存 | gc运行后终止 |
虚引用 | 在垃圾回收时 | 堆外内存 | 利用虚引用的通知特性来管理的堆外内存 |
1、强引用
如果一个对象具有强引用,那就类似于必不可少的生活用品,垃圾回收器绝不会回收它。
当内存空间不足,Java虚拟机宁愿抛出OutOfMemoryError错误,使jvm进程异常终止,也不会靠随意回收具有强引用的对象来解决内存不足问题。如:
String str = "abc";
List<String> list = new Arraylist<String>();
list.add(str)
在list集合里的数据不会释放,即使内存不足也不会
在ArrayList类中定义了一个私有的变量elementData数组,在调用方法清空数组时可以看到为每个数组内容赋值为null。
使用如clear()方法中释放内存的方法对数组中存放的引用类型特别适用,这样就可以及时释放内存。
2、软引用(SoftReference)
-
内存溢出之前进行回收,GC时内存不足时回收,如果内存足够就不回收
-
使用场景:在内存足够的情况下进行缓存,提升速度,内存不足时JVM自动回收
如果一个对象只具有软引用,那就类似于可有可无的生活用品。
如果内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对象的内存。
只要垃圾回收器没有回收它,该对象就可以被程序使用。
软引用可用来实现内存敏感的高速缓存。
软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收,JAVA虚拟机就会把这个软引用加入到与之关联的引用队列中。
public class Test {
public static void main(String[] args){
System.out.println("开始");
A a = new A();
SoftReference<A> sr = new SoftReference<A>(a);
a = null;
if(sr!=null){
a = sr.get();
}
else{
a = new A();
sr = new SoftReference<A>(a);
}
System.out.println("结束");
}
}
class A{
int[] a ;
public A(){
a = new int[100000000];
}
}
当内存足够大时,可以把数组存入软引用,取数据时就可从内存里取数据,提高运行效率
3.弱引用(WeakReference)
-
每次GC时回收,无论内存是否足够
-
使用场景:a. ThreadLocalMap防止内存泄漏 b. 监控对象是否将要被回收
如果一个对象只具有弱引用,那就类似于可有可无的生活用品。
弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。
在垃圾回收器线程扫描它 所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。
不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。
弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java虚拟机就会把这个弱引用加入到与之关联的引用队列中。如:
Object c = new Car(); //只要c还指向car object, car object就不会被回收
WeakReference<Car> weakCar = new WeakReference(Car);
当要获得weak reference引用的object时, 首先需要判断它是否已经被回收:
weakCar.get();
如果此方法为空, 那么说明weakCar指向的对象已经被回收了.
如果这个对象是偶尔的使用,并且希望在使用时随时就能获取到,但又不想影响此对象的垃圾收集,那么你应该用 Weak Reference 来记住此对象。
当你想引用一个对象,但是这个对象有自己的生命周期,你不想介入这个对象的生命周期,这时候你就是用弱引用。
这个引用不会在对象的垃圾回收判断中产生任何附加的影响。
4.虚引用(PhantomReference)
“虚引用”顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。
如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。
虚引用主要用来跟踪对象被垃圾回收的活动。
虚引用与软引用和弱引用的一个区别在于:虚引用必须和引用队列(ReferenceQueue)联合使用。
当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。
程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。
程序如果发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。
特别注意,
在实际程序设计中一般很少使用弱引用与虚引用,使用软引用的情况较多,这是因为软引用可以加速JVM对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出(OutOfMemory)等问题的产生。
虚引用(PhantomReference)的使用场景:
虚引用(PhantomReference)的使用场景,主要在 byteBuffer 回收堆外内存(直接内存)的流程中。
两种使用堆外内存的方法:
-
一种是依靠unsafe对象
-
另一种是NIO中的ByteBuffer,
直接使用unsafe对象来操作内存,对于一般开发者来说难度很大,并且如果内存管理不当,容易造成内存泄漏。所以不推荐。所以, 推荐使用的是ByteBuffer来操作堆外内存。
在上面的ByteBuffer如何 触发堆外内存的回收呢?是通过 虚引用的 关联线程是实现的。
-
当byteBuffer被回收后,在进行GC垃圾回收的时候,发现
虚引用对象Cleaner
是PhantomReference
类型的对象,并且被该对象引用的对象(ByteBuffer对象)
已经被回收了 -
那么他就将将这个对象放入到(
ReferenceQueue
)队列中 -
JVM中会有一个优先级很低的线程会去将该队列中的
虚引用对象
取出来,然后回调clean()
方法 -
在
clean()
方法里做的工作其实就是根据内存地址
去释放这块内存(内部还是通过unsafe对象去释放的内存)。
可以看到被虚引用引用的对象其实就是这个byteBuffer对象。
所以说需要重点关注的是这个byteBuffer对象被回收了以后会触发什么操作。
如何优雅的使用单例模式?
1.什么是单例
-
保证一个类只有一个实例,并且提供一个访问该全局访问点
2.那些地方用到了单例模式
-
网站的计数器,一般也是采用单例模式实现,否则难以同步。
-
应用程序的日志应用,一般都是单例模式实现,只有一个实例去操作才好,否则内容不好追加显示。
-
多线程的线程池的设计一般也是采用单例模式,因为线程池要方便对池中的线程进行控制。
-
Windows的(任务管理器)就是很典型的单例模式,他不能打开俩个。
-
windows的(回收站)也是典型的单例应用。在整个系统运行过程中,回收站只维护一个实例。
3.单例优缺点
优点:
-
在单例模式中,活动的单例只有一个实例,对单例类的所有实例化得到的都是相同的一个实例。这样就防止其它对象对自己的实例化,确保所有的对象都访问一个实例
-
单例模式具有一定的伸缩性,类自己来控制实例化进程,类就在改变实例化进程上有相应的伸缩性。
-
提供了对唯一实例的受控访问。
-
由于在系统内存中只存在一个对象,因此可以节约系统资源,当需要频繁创建和销毁的对象时单例模式无疑可以提高系统的性能。
-
允许可变数目的实例。
-
避免对共享资源的多重占用。
缺点:
-
不适用于变化的对象,如果同一类型的对象总是要在不同的用例场景发生变化,单例就会引起数据的错误,不能保存彼此的状态。
-
由于单例模式中没有抽象层,因此单例类的扩展有很大的困难。
-
单例类的职责过重,在一定程度上违背了“单一职责原则”。
-
滥用单例将带来一些负面问题,如为了节省资源将数据库连接池对象设计为的单例类,可能会导致共享连接池对象的程序过多而出现连接池溢出;如果实例化的对象长时间不被利用,系统会认为是垃圾而被回收,这将导致对象状态的丢失。
4.单例模式使用注意事项
-
使用时不能用反射模式创建单例,否则会实例化一个新的对象
-
使用懒汉单例模式时注意线程安全问题
-
饿汉单例模式和懒汉单例模式构造方法都是私有的,因而是不能被继承的,有些单例模式可以被继承(如登记式模式)
登记式单例模式(Enum Singleton Pattern)是一种实现单例模式的方式,它利用Java枚举(Enum)的特性来确保单例的唯一性。在Java中,枚举类型是线程安全的,并且其构造方法是私有的,这就天然地保证了单例的特性。
登记式单例模式的特点
枚举实现:使用枚举类型来实现单例。由于枚举实例的创建是线程安全的,并且Java虚拟机保证每个枚举类型及其定义的枚举常量都是唯一的,所以这种实现方式天生就是单例的。
自动支持序列化:枚举单例自动支持序列化机制,无需特殊处理。
防止反射攻击:由于枚举单例的构造方法是私有的,并且枚举类型不能被反射实例化,因此可以防止通过反射创建多个实例。
可继承性:虽然枚举的构造方法是私有的,但枚举类型本身是可以被继承的。这意味着你可以创建一个新的枚举类型,它继承了原始的单例枚举,并添加新的方法或属性。
示例
下面是一个登记式单例模式的示例:
public enum Singleton { INSTANCE; public void someMethod() { // 执行一些操作 } }
在这个例子中,
Singleton
是一个枚举类型,它有一个名为INSTANCE
的枚举常量。这个常量就是我们的单例实例。由于枚举实例是全局唯一的,所以INSTANCE
是Singleton
类型的唯一实例。使用方式
要使用这个单例,可以直接通过枚举类型来访问:
public class Main { public static void main(String[] args) { Singleton singleton = Singleton.INSTANCE; singleton.someMethod(); } }
在这个例子中,我们通过
Singleton.INSTANCE
来获取单例实例,并调用其someMethod
方法。登记式单例模式是单例模式的一种高效且简洁的实现方式,特别适用于需要高度安全性和简洁性的场景。
5.单例防止反射漏洞攻击
private static boolean flag = false;
private Singleton() {
if (flag == false) {
flag = !flag;
} else {
throw new RuntimeException("单例模式被侵犯!");
}
}
public static void main(String[] args) {
}
6.如何选择单例创建方式
-
如果不需要延迟加载单例,可以使用枚举或者饿汉式,相对来说枚举性好于饿汉式。
-
如果需要延迟加载,可以使用静态内部类或者懒汉式,相对来说静态内部类好于懒汉式。最好使用饿汉式
7.单例创建方式
1.饿汉式: 类初始化时,会立即加载该对象,线程天生安全,调用效率高。
2.懒汉式: 类初始化时,不会初始化该对象,真正需要使用的时候才会创建该对象,具备懒加载功能。
3.静态内部方式: 结合了懒汉式和饿汉式各自的优点,真正需要对象的时候才会加载,加载类是线程安全的。
4.枚举单例: 使用枚举实现单例模式
优点: 实现简单、调用效率高,枚举本身就是单例, 由jvm从根本上提供保障!避免通过反射和反序列化的漏洞;
缺点: 没有延迟加载。
5.双重检测锁方式
因为JVM重排序、内存可见性的原因,可能会初始化多次,
所以:需要通过 Double Check 双重检查+ synchronized + Volatile 解决 同步问题和可见性问题。
1.饿汉式
类初始化时,会立即加载该对象,线程天生安全,调用效率高。
package com.crazymakercircle.designmodel.singleton;
//饿汉式
public class FSingleton {
// 类初始化时,会立即加载该对象,线程安全,调用效率高
private static final FSingleton instance = new FSingleton();
// 私有化构造方法
private FSingleton() {
}
public static FSingleton getInstance() {
return instance;
}
}
饿汉模式就是类一旦加载,就把单例初始化完成,保证getInstance的时候,单例是已经存在的了。
特点:
-
是否 Lazy 初始化:否
-
是否多线程安全:是
-
实现难度:易
优点:
-
没有加锁,执行效率会提高。
-
这种方式比较常用,但容易产生垃圾对象
-
它基于JVM class loader 机制, 是单线程执行的, 避免了多线程的同步问题
缺点:
-
类加载时就初始化,浪费内存,
2.懒汉式
类初始化时,不会初始化该对象,真正需要使用的时候,才会创建该对象,具备懒加载功能。
package com.crazymakercircle.designmodel.singleton;
//懒汉模式
public class FLazySingleton {
//类初始化时,不会初始化该对象,真正需要使用的时候才会创建该对象。
private static FLazySingleton instance = null;
// 私有化构造方法
private FLazySingleton() {
}
//真正需要使用的时候才会创建该对象
public static synchronized FLazySingleton getInstance() {
if(null==instance)
{
instance=new FLazySingleton();
}
return instance;
}
}
3.静态内部类
静态内部方式:结合了懒汉式和饿汉式各自的优点,真正需要对象的时候才会加载,加载类是线程安全的。
package com.crazymakercircle.designmodel.singleton;
public class Singleton {
//静态内部类
private static class LazyHolder {
//通过final保障初始化时的线程安全
private static final Singleton INSTANCE = new Singleton();
}
//私有的构造器
private Singleton (){}
//获取单例的方法
public static final Singleton getInstance() {
//返回内部类的静态、最终成员
return LazyHolder.INSTANCE;
}
}
4.枚举单例式
枚举单例: 使用枚举实现单例模式
package com.lijie;
package com.crazymakercircle.designmodel.singleton;
//饿汉式
public enum SingletonEnumStyle {
INSTANCE;
// 类初始化时,会立即加载该对象,线程安全,调用效率高
public static SingletonEnumStyle getInstance() {
return INSTANCE;
}
}
枚举实现单例模式 优点:
-
实现简单、枚举本身就是单例,由jvm从根本上提供保障!
-
避免通过反射和反序列化的漏洞
缺点:
-
没有延迟加载
5.双重检测锁方式
所谓懒加载,就是直到第一次被调用时才加载。其实现需要考虑并发问题和指令重排,代码如下:
public class Singleton {
private volatile static Singleton instance; //①
private Singleton() { //②
}
public static Singleton getInstance() {
if (instance == null) {//③
synchronized (Singleton.class) {
if (instance == null) {//④
instance = new Singleton();//⑤
}
}
}
return instance;
}
}
这段代码精简至极,没有一个字符是多余的,下面逐行解读一下:
首先,注意到①处的volatile关键字,它具备两项特性:
一是保证此变量对于所有线程的可见性。即当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。
二是禁止指令重排序优化。
这里解释一下指令重排序优化:
代码 ⑤ 处的instance = new Singleton(); 并不是原子的,大体可分为如下 3 步:
-
分配内存
-
调用构造函数初始化成实例
-
让instance指向分配的内存空间
JVM 允许在保证结果正确的前提下进行指令重排序优化。
即如上 3 步可能的顺序为1->2->3 或 1->3->2 。
如果顺序是 1->3->2 ,当 3 执行完,2 还未执行时,另一个线程执行到代码 ③ 处,发现instance不为null,直接返回还未初始化好的instance并使用,就会报错。
所以使用volatile,就是为了保证线程间的可见性和防止指令重排。
其次,代码②处将构造函数声明为private目的:在于阻止使用new Singleton()这样的代码生成新实例。
最后,当客户端调用Singleton.getInstance()时,先检查是否已经实例化(代码③),未实例化时同步代码块,然后再次检查是否已实例化(代码④),然后才执行代码⑤。
两次检查的意义在于,防止synchronized同步过程中其他线程进行了实例化。
这就是著名的双重检查锁(Double check lock)实现单例,也即懒加载。
8 懒汉式和饿汉式的区别
单例模式属于创建型模式,它提供了一种创建对象的最佳方式。这种模式涉及到一个单一的类,该类负责创建自己的对象,同时确保只有单个对象被创建。这个类提供了一种访问其唯一的对象的方式,可以直接访问,不需要实例化该类的对象。注意:
1、单例类只能有一个实例。
2、单例类必须自己创建自己的唯一实例。
3、单例类必须给所有其他对象提供这一实例。
1、线程安全
饿汉式天生就是线程安全的,可以直接用于多线程而不会出现问题,
懒汉式本身是非线程安全的,需要通过多种手段,保证线程安全和内存可见性:
-
volatile 保证内存可见性
-
synchronized + 双重检查 保证线程安全
2、资源加载和性能
饿汉式在类创建的同时就实例化一个静态对象出来,不管之后会不会使用这个单例,都会占据一定的内存,但是相应的,在第一次调用时速度也会更快,因为其资源已经初始化完成。
而懒汉式顾名思义,会延迟加载,在第一次使用该单例的时候才会实例化对象出来,第一次调用时要做初始化,如果要做的工作比较多,性能上会有些延迟,之后就和饿汉式一样了。
-
意图:保证一个类仅有一个实例,并提供一个访问它的全局访问点。
-
主要解决:一个全局使用的类频繁地创建与销毁。
-
何时使用:当您想控制实例数目,节省系统资源的时候。
-
如何解决:判断系统是否已经有这个单例,如果有则返回,如果没有则创建。
-
关键代码:构造函数是私有的。
CAS 的优势与核心问题
由于JVM重量级锁使用了Linux内核态下的互斥锁(Mutex),这是重量级锁开销很大的原因。
抢占与释放的过程中,涉及到 进程的 用户态和 内核态, 进程的 用户空间 和内核空间之间的切换, 性能非常低。而CAS进行自旋抢锁,这些CAS操作都处于用户态下,进程不存在用户态和内核态之间的运行切换,因此JVM轻量级锁开销较小。这是 CAS 的优势。
CAS 的核心问题是什么呢?
在争用激烈的场景下,会导致大量的CAS空自旋。比如,在大量的线程同时并发修改一个AtomicInteger时,可能有很多线程会不停地自旋,甚至有的线程会进入一个无限重复的循环中。大量的CAS空自旋会浪费大量的CPU资源,大大降低了程序的性能。除了存在CAS空自旋之外,在SMP架构的CPU平台上,大量的CAS操作还可能导致“总线风暴”。
在高并发场景下如何提升CAS操作性能/解决CAS恶性空自旋 问题呢?
较为常见的方案有两种:
-
分散操作热点
-
使用队列削峰
比如,在自增的场景中,可以使用LongAdder替代AtomicInteger。这是一种 分散操作热点,空间换时间 方案,也是 分而治之的思想。
以空间换时间:LongAdder 和 Striped64
Java 8提供一个新的类LongAdder,以空间换时间的方式提升高并发场景下CAS操作性能。
LongAdder核心思想就是热点分离,与ConcurrentHashMap的设计思想类似:将value值分离成一个数组,当多线程访问时,通过Hash算法将线程映射到数组的一个元素进行操作;而获取最终的value结果时,则将数组的元素求和。
最终,通过LongAdder将内部操作对象从单个value值“演变”成一系列的数组元素,从而减小了内部竞争的粒度。LongAdder的演变如图所示。
LongAdder的操作对象由单个value值“演变”成了数组
LongAdder的分治思想和架构
LongAdder的操作对象由单个value值“演变”成了数组
LongAdder 继承了 Striped64,核心源码在 Striped64中。
条带累加Striped64的结构和源码
/**
* A package-local class holding common representation and mechanics
* for classes supporting dynamic striping on 64bit values. The class
* extends Number so that concrete subclasses must publicly do so.
*/
@SuppressWarnings("serial")
abstract class Striped64 extends Number {
/**
* Padded variant of AtomicLong supporting only raw accesses plus CAS.
*
* JVM intrinsics note: It would be possible to use a release-only
* form of CAS here, if it were provided.
*/
@sun.misc.Contended static final class Cell {
volatile long value;
Cell(long x) { value = x; }
final boolean cas(long cmp, long val) {
return UNSAFE.compareAndSwapLong(this, valueOffset, cmp, val);
}
// Unsafe mechanics
private static final sun.misc.Unsafe UNSAFE;
private static final long valueOffset;
static {
try {
UNSAFE = sun.misc.Unsafe.getUnsafe();
Class<?> ak = Cell.class;
valueOffset = UNSAFE.objectFieldOffset
(ak.getDeclaredField("value"));
} catch (Exception e) {
throw new Error(e);
}
}
}
/** Number of CPUS, to place bound on table size */
static final int NCPU = Runtime.getRuntime().availableProcessors();
/**
* Table of cells. When non-null, size is a power of 2.
*/
transient volatile Cell[] cells;
/**
* Base value, used mainly when there is no contention, but also as
* a fallback during table initialization races. Updated via CAS.
*/
transient volatile long base;
/**
* Spinlock (locked via CAS) used when resizing and/or creating Cells.
*/
transient volatile int cellsBusy;
/**
* Package-private default constructor
*/
Striped64() {
}