JVM 是面试重点,这里跟着视频自己总结归纳,加油!后续还有JUC,找个机会一起总结了~
JVM定义
1. 概念
- Java Virtual Machine -java程序的运行环境(java二进制字节码的运行环境)
- 好处:
- 一次编写,到处运行(减轻平台受限)
- 自动内存管理,垃圾回收功能(自动释放,不会造成内存的泄露)
- 数组下标越界检查(防止代码越界覆盖)
- 多态
2. 面试题:jvm、jre和jdk的比较
内存结构
1. 程序计数器
- 物理上通过寄存器实现程序计数器(用于存储地址、读取地址)
执行流程:二进制字节码——解释器——机器码——CPU;
- 程序计数器作用:记住下一条jvm指令的执行地址;
作用:
-
线程私有,不受外部的线程影响;每个线程都有自己的程序计数器
-
不会存在内存溢出(唯一区域)
2. 虚拟机栈
定义
- 先进后出,线程运行的时候需要的内存空间
- 栈帧:每个方法运行的时候需要的内存
- 栈帧中占用的内存空间用于:参数、局部变量、返回地址等。
- 每个线程只能有一个活动栈帧,对应着正在执行的那个方法。
问题辨析
-
垃圾回收是否涉及栈内存?
- 垃圾回收通常是设计堆内存的管理,堆内存是动态分配的内存空间,栈内存是静态分配的。
- 需要考虑对动态堆内存空间的回收利用。
-
栈内存的分配是与大越好吗?
- 栈内存分配空间过多,会影响到线程的数目关系,虽然内存增加可以带来的是线程自身的递归次数调用的增加,一般采用默认的栈内存大小即可。
- 栈内存空间分配过小,部分线程内部执行的过程中可能会出现栈溢出的情况。
-
方法内部的局部变量是否线程安全?
- 私有的变量不用考虑(如内部的
localVar++;) - 共享的变量则需要考虑线程安全(如外部的
private static int sharedCounter=0;)
补充:还需要讨论方法内部局部变量的作用范围,没有逃离,则线程是安全的;否则就不安全。如果是局部变量引用了对象,逃离了作用范围,则线程不安全。
代码举例
public class ThreadSafetyExample { private static int sharedCounter = 0; public void incrementCounter() { int localVar = sharedCounter; // 将共享变量赋给局部变量 localVar++; // 对局部变量进行自增操作 sharedCounter = localVar; // 将局部变量的值赋回共享变量 } public static void main(String[] args) { final ThreadSafetyExample example = new ThreadSafetyExample(); Runnable task = () -> { for (int i = 0; i < 1000; i++) { example.incrementCounter(); } }; // 创建两个线程并同时执行递增操作 Thread thread1 = new Thread(task); Thread thread2 = new Thread(task); thread1.start(); thread2.start(); try { thread1.join(); thread2.join(); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("Final counter value: " + sharedCounter); } }- 在这个示例中,
incrementCounter方法将共享的sharedCounter变量赋值给局部变量localVar,然后对localVar进行递增操作,最后再将递增后的值赋回sharedCounter。 - 如果没有适当地同步控制,两个线程同时对 共享的局部变量:
sharedCounter进行递增操作时可能会出现竞态条件,导致最终结果不正确。
解决方案:
- 添加
synchronized关键字对共享资源进行同步的控制:
// 例如对incrementCounter方法进行改变,添加锁操作,表明再多线程下是同步的,只有一个线程同时访问该方法. public sychronized void incrementCounter(){ sharedCounter++; } // 其余部分,在main函数中调用syncronized方法进行实例对象的创建即可 - 私有的变量不用考虑(如内部的
栈内存溢出
- 栈帧过多导致栈内存溢出(栈帧中的方法的递归调用)
- 栈帧过大导致栈内存溢出(概率比较小)
线程运行诊断
-
cpu占用过多
-
程序运行很长时间没有结果
3. 本地方法栈
-
线程私有
4. 堆
- Heap堆:通过new关键字,创建对象都会使用堆内存
- 特点:
- 它是线程共享,堆中对象都需要考虑线程安全的问题
- 有垃圾回收机制
堆内存溢出
// 举例说明:
public class Demo{
public static void main(String[] args){
int i=0;
try{
List<String>list=new ArrayList<>();
String a="hello";
while(true){
list.add(a); //hello,hellohello,hellohello...
a=a+a; //hellohellohello...
i++;
}
}catch(Throwable e){
e.printStacktrace();
System.out.println(i);
}
}
}
// 错误提示:
// java.lang.OutOfMemoryError:Java heap space
// 平时堆内存过大不易看出错误所在,可以在运行配置中,vm选项处添加参数:Xmx+堆运行空间(比如:Xmx8m,8m的运行空间)
堆内存诊断
- 在命令行
-
jps工具(命令:
jps)- 查看当前系统中存在哪些java进程
-
jmap工具(命令:
jmap -heap 进程id)- 查看堆内存占用情况
-
jconsole工具
-
图形界面的,多功能的监测工具,可以连续监测
-
5. 方法区
定义: 所有Java线程的共享区域;随着虚拟机进程的启动而创建,通常随着虚拟机的退出而销毁;在方法区中存储着每个类的结构信息,包括类的字段、方法信息、构造方法信息,以及运行时常量池等;
组成
方法区内存溢出:
-
1.8 以前会导致永久代内存溢出
-
演示永久代内存溢出
java.lang.OutOfMemoryError: OermGen space -XX:MaxPermSize=8m
-
-
1.8 以后会导致元空间内存溢出
-
演示元空间内存溢出
java.lang.OutOfMemoryError:Metaspace -XX:MaxMetaspaceSize=8m
-
-
二进制字节码(类基本信息,常量池,类方法定义,包含了虚拟机指令)
-1. 运行时常量池:
-
常量池,就是一张表,虚拟机指令根据这张常量表查找要执行的类名、方法名、参数类型、字面量等信息;
-
运行时常量池,常量池是 *.class文件中的,当该类被加载的时候,它的常量池信息通常会放入运行时常量池,并把里面的符号地址变为真实地址。
-2. StringTable
- 常量池和串池的关系
public class Demo{
public static void main(String[] args){
String s1="a"; //懒惰的行为,用到了才会去创建常量池中的字符串
String s2="b";
String s3="ab";
}
}
-
需要注意的是:
-
常量池中的信息,都会被加载到运行时的常量池中,这时 a,b,ab都是常量池中的符号,还没有变为java字符串对象。
-
lda #2此时把a转变为"a"字符串对象;对于b也是相同的地址指令。 -
创建完字符串对象之后,放入串池。上述代码执行之后,串池:
StringTable['a','b','ab']。需要强调:StringTable属于的hashtable的结构,不能扩容,也就是唯一存在,不重不漏。
-
- 字符串变量拼接
// 上述代码在主函数中添加:
String s4 = s1 + s2;
- 先创建的是
new StringBuilder()对象变量; - 接着依次在该局部变量中调用
append()方法;将s1和s2中的字符进行拼接 - 最后调用
toString()方法;将最后的结果存入局部变量的表当中。
//相当于是:
new StringBuilder().append("a").append("b").toString();
// 最后通过toString()方法:
new String("ab"); // 存入s4局部变量中去,注意位置是堆里面
-
此时判断
s3==s4?-
虽然最后二者的值都是
"ab",但是二者的位置不一样。 -
s3的位置在串池StringTable中; -
s4的位置由于是新创建的对象变量,放在了堆里面;
-
- 编译期优化
// 添加测试:
String s5="a"+"b";
-
这段代码和之前的
String s3="ab";找到的"ab"都是同一个,也就是在常量池中寻找同一位置元素。因此有:s3==s5; -
上述的
s5代码,是javac在编译期间的优化,结果已经在编译期确定为ab了。常量字符串拼接的底层原理~
- 字符串延迟加载
-
字符串常量池(String Pool)(这里简称为串池)是一个特殊的内存区域,用于存储字符串常量。在字符串常量池中,相同内容的字符串常量只会被存储一次,以节省内存空间。
-
字符串常量池中的字符串是不可变的,一旦创建了就不会修改~
-
延迟加载(Lazy Loading)指的是在需要的时候才进行加载或初始化的机制。在字符串常量池中,延迟加载意味着并不是所有的字符串常量都在虚拟机启动时就被加载到常量池中,而是在需要使用时才被加载。
- intern方法,主动将串池中还没有的字符串对象放入串池
-
给出两个不同的例子测试:
public class test { // ["a","b","ab"] public static void main(String[] args) { String s = new String("a") + new String("b"); // new String("ab); // 堆 new String("a") new String("b") new String("ab"); String s2= s.intern(); // 将s字符串对象尝试放入串池中去,如果有则并不会放入,如果没有就会放入串池中去,会把串池中的对象返回 System.out.println(s2 == "ab"); System.out.println(s == "ab"); } } /** * 输出的结果是: * true * true */public class test { // ["ab","a","b"] public static void main(String[] args) { String x = "ab"; // 首先放入串池 String s = new String("a") + new String("b"); // 此时为堆中创建的字符串 // 堆 new String("a") new String("b") new String("ab"); 这里的对象和串池中首先创建的对象不一样了 String s2= s.intern(); // 将s字符串对象尝试放入串池中去,如果有则并不会放入,如果没有就会放入串池中去,会把串池中的对象返回; System.out.println(s2 == x); //s2此时即为串池中的对象 System.out.println(s == x); } } /** * 输出的结果是: * true * false */ -
注意的是:上述的规则适用于
jdk1.8:将s字符串对象尝试放入串池中去,如果有则并不会放入,如果没有就会放入串池中去,会把串池中的对象返回。 -
在
jdk1.6环境下,这里的String s2 = s.intern();是将s进行拷贝一份,将拷贝放入串池中。也就是串池中引用的对象和s对象不一样,最后的s==x返回的是false。
综合面试题:
public class StringTableTest {
public static void main(String[] args) {
String s1="a";
String s2="b";
String s3= "a"+"b"; // ab,放入常量池中
String s4=s1+s2; // 产生新的字符串拼接,放在堆里。new String("ab")
String s5="ab"; // 同s3
String s6=s4.intern(); //同s3
// 问:
System.out.println(s3==s4); // false
System.out.println(s3==s5); // true
System.out.println(s3==s6); // true
String x2=new String("c")+new String("d"); // new String("cd"); 堆中
String x1="cd"; //常量池
x2.intern();
// 问:如果调换了【最后两行代码】的位置?jdk1.6呢?
// x2.intern();
// String x1="cd";
// x1 == x2 true
System.out.println(x1==x2); // false
// 引用的是常量池中的副本,1.6版本返回的是false;
}
}
- 1.8 串池用的是堆空间
- 1.6 串池用的是永久代
- StringTable的垃圾回收
-
StringTable的垃圾回收是通过对无用的字符串对象进行回收来释放内存空间。StringTable中的字符串对象通常是不可变的,一旦创建了就无法进行修改了,因此是可以被认为安全的且可以被重复使用的。
-
给出简单的示例:
public class StringTableGCExample { public static void main(String[] args) { // 创建一个字符串对象并指向它 String str1 = "Hello"; // 创建另一个字符串对象,并让原来的字符串对象不再被引用 String str2 = "World"; str1 = null; // 手动触发垃圾回收 System.gc(); } } -
在上面的示例中,通过将原来的字符串"Hello"的引用置为null,使得该字符串对象成为可回收的垃圾。调用System.gc()方法可以请求系统运行垃圾回收器来回收这些无用的字符串对象。
- StringTable的性能调优
-
调整:-XX:StringTableSize=桶个数;
-
考虑将字符串对象是否入池;
- 本质上是利用了串池唯一性的原则,如果字符串常量在常量池中已经存在,新创建的字符串对象会直接指向已经存在的字符串常量,节省内存空间,机制称为字符串入池~
public class StringInternExample { public static void main(String[] args) { String str1 = new String("Hello"); // 创建一个新的字符串对象 String str2 = "Hello"; // 字符串"Hello"已在常量池中 String str3 = str1.intern(); // 将str1所引用的字符串对象放入常量池,并返回常量池中的引用 System.out.println(str2 == str3); // true,因为str3指向的是常量池中的字符串对象 } }-
在上面的示例中,通过调用str1.intern()方法,可以将str1引用的字符串对象放入常量池中,并返回常量池中该字符串的引用。然后,通过比较str2和str3的引用,可以看到它们指向的是同一个字符串对象,因此输出结果为true。
6. 直接内存
直接内存并不属于Java虚拟机的内存管理,而是属于操作系统内存
6.1 定义
-
常见于NIO操作时候,用于数据缓冲区
-
分配回收成本较高,但是读写性能高
-
不受JVM内存回收管理
6.2 基本使用
6.3 释放原理
-
垃圾回收只用释放Java中的内存,而且是自动释放的;
-
直接内存中的主动调用的是
freememory()方法完成对内存的释放~ -
与堆内存中的对象不同,直接内存中的垃圾无法通过Java虚拟机的垃圾回收器来回收。直接内存的分配和释放由操作系统负责管理。当不再需要直接内存时,应该显式地调用ByteBuffer的clean方法或者手动设置引用为null来释放内存,并最终由操作系统回收。
-
这里有个概念叫做虚拟引用:
-
通过
ByteBuffer的cleaner方法获取到的cleaner对象就是虚拟引用。这个虚拟引用实际上是一个sun.misc.Cleaner类型的对象,它与要释放的直接内存对象关联在一起。当该直接内存对象被垃圾回收器回收时,虚拟引用会收到通知,并执行一些释放资源的操作。
-
6.4 分配和回收原理
-
使用了
Unsafe对象完成直接内存的分配和回收,并且回收需要主动调用freeMemory方法 -
ByteBuffer的实现类内部,使用了Cleaner(虚引用)来监测ByteBuffer对象,一旦ByteBufer对象被垃圾回收,那么就会有ReferenceHandler线程通过Cleaner的clean方法调用freeMemory来释放直接内存;
垃圾回收
1. 如何判断对象可以回收
1.1 引用计数法
-
引用计数法是一种垃圾回收算法,其基本思想是对内存中的每个对象维护一个引用计数器,用来记录当前指向该对象的引用数量。当对象被引用时,计数器加1;当引用失效时,计数器减1。当对象的引用计数为0时,表示该对象不再被任何引用指向,即成为垃圾对象,可以被回收。
虽然引用计数法简单直观,但它存在一些问题,例如:
-
循环引用问题:如果两个或多个对象相互引用,它们的引用计数永远不会为0,导致无法回收这些循环引用的对象,造成内存泄漏。
-
计数器更新开销大:每次引用发生变化时都需要更新计数器,增加了额外的开销。
-
需要实时维护计数器:需要在每次引用操作时更新计数器,可能影响程序运行效率。
-
1.2 可达性分析算法
-
Java虚拟机中的垃圾回收器采用可达性分析来探索所有存活的对象;
-
扫描堆中的对象,看是否可以沿着GC Root对象 为起点的引用链找到对象,找不到的话,说明可以回收;
-
从一组称为"GC Roots"的对象开始,递归地遍历对象之间的引用关系,判断对象是否可达。(注意是递归的算法~)
1.3 五种引用
详细分析过程
-
强引用而言:对于实例对象,只有当此时的
GC ROOT强引用都断开时候,在垃圾回收的同时才可以被回收~ -
软引用而言:对于某个对象而言,如果此时发生了软引用和强引用,此时不会进行垃圾回收。但是,如果不存在强引用,在内存空间不足的情况下,可以进行软引用~
-
弱引用而言:相比于软引用而言,只要此时没发生强引用,只要发生了软引用,就可以对对象进行垃圾回收(相较于软引用的回收条件更为宽松~)
- 软引用和弱引用的引用对象在发生垃圾回收之后,会进入引用队列中~
- 原因:软/弱 引用自身同样会占用内存空间,如果想对该内存进行释放,需要使用引用队列定位,后做相关处理。
-
虚引用而言:创建
ByteBuffer的实现类对象的时候,创建Cleaner的虚引用对象,ByteBuffer分配直接内存,并把内存地址传递给虚引用对象。在ByteBuffer被垃圾回收的时候,会把虚引用对象进入引用队列,利用线程间接调用Unsafe.freeMemory方法,回收掉直接内存,不会造成内存泄漏。 -
终结器引用而言:同样当此时没有强引用对象的时候,虚拟机创建终结器引用对象,此时将此对象加入引用队列中~,再由优先级较低的
handler线程对终结器引用的对象进行finallize()方法调用,下一次垃圾回收的时候,可以进行。-
问题是:第一次入队时候还不能回收引用对象,而且由于是较低优先级的线程执行,可能会造成应用等待,占用内存过久没被释放,因此一般不推荐~
-
简洁记忆
-
强引用
- 只有所有的GC Roots 对象都不通过【强引用】引用该对象,该对象才可以被垃圾回收;
-
软引用
- 仅有软引用引用该对象时候,在垃圾回收后,内存仍然不足的时候会再次触发垃圾回收,回收软引用对象;
- 可以配合引用队列来释放软引用自身;
-
弱引用
- 仅有弱引用引用该对象时,在垃圾回收时,无论内存是否充足,都会回收弱引用对象;垃圾回收一个,加入一个~
- 可以配合引用队列释放弱引用对象本身;
-
虚引用
- 必须配合引用队列进行使用,主要时配合
ByteBuffer使用,被引用对象回收时,会将虚引用入队,由Reference Handler线程调用虚引用相关方法释放直接内存~
- 必须配合引用队列进行使用,主要时配合
-
终结器引用
-
无需手动编码,但其内部配合引用队列使用,在垃圾回收时,终结器引用入队(被引用对象暂时没有被回收),再由
Finalizer线程通过终结器引用找到被引用对香港并调用它的finalize方法,第二次GC时,才会回收被引用对象~
-
2. 垃圾回收算法
2.1 标记清除
-
Mark-and-sweep算法分为:标记、清除阶段:首先是标记出所有不需要回收的算法,在标记完成后统一回收掉所有没有被标记的对象。 -
优点:速度较快;
-
缺点:会造成内存碎片(大量且不连续的碎片);同时标记清除两个过程的效率都不高;
2.2 标记整理算法
-
Mark Compat算法,分为标记、整理两个阶段。和标记清除类似,但后续步骤不是直接对可回收对象进行回收,而是让所有存活的对象向一端移动,直接清理掉端边界以外的内存。 -
优点:没有内存碎片
-
缺点:速度慢、执行效率不高
2.3 复制算法
-
Copy算法。将内存分为大小相同的两块,每次使用其中的一块。 -
当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收。
-
优点:没有内存碎片
-
缺点:占用双倍的内存空间(大区域中一半一半),而且可用的内存变小,每次只能使用其中的一半;不适合老年代(存活对象数量较大,复制性能差很多)
2.4 分代算法
- 对象首先分配在伊甸园区
- 新生代空间不足时,触发
minor gc,伊甸园和from存活的对象,使用copy复制到to中,存活的对象年龄加1,并且交换from to幸存区 minor gc会引发一次stop the world,暂停用户其他的线程,等垃圾回收结束,用户线程才恢复运行(新生代 回收时暂停的时间较短)- 当对象寿命超过阈值,会晋升到老年代,最大寿命时
15(1111 4bit) - 当老年代空间不足,会先尝试触发
minor gc,如果之后空间仍不足,会触发full gc,STW的时间更长(老年代存活对象较多,处理慢;清除采用算法时标记清除/整理 速度较慢。)
小问题:
HotSpot为什么要分为新生代和老年代?
-
综述:新生代和老年代的主要原因时为了优化垃圾回收的效率和性能,以提高整个应用程序的性能和响应速度;同时,通过这种分代的方式,还可以更好地控制对象的分配和回收,减少内存碎片化问题,提高整个Java应用程序的性能和稳定性。
-
新生代:对象的诞生地和短期存活地,大部分对象会成为垃圾,因此采用较小的Eden(伊甸园)空间和两个Survivor(幸存区)空间进行回收。新生代通常采用的:复制算法进行垃圾回收,避免碎片化和快速回收垃圾。
-
老年代:存放的对象多且存活时间长,多采用标记-清除/整理的算法进行垃圾回收,减少复制的开支,但可能会造成碎片化问题。
3. 分代垃圾回收
-
相关参数:
4. 垃圾回收器
概述思想:
- 串行
- 单线程;
- 堆内存较小,适合个人电脑;
- 吞吐量优先
- 多线程;
- 适合堆内存较大,多核CPU支持
- 让单位时间内,STW的时间最短(0.2 0.2 = 0.4)
- 响应时间优先
- 多线程;
- 适合堆内存较大,多核CPU支持
- 尽可能让单次 STW 的时间最短(0.1 0.1 0.1 0.1 0.1 = 0.5)
4.1 串行垃圾回收器(Serial收集器)
-
指令:
-XX : +UseSerialGC = Serival + SerialOld(新生代 复制 ;老年代) -
-
Serial单线程运行,这里的单线程不仅仅意味着它只会使用一条垃圾回收线程去完成垃圾收集工作,更重要的是它在进行垃圾回收工作的时候,必须暂停其他所有的工作线程(STW),直到此时的收集结束。
4.2 ParNew垃圾回收器
ParNew收集器其实就是Serial收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为(控制参数、收集算法、回收策略等等)和Serial收集器完全一样。-
垃圾回收中的并行和并发:
-
并行(Parallel):指的是多垃圾收集线程并行工作,但此时用户线程仍然属于等待的状态。
-
并发(Concurrent):指用户线程与垃圾回收线程同时进行,可能会交替执行,用户程序在继续运行,而垃圾回收器运行在另外一个CPU上。
-
ParNew回收器是JVM中用于新生代的并行垃圾回收器(重点在新生代)
4.3 Parallel Scavenge 垃圾回收器
-
关注点在吞吐量优先(高效利用CPU)
-
4.4 Serial Old 垃圾回收器
-
本质上是Serial收集器的老年代版本。
-
4.5 Parallel Old 垃圾回收器
- 本质上Parallel Scavenge 收集器的老年代版本,在注重吞吐量和CPU资源的场合上,都可以优先考虑这两种垃圾回收器。
-
4.6 CMS 并发垃圾回收器
- 主要用于老年代的垃圾回收,CMS的设计目标是减少引用程序停顿时间,通过与引用程序线程并发执行部分垃圾回收操作来实现这一目标。
主要步骤:
- 初始标记:在此阶段,CMS会暂停应用程序线程,仅在STW的情况下进行,在这个阶段,CMS只标记根对象和直接根对象关联的对象;
- 并发标记:随后的标记工作将与引用程序并发执行。CMS使用并发标记算法来识别所有存活的对象,并记录它们的存活状态;
- 重新标记:在并发标记结束之后,CMS会再次暂停应用程序线程,进行短暂的重新标记。
- 并发清除:CMS将并发执行清除操作,删除未标记的对象,释放它们所占用的内存空间;
-
优点:尽量减少应用程序的停顿时间,适用于较小的STW和更高的吞吐量场景;
-
缺点:可能会引发内存碎片的问题,在标记和清除的过程中消耗一定的系统资源;
4.7 G1 回收器
- G1(Garbage-First)是一款面向服务器的垃圾回收器,主要针对配备多颗处理器以及大容量内存的及其,以高概率满足GC停顿时间要求的同时,还具备提高吞吐量性能的特点。
- 随着新版 JDK 的发布,CMS 垃圾回收器已经被标记为"deprecated",推荐使用 G1 或者其他垃圾回收器来替代;
适用场景:
- 同时注重吞吐量和低延迟,默认的暂停目标是200ms;
- 超大堆内存,会将堆划分为多个大侠相等的Region;
- 整体上是标记+整理算法,两个区域之间是复制算法;
G1 垃圾回收阶段
-
Young Collection 存在STW阶段
-
Young Collection +CM
- 在Young GC时会进行GC Root 的初始标记
- 老年代占用堆空间比例达到阈值的时候,进行并发标记(不会STW),阈值默认是45%
-
Mixed Collection
会对E、S、O进行全面垃圾回收
- 最终标记(Remark)会STW;
- 拷贝存活(Evacuation)会STW;
-
Full GC
- SerialGC、ParallelGC、CMS、G1。在新生代内存不足的情况下发生的垃圾收集都是:minor gc
- 前两者在老年代内存不足时发生的垃圾收集是full gc;后两者需要看并发产生垃圾的效率,比如回收的速度高于产生的速度,那么就不会产生full gc;
- Young Collection 跨代引用
- 指的是新生代垃圾回收过程中可能涉及到的跨代引用。
- 比如:在进行新生代垃圾回收时,可能会发现部分对象存在于年轻代区域,但被老年代区域的对象所引用,这种情况就称为 “Young Collection 跨代引用”。
- G1回收器会在垃圾回收的期间,建立记录跨代引用的数据结构,确保执行垃圾回收操作时,能够正确处理这些引用关系~
- Remark(重标记)
大致运行流程:
-
G1收集器在后天维护了一个优先列表,每次根据允许的手机时间,优先选择回收价值最大的Region(也就是它的名字 Garbage-First的由来)
4.8 响应时间优先
总结
Serial GC、Parallel GC、Concurrent Mark Sweep GC 这三个 GC 不同:
- 最小化地使用内存和并行开销,选 Serial GC
- 最大化应用程序的吞吐量,选 Parallel GC
- 最小化 GC 的中断或停顿时间,选 CMS GC
5. 垃圾回收调优
预备知识:
- 掌握GC相关的VM参数,会基本的空间调整;
- 掌握相关工具;
- 调优限定于当前的应用、环境;部分举例仅供参考学习~
5.1 调优领域
- 内存
- 锁竞争
- cpu占用
- io
5.2 确定目标
低延迟、高吞吐量,选择合适的回收器;CMS,G1,ZGC;ParallelGC;
- ZGC 收集器:与 CMS 中的 ParNew 和 G1 类似,ZGC 也采用标记-复制算法,不过 ZGC 对该算法做了重大改进。ZGC 可以将暂停时间控制在几毫秒以内,且暂停时间不受堆内存大小的影响,出现 Stop The World 的情况会更少,但代价是牺牲了一些吞吐量。ZGC 最大支持 16TB 的堆内存。
5.3 最快的GC是不发生GC
- 查看 FullGC前后的内存占用,考虑到几个问题:
-
数据是不是太多?
resultSet = statement.executeQuery("select * from BigTable limit n"); // 进行查询数量的限制 -
数据表示是否臃肿?
- 对象图
- 对象大小
-
是否存在内存泄露?解决:
- 软/弱引用
- 第三方缓存实现(redis 等)
5.4 新生代调优
- 新生代特点:
- 所有的new 操作的内存分配非常廉价;
- 死亡对象的回收代价是0;
- 大部分对象用过即消灭;
- Minor GC 的时间远远低于 Full GC;
- 新生代的空间并不是越大越好,需要考虑到吞吐量和垃圾回收的效率问题。新生代尽可能大,由于垃圾回收都是标记-复制算法,且复制时间长于标记时间(主要复制时间),但是新生代的对象存活较少,占用的时间也会较短。
- 新生代能容纳所有【并发量 * (请求 - 响应)】的数据;
幸存区:
- 幸存区大到能保留【当前活跃对象 + 需要晋升到老年代对象】;
- 晋升阈值配置得当,让长时间存活对象尽快晋升;
5.5 老年代调优
以 CMS 为例:
- CMS 的老年代内存越大越好
- 先尝试不做调优,如果没有Full GC 那么已经…,否则先尝试调优新生代;
- 观察发生 Full GC 时候老年代内存占用,将老年代内存预设调大1/4 ~ 1/3;
类加载与字节码技术
1. 概述
- 类文件结构
- 字节码指令
- 编译期处理
- 类加载阶段
- 类加载器
- 运行期优化
2. 类文件结构
2.1 文件结构
字节码是一种二进制的类文件,是编译之后供虚拟机解释执行的二进制字节码文件,一个 class 文件对应一个 public 类型的类或接口
字节码内容是 JVM 的字节码指令,不是机器码,C、C++ 经由编译器直接生成机器码,所以执行效率比 Java 高;
ClassFile {
u4 magic;
u2 minor_version;
u2 major_version;
u2 constant_pool_count;
cp_info constant_pool[constant_pool_count-1];
u2 access_flags;
u2 this_class;
u2 super_class;
u2 interfaces_count;
u2 interfaces[interfaces_count];
u2 fields_count;
field_info fields[fields_count];
u2 methods_count;
method_info methods[methods_count];
u2 attributes_count;
attribute_info attributes[attributes_count];
}
| 类型 | 名称 | 说明 | 长度 | 数量 |
|---|---|---|---|---|
| u4 | magic | 魔数,识别类文件格式 | 4个字节 | 1 |
| u2 | minor_version | 副版本号(小版本) | 2个字节 | 1 |
| u2 | major_version | 主版本号(大版本) | 2个字节 | 1 |
| u2 | constant_pool_count | 常量池计数器 | 2个字节 | 1 |
| cp_info | constant_pool | 常量池表 | n个字节 | constant_pool_count-1 |
| u2 | access_flags | 访问标识 | 2个字节 | 1 |
| u2 | this_class | 类索引 | 2个字节 | 1 |
| u2 | super_class | 父类索引 | 2个字节 | 1 |
| u2 | interfaces_count | 接口计数 | 2个字节 | 1 |
| u2 | interfaces | 接口索引集合 | 2个字节 | interfaces_count |
| u2 | fields_count | 字段计数器 | 2个字节 | 1 |
| field_info | fields | 字段表 | n个字节 | fields_count |
| u2 | methods_count | 方法计数器 | 2个字节 | 1 |
| method_info | methods | 方法表 | n个字节 | methods_count |
| u2 | attributes_count | 属性计数器 | 2个字节 | 1 |
| attribute_info | attributes | 属性表 | n个字节 | attributes_count |
Class 文件格式采用一种类似于 C 语言结构体的方式进行数据存储,这种结构中只有两种数据类型:无符号数和表
- 无符号数属于基本的数据类型,以 u1、u2、u4、u8 来分别代表1个字节、2个字节、4个字节和8个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照 UTF-8 编码构成字符串
- 表是由多个无符号数或者其他表作为数据项构成的复合数据类型,表都以
_info结尾,用于描述有层次关系的数据,整个 Class 文件本质上就是一张表,由于表没有固定长度,所以通常会在其前面加上个数说明
获取方式:
-
HelloWorld.java 执行
javac -parameters -d . HellowWorld.java指令 -
写入文件指令
javap -v xxx.class >xxx.txt -
IDEA 插件 jclasslib
2.2 魔数版本
魔数:每个Class文件开头的4个字节的无符号整数称之未魔数(Magic Number),是Class文件的标识符,代表的是一个能被虚拟机接受的有效合法的Class文件。
- 魔数值固定为:0XCAFEBABE,不符合则会抛出错误;
- 使用魔数而不是扩展名来进行识别主要是基于安全方面的考虑,因为文件扩展名可以随意改动~
版本:4个字节,5 6 两个字节代表的是编译的副版本号 minor_version ,而7 8 两个字节是编译的主版本号major_version;
-
不同版本的Java编译器编译的Class文件对应的版本是不一样的,高版本Java虚拟机可以执行由低版本编译器生成的Class文件,反之JVM会抛出异常~
2.3 常量池
常量池中的常量数量是不固定的,所以在常量池的入口需要防止一项u2类型的无符号数,代表常量池计数器,这个容量计数是从1而不是0开始。为了满足后面某些指向常量池的索引值的数据,在特定情况下需要表达不引用任何一个常量池项目,这种情况可用索引值0来表示。
以1~constant_pool_count-1 为索引,表明存在多少个常量池表项。表项中存放编译时期生成的各种字面量和符号引用~这部分内容在类加载后进入方法区的运行常量池。
-
字面量:基本数据类型、字符串类型常量、字段的名称和描述符、方法的名称和描述符
-
符号引用:类和接口的全限定名、字段的名称和描述符、方法的名称和描述符…
- 全限定名:com/test/Demo 这个就是类的全限定名,仅仅是把包名的
.替换成/,为了使得连续的多个全限定名之间不产生混淆,在使用时最后一般会加入一个;表示全限定名结束; - 简单名称: 指没有类型和参数修饰的方法或者字段名称
- 描述符:用来描述字段的数据类型、方法的参数列表(包括数量、类型以及顺序)和返回值;
- 全限定名:com/test/Demo 这个就是类的全限定名,仅仅是把包名的
-
18种常量没有出现
byte\short\char\boolean的原因:编译之后都可以理解为Integer;
2.4 访问标识
访问标识,又称为访问标志、访问标记,该标记用两个字节标识,用于识别一些类或者接口层次的访问信息,包括这个Class是类还是接口,是否定义为public类型,是否定义为abstract类型等等;
- 类的访问权限通常为:
ACC_ 开头的常量; - 每一种类型的标识都是通过设置访问标记的32位中的特定位来实现,比如是 public final 的类,组标记为
ACC_PUBLIC | ACC_FINAL; - 使用
ACC_SUPER可以让类更准确地定位到父类的方法,确定类或接口中的invokespecial指令使用的是哪一种执行语义,现代编译期都会设置并且使用这个标记;
| 标志名称 | 标志值 | 含义 |
|---|---|---|
| ACC_PUBLIC | 0x0001 | 标志为 public 类型 |
| ACC_FINAL | 0x0010 | 标志被声明为 final,只有类可以设置 |
| ACC_SUPER | 0x0020 | 标志允许使用 invokespecial 字节码指令的新语义,JDK1.0.2之后编译出来的类的这个标志默认为真,使用增强的方法调用父类方法 |
| ACC_INTERFACE | 0x0200 | 标志这是一个接口 |
| ACC_ABSTRACT | 0x0400 | 是否为 abstract 类型,对于接口或者抽象类来说,次标志值为真,其他类型为假 |
| ACC_SYNTHETIC | 0x1000 | 标志此类并非由用户代码产生(由编译器产生的类,没有源码对应) |
| ACC_ANNOTATION | 0x2000 | 标志这是一个注解 |
| ACC_ENUM | 0x4000 | 标志这是一个枚举 |
2.5 索引集合
类索引、父类索引、接口索引集合
- 类索引:用于确定这个类的全限定名。
- 父类索引:Java语言不允许多重继承,所以父类索引只有一个,除了Object之外,所有的Java类都有父类,因此除了
java.lang.Object外,所有Java类的父类索引都不为0; - 接口索引:集合就用来描述这个类实现了哪些接口。
interfaces_count项的值标识当前类或接口的直接超链接数量;interfaces[]接口索引集合,被实现的接口将按implements语句后的接口顺序从左到右排列在接口索引集合中;
| 长度 | 含义 |
|---|---|
| u2 | this_class |
| u2 | super_class |
| u2 | interfaces_count |
| u2 | interfaces[interfaces_count] |
2.6 字段表
字段 fields 用于描述接口或类中声明的变量,包括类变量以及实例变量,但不包括方法内部、代码块内部声明的局部变量以及从父类或父接口继承。字段叫什么名字、被定义为什么数据类型,都是无法固定的,只能引用常量池中的常量来描述
fields_count(字段计数器),表示当前 class 文件 fields 表的成员个数,用两个字节来表示
fields[ ](字段表):
- 表中的每个成员都是一个 fields_info 结构的数据项,用于表示当前类或接口中某个字段的完整描述
- 字段访问标识
- 字段名索引:根据该值查询常量池中的指定索引项即可
- 描述符索引:用来描述字段的数据类型、方法的参数列表和返回值
ConstantValue_attribute{
u2 attribute_name_index;
u4 attribute_length;
u2 constantvalue_index;
}
2.7 方法表
方法表是 methods 指向常量池索引集合,其中每一个 method_info 项都对应着一个类或者接口中的方法信息,完整描述了每个方法的签名
- 如果这个方法不是抽象的或者不是 native 的,字节码中就会体现出来
- methods 表只描述当前类或接口中声明的方法,不包括从父类或父接口继承的方法
- methods 表可能会出现由编译器自动添加的方法,比如初始化方法
和实例化方法
**重载(Overload)**一个方法,除了要与原方法具有相同的简单名称之外,还要求必须拥有一个与原方法不同的特征签名,特征签名就是一个方法中各个参数在常量池中的字段符号引用的集合,因为返回值不会包含在特征签名之中,因此 Java 语言里无法仅仅依靠返回值的不同来对一个已有方法进行重载。但在 Class 文件格式中,特征签名的范围更大一些,只要描述符不是完全一致的两个方法就可以共存
methods_count(方法计数器):表示 class 文件 methods 表的成员个数,使用两个字节来表示
methods[ ](方法表):每个表项都是一个 method_info 结构,表示当前类或接口中某个方法的完整描述
2.8 属性表
属性表集合,指的是 Class 文件所携带的辅助信息,比如该 Class 文件的源文件的名称,以及任何带有 RetentionPolicy.CLASS 或者 RetentionPolicy.RUNTIME 的注解,这类信息通常被用于 Java 虚拟机的验证和运行,以及 Java 程序的调试。字段表、方法表都可以有自己的属性表,用于描述某些场景专有的信息
attributes_ count(属性计数器):表示当前文件属性表的成员个数
attributes[](属性表):属性表的每个项的值必须是 attribute_info 结构
-
属性的通用格式:
ConstantValue_attribute{ u2 attribute_name_index; //属性名索引 u4 attribute_length; //属性长度 u2 attribute_info; //属性表 } -
属性类型:
属性名称 使用位置 含义 Code 方法表 Java 代码编译成的字节码指令 ConstantValue 字段表 final 关键字定义的常量池 Deprecated 类、方法、字段表 被声明为 deprecated 的方法和字段 Exceptions 方法表 方法抛出的异常 EnclosingMethod 类文件 仅当一个类为局部类或者匿名类是才能拥有这个属性,这个属性用于标识这个类所在的外围方法 InnerClass 类文件 内部类列表 LineNumberTable Code 属性 Java 源码的行号与字节码指令的对应关系 LocalVariableTable Code 属性 方法的局部变量描述 StackMapTable Code 属性 JDK1.6 中新增的属性,供新的类型检查检验器检查和处理目标方法的局部变量和操作数有所需要的类是否匹配 Signature 类,方法表,字段表 用于支持泛型情况下的方法签名 SourceFile 类文件 记录源文件名称 SourceDebugExtension 类文件 用于存储额外的调试信息 Syothetic 类,方法表,字段表 标志方法或字段为编泽器自动生成的 LocalVariableTypeTable 类 使用特征签名代替描述符,是为了引入泛型语法之后能描述泛型参数化类型而添加 RuntimeVisibleAnnotations 类,方法表,字段表 为动态注解提供支持 RuntimelnvisibleAnnotations 类,方法表,字段表 用于指明哪些注解是运行时不可见的 RuntimeVisibleParameterAnnotation 方法表 作用与 RuntimeVisibleAnnotations 属性类似,只不过作用对象为方法 RuntirmelnvisibleParameterAnniotation 方法表 作用与 RuntimelnvisibleAnnotations 属性类似,作用对象哪个为方法参数 AnnotationDefauit 方法表 用于记录注解类元素的默认值 BootstrapMethods 类文件 用于保存 invokeddynanic 指令引用的引导方式限定符
3. 编译指令
3.1 javac
javac:编译命令,将 java 源文件编译成 class 字节码文件;
javac xx.java 不会在生成对应的局部变量表等信息,使用 javac -g xx.java 可以生成所有相关信息;
3.2 javap
javap 反编译生成的字节码文件,根据 class 字节码文件,反解析出当前类对应的 code 区 (字节码指令)、局部变量表、异常表和代码行偏移量映射表、常量池等信息;
1. 双亲委派机制的原理与实践
双亲委派机制是类加载的核心之一,需要重点掌握
概述:
- 类加载是Java虚拟机中的一种机制,用于将类的二进制数据读入内存并解析成Class对象。其中,每个类都必须由某个类加载器加载到内存当中去。Java的类加载器是通过双亲委派模型实现的,这种模型可以保证同一个类只会被同一个类加载器加载。
- 核心:双亲指派的是类加载器的执行顺序,当收到类加载请求的时候,先将请求委托给父类加载器进行执行,依次递归,直到父类加载器无法去完成请求时,才会子加载器尝试加载解析。
流程:
1)当一个类加载器接收到类加载请求时,首先检查该请求是否合法。
2)如果该请求不合法,则直接拒绝。
3)如果该请求合法,则判断该请求是否需要使用引导类加载器。如果需要使用引导类加载器,则将该请求委托给引导类加载器去完成。
4)如果不需要使用引导类加载器,则将该请求委托给父类加载器去完成。如果父类加载器无法完成该加载任务,则子类加载器才会尝试自己去加载。
5)后续如果父类加载器可以完成任务,则会成功返回。否则交给子类加载器尝试去加载。
工作原理:
- 层次结构
- Java类加载器包含三层次:启动类加载器、扩展类加载器和应用程序加载器。这些加载器如上图简示,构成树状结构。
- 加载类请求
- 当程序需要加载一个类的时候,会先判断检查是否已经加载过,如果加载过,直接返回该类的引用即可。
- 如果没有加载过,将加载的请求委派给其父类加载器。
- 委派给父类加载器
- 同上进行加载、判断是否加载、是否返回引用
- 如果并未加载过,则会委派给父类自己的加载器,一直持续到启动类加载器为止
- 启动类加载器
- 启动类加载器位于类加载器的根部,通常由JVM进行提供实现,负责加载类加载器中的核心部分(比如
java.lang包中的核心类) - 启动类加载器也无法加载,直接抛出
ClassNotFoundException,提示类找不到
- 启动类加载器位于类加载器的根部,通常由JVM进行提供实现,负责加载类加载器中的核心部分(比如
- 加载成功与否
- 父类成功的话。,将返回该类的引用给子加载器,一直加载到过程结束。
- 如果所有父加载器都无法加载该类,当前类加载器会尝试自己加载类。如果加载成功,它会将类引用返回给请求者,加载过程结束。
- 如果加载失败,当前类加载器会抛出
ClassNotFoundException,指示类找不到。
简单总结:
- 通过这种双亲委派模型,类加载器可以确保类的唯一性和安全性。核心类库由启动类加载器加载,避免了用户代码替换核心类的风险。同时,类加载器还可以自定义,以满足应用程序的需求,这有助于实现插件化和动态加载类的功能。双亲委派模型是Java类加载机制的一个关键概念,有助于保障Java程序的稳定性和安全性。
优势:
- 类的唯一性和一致性
- 双亲委派模型确保了类的唯一性,因为一个类只会被加载一次。如果一个类已经被加载,即使在不同的类加载器命名空间中也不会再次加载,避免了类的多重定义问题。
- 这有助于维持类加载的一致性,确保在不同的类加载器层次结构中使用的类是同一个版本,避免了类冲突。
- 安全性
- 核心类库是由启动类加载器加载,这些类库的完整性受到更为严格的控制。
- 用户自定义类不容易替代核心类库,或者说用户自定义类库不会在启动类中提前加载。
- 避免重复加载
- 避免加载相同的类,类被加载后会被缓存,之后的加载请求直接返回缓存的类引用,提高性能并节省内存。
- 层次结构
- 双亲委派模型建立了一个层次结构,包括启动类加载器、扩展类加载器和应用程序类加载器。这种层次结构使类加载器的管理更加清晰,有助于隔离不同的类。
- 动态加载和模块化
- 允许开发人员创建自定义类加载器,这有助于实现动态加载类和模块化的应用程序设计。
- 插件系统和动态模块化系统可以受益于这种灵活性,可以在运行时加载新的类和模块,而不需要重新启动应用程序。
实际应用:
- 安全性和防止类冲突:通过双亲委派机制,Java确保核心类库只由启动类加载器加载,从而提高了系统的安全性。这样可以防止用户自定义的类意外地替代核心类库。
- 模块化和插件系统:双亲委派模型使得创建模块化和插件化系统更容易。每个模块或插件可以有自己的类加载器,以避免类名冲突,同时还能够访问系统类和其他模块的类。
- 动态加载和热部署:双亲委派模型使得动态加载和热部署变得更加可行。自定义类加载器可以加载新的类或更新现有的类,而不必重新启动整个应用程序。