JVM
教程:https://www.bilibili.com/video/BV1PJ411n7xZ
1. JVM 整体架构
-
java 文件先编译为 class 文件,然后通过类加载器子系统进行加载,连接,初始化。
-
当所需的类加载进来放在内存(运行时数据区),结构如下,其中有:
- 线程共享的方法区,堆内存
- 线程私有的虚拟机栈,本地方法栈,程序计数器
-
执行引擎
- 解释器
- 即时编译器
- 分析器
- 垃圾回收器
2. 类加载器
2.1 类加载器子系统作用
- 负责从文件系统或者网络中加载 class 文件
- ClassLoader 只负责文件加载,文件是否运行由执行引擎决定
- 加载的类信息放在方法区,方法区存放运行时常量池(存放编译期的字符串字面量和数字常量,以及运行时的常量,如
String
类的intern()
方法),还存放已被加载的类的信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
一个在 class 中的类 → \to → ClassLoader → \to → 元数据模板
2.2 类加载过程
2.2.1 加载
- 通过一个类的全类名获取该类的二进制字节流
- 将这个字节流的静态存储结构转化为方法区的运行时数据结构
- 在内存中生成一个代表该类的
java.lang.Class
对象,作为方法区这个类的各种数据的访问入口
加载 class 文件的方式:
- 本地文件系统
- 网络获取,Web Applet
- 压缩包读取,Jar,War
- 运行时计算生成,动态代理技术
- JSP
- 从加密文件中获取,防止 class 文件被反编译的保护措施
类加载时机:
- new 对象,创建类的实例
- 访问某个类或者接口的静态变量,或者对该静态变量赋值(除了被 final 修饰的变量)
- 调用类的静态方法
- 反射(如
Class.forName("com.mysql.cj.jdbc.Driver")
) - 初始化一个类的子类
- 系统启动执行的带有 main 方法的类
- 一个接口使用 JDK8 中的
default
关键字修饰的接口方法时,当这个接口的实现类被初始化,该接口需要在此之前被加载
2.2.2 链接
- 验证,确保 class 文件的字节流中包含的信息符合当前虚拟机的要求,保证加载类的正确性
- 准备,将类变量,即
static
修饰的变量初始化为 0,null,false。若是static final
修饰,则编译的时候就已经分配值了 - 解析,将常量池中的符号引用转换为直接引用
Note:
我对准备阶段的理解:应该类似之前学习 C++ 的时候,new 的对象因为是内存中直接分配的,所以该对象的值可能是随机的,一般都初始化为 0,false 或者 NULL。
2.2.3 初始化
- 执行类构造器方法
<clinit>()
,该方法不需要定义,由 javac 编译器收集所有类变量的赋值动作和静态代码块的语句合并而来。 - 构造器方法中的指令按照语句在源文件中出现的顺序执行。若代码块在一个变量定义之前,则代码块中可以对变量赋值,但是不能访问。
-
<clinit>()
和类的构造方法不同,类的构造方法为<init>()
- 若该类有父类,则在子类
<clinit>()
之前先执行父类的<clinit>()
- JVM 必须保证
<clinit>()
方法在多线程下被同步加锁
Note:
补充一个小基础知识,被 final 修饰的变量必须在定义的时候就赋值吗?
答案:不是,在构造方法中赋值也可以。
static { } 静态代码和 static 修饰的变量只会执行一次。
2.3 类加载器分类
从 JVM 的角度来看,只有两种类加载器,一种是 Bootstrap 类加载器,一种就是其他类加载器。
- Bootstrap 类加载器由 C++ 实现,是 JVM 的一部分,
- 而其它类加载器由 Java 实现,全部继承于抽象类
java.lang.ClassLoader
。
// 获取系统类加载器
ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
System.out.println(systemClassLoader);
// 获取上层, 扩展类加载器
ClassLoader extClassLoader = systemClassLoader.getParent();
System.out.println(extClassLoader);
// 试图获取引导启动类加载器 null
ClassLoader bootstrapClassLoader = extClassLoader.getParent();
System.out.println(bootstrapClassLoader);
// 获取当前类的类加载器
ClassLoader classLoader = InitTest.class.getClassLoader();
System.out.println(classLoader);
// 加载字符串类型的类加载器, String 也是引导启动类加载器加载的
ClassLoader classLoader2 = String.class.getClassLoader();
System.out.println(classLoader2);
2.3.1 Bootstrap 类加载器
- 加载 Java 的核心库:
JAVA_HOME/jre/lib/rt.jar、resource.jar
或者sun.boot.class.path
路径下的内容,用于提供 JVM 自身需要的类 - 用于加载扩展类加载器
- 出去安全考虑,只加载包名为
java
、javax
、sun
等开头的类
URL[] urls = Launcher.getBootstrapClassPath().getURLs();
for (URL url : urls) {
System.out.println(url);
}
2.3.2 Extension 类加载器
- 派生于 ClassLoader 类
- 上层加载器为 Bootstrap 类加载器
- 加载扩展目录中的内容:
JAVA_HOMEjre/lib/ext
或者java.ext.dirs
系统变量所指定的路径种的所有类库 - 允许用户创建的的 jar 包放在此目录下面,会自动加载
String exts = System.getProperty("java.ext.dirs");
for (String ext : exts.split(";")) {
System.out.println(ext);
}
2.3.3 System 类加载器 AppClassLoader
- 派生于 ClassLoader 类
- 上层加载器为 Extension 类加载器
- 负责加载环境变量
classpath
或者系统属性java.class.path
指定路径下的类库 - 程序默认的类加载器
2.3.4 自定义类加载器
为什么需要自定义类加载器:
- 隔离加载类
- 修改类加载的方式
- 扩展加载源
- 防止源码泄露
自定义类加载器实现步骤:
- 继承 ClassLoader 重写 findClass() 方法
- 若没有复杂需求可以直接继承 URLClassLoader 类,这样可以避免自己去编写 findClass() 方法及其获取字节码流的方式,使自定义类加载器编写更加简洁。
2.4 获取 ClassLoader 的途径
// 1. class
System.out.println(Class.forName("java.lang.String").getClassLoader());
System.out.println(InitTest.class.getClassLoader());
// 2.
System.out.println(Thread.currentThread().getContextClassLoader());
// 3.
System.out.println(ClassLoader.getSystemClassLoader());
2.5 双亲委派模型
2.5.1 引子
假如我创建一个 String 相同的包和相同的类,然后执行 main() 方法会发生什么?
package java.lang;
public class String {
public static void main(String[] args) {
System.out.println("hello world");
}
}
换个类名行不行?
package java.lang;
public class CCC {
public static void main(String[] args) {
System.out.println("hello world");
}
}
2.5.2 工作原理
- 一个类加载器收到类加载请求,他不会自己先去加载,而是把这个请求交给上层去执行,如上层依然有更上层的类加载器,则进一步向上委托,直到到达顶部的 Bootstrap 类加载器。
- 若上层加载器能完成加载就成功返回,若不能则由下层加载器尝试去加载。
因此可以解释,自定义的 String 类已经被上层加载器加载过了,因此该类中找不到 main 方法。第二个错误原因是该包下被 Bootstrap 加载器加载过了,所以下层加载器不能再对其加载,这也是 Java 沙箱安全机制中对于恶意代码所采取的防护措施,防止核心 API 被篡改。
Note:
JVM 两个 class 对象是一个类的必要条件:
- 完整类名相同
- 加载这个类的类加载器必须相同
3. 运行时数据区
3.1 内存结构
红色为线程共享的,灰色是线程私有的。方法区又叫元空间(永久代)。
空间 | 异常 | 垃圾回收 |
---|---|---|
程序计数器 | × | × |
本地方法栈 | √ | × |
虚拟机栈 | √ | × |
堆 | √ | √ |
方法区 | √ | √ |
3.2 线程
一个 JVM 允许有多个线程并行执行。
Hotspot JVM 中,每个线程都与操作系统的本地线程直接映射。
3.3 程序计数器(PC寄存器)
用来存储下一条指令的地址,就是说记录当前线程运行到哪里。
如果执行的是本地方法,指定的地址为未指定值(undefined)。
唯一没有规定 OutOfMemoryError
且不需要 GC 的区域。
Note:
使用javap -v class
路径 可以进行反汇编
面试题
-
PC 寄存器为什么要记录当前线程的执行地址呢?
- 因为在多线程并发执行时,CPU 会切换线程执行,因此切换线程的时候,需要直到当前线程运行到哪里了。
-
PC 寄存器为什么设置为线程私有?
- 为了准确记录各个线程正在执行的当前字节码指令地址,最好的办法就是每个线程分配一个 PC 寄存器
3.4 虚拟机栈
3.4.1 定义
- 每个线程创建时都会创建一个虚拟机栈,内部保存一个个的栈帧(Stack Frame),对应一次次的 Java 方法调用。
- 是线程私有的。
- 栈中不存在垃圾回收问题
主要作用:
- 主管 Java 程序的运行,保存方法的局部变量,部分结果,并参与方法调用和返回
异常:
- 线程请求的栈的容量大于虚拟机所允许的容量,抛出
*Error
异常 - 若虚拟机栈可以动态扩展,当在尝试扩展时无法申请足够的内存,或者创建新线程没有足够的内存去创建对应的虚拟机栈,抛出
OutOfMemoryError
异常
3.4.2 虚拟机栈出现的背景
为了跨平台的特性,Java 的指令都是根据栈来设计的。
因为不同平台的 CPU 架构不同,所以不能基于寄存器实现。
虚拟机栈好处:
- 跨平台
- 指令集小(多地址指令集还需要存放参数)
- 编译器容易实现(不需要考虑空间分配的问题,所需空间都在栈上操作)
虚拟机栈缺点:
- 性能相对于寄存器更低(频繁的栈操作,内存成为瓶颈)
- 实现相同的功能需要更多的指令
使用 1+1
的操作例子:
- 虚拟机栈,将两个常量压入栈,然后将两个值出栈相加放到栈顶,再存入第 0 个 slot:
iconst_1
iconst_1
iadd
istore_0
- 寄存器 mov 把 eax 寄存器设置为 1,add 指令将该值加上 1,结果保存在 eax 寄存器中:
mov eax, 1
add eax, 1
Note:
参考:https://blog.csdn.net/shockang/article/details/116676873
3.4.3 内存中的栈和堆
- 栈是运行时的单位
- 堆是存储的单位
栈解决程序的运行问题,程序如何执行,如何处理数据。
堆解决数据存储问题,数据怎么放,放在哪里。(堆主要负责的是对象存储,基本数据类型和引用类型的局部变量放在栈中)
3.4.4 设置栈内存大小
Note:
参数设置参考:https://docs.oracle.com/en/java/javase/11/tools/java.html
Windows 默认是虚拟内存大小,Linux 默认是 1024 KB
使用 -Xss
选项设置线程的最大栈空间,栈的大小直接决定了函数调用的最大深度。
Idea 可以通过以下操作设置参数:
/**
* count 值:
* 默认情况下: 11232
* 设置 -Xss256K: 2314
*
* */
private static int count = 1;
public static void main(String[] args) {
System.out.println(count);
count++;
main(args);
}
3.4.5 栈帧:栈的存储单位
- 每个线程有自己的栈,栈中的数据都是以栈帧的格式存在。
- 在此线程正在执行的每个方法都各自对应一个栈帧
JVM 对栈的的操作就只有压栈和出栈,在活动线程中,一个时间点上,只有一个活动的栈帧。即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的。若在当前方法中调用其他方法,则对应的新的栈帧会被创建放在栈顶,成为新的当前栈帧。
不同线程所包含的栈帧不允许相互引用。
Java 方法有两种返回函数的方式,会导致栈帧弹出:
- 正常函数返回,
return
- 抛出异常
3.4.6 栈帧的内部结构
每个栈帧包含:
- 局部变量表
- 操作数栈
- 动态链接
- 方法返回地址
- 一些附加信息
3.4.6.1 ★局部变量表
- 存储编译期间可知的各种基本数据类型,对象引用类型以及返回值地址类型
returnAddress
(一条指向字节码指令的地址)。局部变量表所需容量在编译期间已经确定下来,运行期间不会改变。 - 局部变量表中的变量是对象垃圾回收的根节点,只要被局部变量表中直接或者间接引用的对象都不会被回收。
- 重点性能调优最密切的部分
局部变量表最基本的存储单位:Slot
局部变量槽
- 其中每一个 Slot 占 32 bit,只有
double
和long
占两个 Slot,其余类型均占一个 Slot -
char
,byte
,short
,boolean
在存储之前都被转换为int
通过 javap -v
命令反汇编可以看到 double
和 long
的槽数为 2
:
public static void main(String[] args) {
int a = 10;
int b = 20;
double c = 0D;
long d = 0L;
boolean e = false;
}
其中 Start
为字节码指令起始位置,Length
表示作用范围。
如果当前的栈帧由构造方法或者实例方法创建,那么对象引用 this
会存在索引为 0 的 Slot 处:
栈帧中的 Slot 是可以重复利用的,可以看到变量 c
使用的是 b
之前使用的 Slot:
public void xxx () {
int a = 10;
{
int b = 10;
b += a;
}
int c = 10;
}
Java 局部变量在使用之前必须进行初始化,不像 C 中可以不赋值:
3.4.6.2 ★操作数栈
- 在方法执行过程中,根据字节码指令,向栈中写入数据或者提取数据,即入栈和出栈
- 主要用于保存计算过程中的中间结果,同时作为计算过程中变量临时的存储空间(类似寄存器?)
- 操作数栈是 JVM 执行引擎的工作区,一个方法刚开始执行时操作数栈是空的
- 若被调用的方法具有返回值,其返回值也会被压入当前栈帧的操作数栈中
3.4.6.3 栈顶缓存技术
又因操作数是存储在内存,每次操作都需要进行入栈出栈操作,必然会影响执行速度。
为了解决这个问题,HotSpot JVM 提出了栈顶缓存:
- 将栈顶元素全部缓存到物理 CPU 的寄存器中,以此来降低对内存的读写次数,提升执行引擎的执行效率。
3.4.6.4 ★动态链接
Java 文件编译成字节码文件时,所有的变量和方法引用都作为符号链接保存在 class 文件的常量池中。
- 每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用
- 动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用
3.4.6.5 方法的调用
静态链接:被调用的方法在编译时可知,且运行期间不变
动态链接:被调用的方法在编译期间无法被确定下来,运行期间才能确定
动态链接例子:多态,接口的实现
Note:
Java 中默认除了 invokestatic 和 invokespecial 指令调用的方法(除了final
修饰的)都是虚方法。
Java 是静态类型的语言,因为定义变量的时候需要指定类型,而动态类型的语言(JS,Python)是只有变量值有类型信息
invokedynamic 指令使用 lambda 表达式可以直接生成
虚方法表用于快速寻找子类未实现的方法
3.4.6.6 ★方法返回地址
存放该方法的 PC 寄存器(程序计数器) 的值。
若异常退出,返回地址需要通过异常表来确定。
Note:
静态代码块也算是一个没有返回值的类构造器
3.4.6.7 附加信息
栈帧中允许携带与 JVM 实现相关的一些附加信息,例如对程序调试提供支持的信息。
面试题
-
栈溢出的情况?
- Linux 默认 1M,可以通过
-Xss<size>
设置,当超出该容量会导致*Error
,若整个内存空间不足会导致OutOfMemoryError
- Linux 默认 1M,可以通过
-
调整栈的大小,就能保证不出现溢出吗?
- 不能,若递归一直不终止那么仍然会出现溢出情况。
-
分配的栈内存越大越好吗?
- 不是,如果发生问题,问题发生的时间会延迟。
- 而且整个物理内存空间是有限的,当栈空间设置过大,其他的空间就会变小。
-
垃圾回收是否会涉及到虚拟机栈?
- 不会,代码块执行结束,局部变量直接出栈
-
方法中定义的局部变量是否线程安全?
- 具体问题具体分析
- 若一个局部变量内部产生内部消亡,一般是线程安全
- 若一个局部变量不是内部产生或者内部产生返回给外面,一般是线程不安全的
3.5 本地方法栈
本地方法(Native Method)是一个 Java 调用非 Java 代码的接口。
本地方法由 native
修饰。
为什么用本地方法?
- 需要与 Java 环*的环境交互,若想要操作底层,只凭 Java 代码做不到
虚拟机栈是用于管理 Java 方法的调用,本地方法栈负责本地方法的调用,一样具有两种异常。
- 当线程调用一个本地方法,该方法进入了一个全新且不受 JVM 限制的空间,和虚拟机具有相同的权限
- HotSpot JVM 直接将本地方法栈和虚拟机栈合二为一了
3.6 堆
3.6.1 堆的核心概念
- 堆是 JVM 管理的内存中最大的一块
- 线程共享
- 《JVM 规范中》规定:
- 物理上可以不连续,但是逻辑上必须连续
- 所有的对象和数组都应该在堆上分配
- 对于大对象,多数虚拟机为了实现简单,存储高效的目的,很可能要求连续的空间
- ★从分配内存角度,线程共享的堆空间还可以划分线程私有的分配缓冲区(TLAB)
- 方法结束后,堆中的对象不会马上释放,需要垃圾收集的时候才能被移除
Visual VM
输入命令:jvisualvm
打开
安装插件 Visual GC 插件参考:https://blog.csdn.net/jushisi/article/details/109655175
3.6.2 堆的内存结构
现代 GC 大部分都是基于分代收集理论设计的,堆空间细分为:
Java 8 以后堆内存逻辑分为:
- 新生代 + 老年代 + 元空间(实际放在方法区)
新生代进一步划分:
- Eden 空间
- From Survivor 空间
- To Survivor 空间(S0 和 S1,谁空谁是 To)
-XX:+PrintGCDetailes
可以查看堆空间的细节,Java17 使用 -Xlog:gc*
选项。
或者 jps
查看 java 进程 id,然后使用 jstat -gc <pid>
查看。
3.6.3 设置堆空间大小
-
Xms<size>
:表示堆区的初始内存(年轻代+老年代),等价于-XX:InitialHeapSize
,默认物理内存/64
-
Xmx<size>
:堆区的最大内存(年轻代+老年代),等价于-XX:MaxHeapSize
,默认物理内存/4
查看内存:
Runtime runtime = Runtime.getRuntime();
longinit = runtime.totalMemory() / 1024 / 1024;
long max = runtime.maxMemory() / 1024 / 1024;
log.debug("Xms: {}", init);
log.debug("Xmx: {}", max);
通常会将初始内存和最大内存设置相同的值,目的是为了能够在 java 垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小,从而提高性能。
当堆区的内存超过最大内存所指定的值将会抛出 OutOfMemoryError
:
public class InitTest {
public static void main(String[] args) throws InterruptedException {
List list = new ArrayList();
while (true) {
list.add(new byte[1024 * 1024]);
}
}
}
3.6.4 新生代和老年代
设置新生代和老年代在堆结构的占比:
-
-XX:NewRatio=4
表示新生代占 1,老年代占 4
Eden 和两个 Survivor 空间默认比例为:8:1:1
(实际上并不一定是该比例,是自适应的):
-
-XX:SurvivorRatio=8
手动设置8:1:1
几乎所有的对象都在 Eden 区域被 new 出来。
绝大部分的对象的销毁都在新生代中进行。
-
-Xmn<size>
设置新生代最大内存,一般默认即可
3.6.5 新生代对象分配和回收过程
首先创建对象在 Eden 区域,当 Eden 区域满时,会进行垃圾回收 YGC/MinorGC(垃圾回收时会将S0,S1 一起回收)。Eden 中还在使用的放入 From Survivor 区,并将对象的 age++(初始为 1)。
一段时间后,若 Eden 区域又满,Eden 中还在使用的对象以及 From Survivor 中的对象一起放入 To Survivor 区,将对象的 age++。
重复此过程,当对象的 age 达到 15 (默认)时会放入老年代。
-
-XX:MaxTenuringThreshold=<N>
进行设置放入老年代的 age 阈值
Note:
jinfo -flags <pid>
查看虚拟机设置参数
总结和问题
总结:
- 针对 S0,S1 区域,复制之后谁空谁是 To Survivor
- 关于垃圾回收,频繁在新生代,很少在老年代,几乎不在元空间
问题:
-
什么时候 Eden 区域的对象会直接到老年代?
- YGC 的时候,Eden 中的对象 S0,S1 都放不下,那么直接放入老年代
- 超大对象,Eden 放不下,老年代能放下,直接放入老年代
- 如果 S0,S1 满了,即使 age 没到 15,也会放入老年代
3.6.6 Major GC,Major GC,Mixed GC,Full GC
- Minor GC/Young GC:只针对新生代(Eden,S0,S1)的垃圾回收
- Major GC/Old GC:针对老年代的垃圾回收(只有 CMS 收集器有单独收集老年代)
- Mixed GC:针对整个新生代和部分老年代的垃圾回收(只有 G1 收集器有该行为)
- Full GC:针对整个堆和方法区的垃圾回收
Minor GC 触发机制:
- Eden 区域满,S0,S1 区域满不会触发(会直接尝试放入老年代)
- Minor GC 会导致 STW,暂停其他用户线程,等垃圾回收结束,用户线程继续执行,大部分对象是很快死亡的,因此 Major GC 很频繁,回收速度较快
Major GC 触发机制:
- 老年代空间不足,会先尝试触发 Minor GC,若空间还不足,则触发 Major GC
- Major GC 速度比 Minor GC 慢 10 倍以上,STW 时间更长
- 若 Major GC 之后还是内存不足,就会报
OOM
Full GC 触发机制:
- 调用
System.gc()
,系统会建议执行 Full GC,不是一定执行 - 老年代空间不足
- 方法区/元数据空间不足
- 通过 Minor GC 后进入老年代的平均大小大于老年代的可用内存
- 开发和调优中要尽量避免
3.6.7 为什么要分代?
不分代也可以,分代是为了优化 GC 的性能,如果没有分代,所有的对象都在一起,每次 GC 都要在全部对象中找已经不再使用的对象,效率太低。
3.6.8 内存分配策略
- 优先分配到 Eden
- 大对象直接分配到老年代(尽量避免出现过多大对象)
- 长期存活的对象放进老年代
- 动态对象年龄判断:S 区相同年龄的所有对象大小的总和 > S 区的一半,年龄 >= 该年龄的对象可以直接进入老年代(G1 收集器中单独设置了一个 Humongous 区域存放大对象)
3.6.9 TLAB 线程私有的分配缓冲区
由于堆内存是线程共享的,并发状态下从堆中划分内存空间是线程不安全的,为了避免多个线程操作同一个地址,需要使用加锁等机制,影响性能。
JVM 给每一个线程分配了一个私有缓冲区,包含在 Eden 空间中。
- TLAB 空间只占 Eden 总空间的
1%
-
-XX:TLABWasteTargetPercent
可以设置 TLAB 所占 Eden 空间的百分比 -
jinfo -flag UseTLAB <pid>
可以查看进程是否使用 TLAB(默认开启)
多个线程同时分配内存时,使用 TLAB 可以避免非线程安全的问题,同时提升内存分配的效率,因此我们将这种内存分配方式称为快速分配策略。
对象分配过程:TLAB
3.6.10 常用参数
-
-XX:+PrintFlagsInitial
查看 JVM 所有参数的默认值 -
-XX:+PrintFlagsFinal
查看 JVM 所有参数可能进行修改后的最终值 -
jinfo -flag 参数 <pid>
可以查看进程的某个参数的值 -
-XX:PrintGCDetails
垃圾回收详细情况(Java 8) -
-Xlog:gc*
垃圾回收详细情况(Java 17)