0 为什么要学JVM?
- ⾸先:⾯试需要。⾯试题层出不穷,难道每次⾯试都靠背⼏百上千条⾯试⼋股?
- 其次:基础决定上层建筑。⾃⼰写的代码都不知道是怎么回事,怎么可能写出靠谱的系统?
- 然后:学习JVM也是进⾏JVM调优的基础。写的代码放到线上要如何运⾏?要配多少内存?4G够不够?线上环境出问题,服务崩溃了,怎么快速定位?怎么解决问题?
- 总之,学不学JVM,是能⾃主解决问题的⼀流程序员与跟着别⼈做CRUD的⼆流程序员的分⽔岭!⼆流程序员会觉得学JVM⽆关紧要,反正开发也⽤不上。做开发我只要学各种框架就⾏了。⽽⼀流程序员都在尽⾃⼰能⼒把JVM每个底层逻辑整理成⾃⼰的知识体系。
1 JVM后面要学什么?
-
Java发展⾄今,已经远不是⼀种语⾔,⽽是⼀个标准。只要能够写出满⾜JVM规范的class⽂件,就可以丢到JVM虚拟机执⾏。通过JVM虚拟机,屏蔽了上层各种开发语⾔的差距,同时也屏蔽了下层各种操作系统的区别。⼀次编写,多次执⾏;
- JVM也有很多具体的实现版本,现在最主流的是Oracle官⽅的HotSpot虚拟机;
-
使用
java- verison
可以查看自己的Java JDK版本,其中就包含了 Java 虚拟机的相关信息:-
openjdk version "21.0.6" 2025-01-21 LTS
- OpenJDK 21.0.6:表示安装的是 OpenJDK 的 21.0.6 版本;
- LTS(Long-Term Support):这是一个长期支持版本,通常会获得更长时间的安全更新和维护(LTS 版本一般支持数年,而普通版本通常只有 6 个月的支持期);
- 2025-01-21:表示该版本的发布日期是 2025 年 1 月 21 日(可能是 Microsoft 构建的特定版本日期);
-
OpenJDK Runtime Environment Microsoft-10800203 (build 21.0.6+7-LTS)
-
OpenJDK Runtime Environment:这是 Java 运行时环境(JRE),用于运行 Java 程序;
-
Microsoft-10800203:表示这个 OpenJDK 是由 Microsoft 构建的(Microsoft 有自己的 OpenJDK 发行版);
-
build 21.0.6+7-LTS:具体的构建版本号,
21.0.6
是主版本,+7
可能是补丁或修订号;
-
-
OpenJDK 64-Bit Server VM Microsoft-10800203 (build 21.0.6+7-LTS, mixed mode, sharing)
-
64-Bit Server VM:表示这是一个 64 位的 Java 虚拟机(JVM),运行在“Server”模式下(相比“Client”模式,它针对长时间运行的服务器应用做了优化);
-
mixed mode:表示 JVM 同时使用解释执行和即时编译(JIT)来运行字节码;
-
sharing:表示这个 JVM 支持类数据共享(Class Data Sharing, CDS),可以加快 JVM 启动速度;
-
-
-
⼀个java⽂件,整体的执⾏过程整理如下图:
- 下图的“HotSport JVM”纠正为“HotSpot JVM”;
-
编译过程
-
***.java
:Java 源文件,是程序员编写的代码文件; -
javac
:Java编译器,将.java
源文件编译成classFile
(字节码文件) ,字节码是一种平台无关的中间代码形式,可在JVM上运行;
-
-
HotSpot JVM内部结构
-
线程私有区域
- 程序计数器:记录当前线程执行字节码的行号指示器,用于线程切换后能恢复到正确执行位置 ;
- 虚拟机栈:由多个栈帧组成;
- 栈帧:方法执行时的内存模型,包含局部变量表(存放方法参数和局部变量)、操作数栈(用于字节码运算时操作数的存储)、动态链接库(指向运行时常量池中该方法的符号引用)、返回地址(方法执行结束后返回的位置)和附加信息 ;
- 本地方法栈:与虚拟机栈类似,不过是为本地方法(用 native 关键字修饰的方法)服务的;
-
堆(Heap):是JVM中最大的一块内存区域,被所有线程共享,用于存放对象实例和数组,是垃圾回收的主要区域;
-
直接内存(DirectBuffer):不属于JVM堆内存,可通过
DirectByteBuffer
等方式直接分配和使用,常用于提高I/O操作性能; -
方法区(元数据区,Method Area):也是被所有线程共享的区域,存储已被虚拟机加载的类信息(类模板)、常量池、静态变量等数据 。其中常量池存放字面量和符号引用等;
-
本地方法库(Native Method):存放本地方法的实现,供Java代码调用本地(非Java语言编写,如C、C++ )代码时使用;
-
-
类加载机制(ClassLoader)
-
Loading(加载)
- BootstrapClassLoader:启动类加载器,是最顶层的类加载器,用C++实现,负责加载Java核心类库,如
java.lang
包下的类; - ExtClassLoader:扩展类加载器,负责加载Java的扩展类库,如
%JAVA_HOME%/jre/lib/ext
目录下的类; - AppClassLoader:应用程序类加载器,负责加载应用程序classpath路径下的类,是大多数自定义类的加载器;
- BootstrapClassLoader:启动类加载器,是最顶层的类加载器,用C++实现,负责加载Java核心类库,如
-
Linking(链接)
- Verifying:验证阶段,确保字节码文件符合JVM规范,保证安全性,如文件格式验证、语义验证等;
- Prepare:准备阶段,为类变量(静态变量)分配内存并设置初始值(默认值,如
0
、null
等); - Resolve:解析阶段,将常量池中的符号引用替换为直接引用。;
-
Initialization(初始化):对类变量赋予程序员定义的初始值,执行类构造器
<clinit>()
方法,该方法由编译器自动收集类中所有类变量的赋值动作和静态代码块中的语句合并产生;
-
-
执行引擎
-
解释器:将字节码文件逐行解释执行,边解释边执行,响应速度快但执行效率相对低;
-
JIT(即时编译器):运行时将热点代码(频繁执行的代码)编译成机器码,提高执行效率,适用于长时间运行的程序;
-
Eclipse编译器:可作为JVM执行引擎的一部分,用于特定场景下的代码编译优化等。
-
2 Class 文件规范
2.1 Class 文件结构
-
实际上,我们需要了解的是,Java 官⽅实际上只定义了JVM的⼀种执⾏规范,也就是class⽂件的组织规范。理论上,只要你能够写出⼀个符合标准的class⽂件,就可以丢到 JVM 中执⾏。⾄于这个class⽂件是怎么来的,JVM 虚拟机是不管的。这也是 JVM ⽀持多语⾔的基础;
-
这个规范到底是什么样⼦呢?当然,你可以直接去看 Oracle 的官⽅⽂档(JDK8 的⽂档地址:https://docs.oracle.com/javase/specs/jvms/se8/html/index.html),此处先暂时只讲解主体内容;
-
编写
ByteCodeInterView
类:package com.roy; import org.junit.Test; public class ByteCodeInterView { //包装类对象的缓存问题 @Test public void typeTest(){ Integer i1 = 10; Integer i2 = 10; System.out.println(i1 == i2);//true Integer i3 = 128; Integer i4 = 128; System.out.println(i3 == i4);//false Boolean b1 = true; Boolean b2 = true; System.out.println(b1 == b2);//true Double d1=1.00; Double d2=1.00; System.out.println(d1==d2);//false } public int mathTest(){ int k = 1 ; k = k++; return k; } public int inc(){ int x; try{ x=1; }catch (Exception e){ x = 2; }finally { x = 3; } return x; } }
- 执行后,会生成一个
target
文件夹,里面就包含了该工程下的所有class文件;
- 执行后,会生成一个
-
class ⽂件本质是⼀个⼆进制⽂件,虽然不能直接⽤⽂本的⽅式阅读,但是是可以⽤⼀些⽂本⼯具打开看看的。⽐如,对于⼀个
ByteCodeInterView.class
⽂件,可以⽤ UltraEdit ⼯具打开⼀个class⽂件,看到的内容部分是这样的:-
中间这⼀部分就是该class文件的⼆进制内容(当然这是⼗六进制的表达);
-
每⼀位代表的是 4 个 bit,也就是⼀个⼗六进制的数字;
- 例:第⼀个字⺟ C 就表示⼗六进制的 12,⼆进制是 1100;
-
所有的class⽂件:
-
其开头的前四组数,都是 magic,是⼗六进制的 CAFEBABE,这就是 JVM 规范的⼀部分;
- 这也解释了 Java 这个词的由来,到底是⼀种咖啡,还是⽖哇岛;
-
第五组和第六组数,是minor_version,表示小版本;
-
第七组和第八组数,是major_version,表示大版本;
- 例:十六进制的 34,换算成十进制就是52(3 × 16^1 + 4 × 16^0),这个 52 就是JDK8分配的版本号,即改文件是用JDK8编译后得到的;
- 这也解释了,只允许大版本号比该文件中规定的大版本号高的环境,才可以执行这个 class 文件;
- 例:Spring 6 和SpringBoot 3,这些框架是用JDK17编译的,小于17的Java环境,都无法使用这两个框架;
-
-
-
后⾯的部分就⽐较复杂了,没法直接看。此时可以借助一些工具,JDK ⾃⼰就提供了⼀个 javap 指令可以直接来看⼀些 class ⽂件。例如可以⽤
javap -v ByteCodeInterView.class
查看到这个class⽂件的详细信息; -
当然,可以在 IDEA ⾥添加⼀个
jclasslib Bytecode Viewer
插件来更直观地查看⼀个 ClassFile 的内容;-
jclasslib Bytecode Viewer
插件:
-
-
看到的⼤概内容是这样的:
-
可以看到,⼀个class⽂件的⼤致组成部分。然后再结合官⽅的⽂档:
-
前⾯u4表示四个字节是magic魔数,⽽这个魔数就是不讲道理的 CAFEBABE;
-
⽽后⾯的两个u2,表示两个字节的版本号;
-
前面使⽤ UltraEdit ⼯具查看class⽂件时,
minor_version
就是 00 00,major_version
就是 00 34; -
00 34换成⼆进制就是 52,52.0 这就是 JVM 给 JDK8 分配的版本号。这两个版本号就表示当前这个class⽂件是由JDK8编译出来的。后续就只能⽤8以后版本的JVM执⾏,这就是JDK版本向后兼容的基础;
-
如果尝试⽤JDK8去引⽤ Spring 6 或者 SpringBoot 3 以后的新版本,就会报错。就是因为 Spring 6 和 SpringBoot 3 发布出来的class⽂件,是⽤JDK17编译的,版本号是61。JDK8是⽆法执⾏的;
-
-
常量池是最复杂的部分,包含了表示这个class⽂件所需要的⼏乎所有常量。⽐如接⼝名字,⽅法名字等等;
- 在这⾥,有⼀个不太起眼的⼩细节,常量池中的索引结构是从 1 开始的,⽽不是像 Java 中其他地⽅⼀样,从 0 开始;
- 这样做的⽬的在于,如果后⾯某些指向常量池的索引值的数据在特定情况下需要表达“不引⽤任何⼀个常量池项⽬”的含义,就可以把索引值设定为 0 表示;
-
⽽后⾯的⼏个部分,⽐如⽅法,接⼝等都是引⽤常量池中的各种变量;
-
属性,是每个JVM虚拟机自己做定制的部分;
-
-
-
尽管 Java 发展了很多年,JDK 版本也不断更新,但是 Class ⽂件的结构、字节码指令的语义和数量⼏乎没有发⽣过变动,所有对 Class ⽂件格式的改进,都集中在⽅法标志、属性表这些设计上原本就是可扩展的数据结构中添加新内容。
2.2 理解字节码指令
-
⽽这其中,我们重点关注的是⽅法,也就是class⽂件是如何记录我们写的这些关键代码的。例如在
ByteCodeInterView
类中的typeTest
这个⽅法,在class⽂件中就是这样记录的:- 这⾥每⼀样就是⼀个字节码指令。 JVM 虚拟机的字节码指令由⼀个字节⻓度的,代表着某种特定操作含义的数字(称为操作码,OpCode)以及跟随其后的零⾄多个代表此操作所需要的参数(称为操作数,Operand)构成。其中操作数,可以是⼀个具体的参数,也可以是⼀个指向class⽂件常量池的符号引⽤,也可以是⼀个指向运⾏时常量池中的⼀个⽅法;
- 例:第 0 ⾏ bipush 10,操作码就是 bipush,操作数就是 10。这个指令就占据了第 0 ⾏和第 1 ⾏两⾏;
- ⽽有些操作码,如 astore_1,就只有⼀个操作码,没有操作数;
- Java 虚拟机中的操作码的⻓度只有⼀个字节(能表示的数据是0~255),这意味着 JVM 指令集的操作码总数不超过 256 条;
- 这些指令相⽐于庞⼤的操作系统来说,已经是⾮常⼩的了;
- 另外其中还有很多差不多的,⽐如aload_1,aload_2 这些,明显就是同⼀类的指令;
- 这些字节码指令,在不同JDK 版本中会稍有不同。具体可以参考 Oracle 官⽅⽂档,JDK ⽂档地址: https://docs.oracle.com/javase/specs/jvms/se8/html/index.html;
- 这⾥每⼀样就是⼀个字节码指令。 JVM 虚拟机的字节码指令由⼀个字节⻓度的,代表着某种特定操作含义的数字(称为操作码,OpCode)以及跟随其后的零⾄多个代表此操作所需要的参数(称为操作数,Operand)构成。其中操作数,可以是⼀个具体的参数,也可以是⼀个指向class⽂件常量池的符号引⽤,也可以是⼀个指向运⾏时常量池中的⼀个⽅法;
-
如果不考虑异常的话,那么 JVM 虚拟机执⾏代码的逻辑就应该是这样:
do{ 从程序计数器中读取 PC 寄存器的值 + 1; 根据 PC 寄存器指示的位置,从字节码流中读取⼀个字节的操作码; if(字节码存在操作数) 从字节码流中读取对应字节的操作数; 执⾏操作码所定义的操作; }while(字节码流长度>0)
-
虽然现在看不懂这些字节码指令,但⾄少现在,可以知道自己写的代码在 Class ⽂件当中是怎么记录的了。另外,如果还想更仔细⼀点地分辨你的每⼀样代码都对应哪些指令,那么在这个⼯具中还提供了⼀个LineNumberTable,会告诉你这些指令与代码的对应关系;
- 起始 PC 就是这些指令的字节码指令的⾏数,⾏号则对应 Java 代码中的⾏数;
- 实际上,Java 程序在遇到异常时给出的堆栈信息,就是通过这些数据来反馈报错⾏数的。
2.3 字节码指令解读案例
-
接下来详细分析⼀个⼩案例,来看看了解字节码指令的必要性;
-
在
ByteCodeInterView
类中,有⼀个typeTest
⽅法,我们重点来分析其中最容易让⼈产⽣困惑的⼏⾏代码:Integer i1 = 10; Integer i2 = 10; System.out.println(i1 == i2);//true Integer i3 = 128; Integer i4 = 128; System.out.println(i3 == i4);//false
- 执⾏结果注释在了后⾯。这些莫名其妙的
true
和false
是怎么蹦出来的?如果你之前恰巧刷到过这样的⾯试题,或许你会记得这是因为JAVA的基础类型装箱机制引起的⼩误会。但是如果你没背过呢?或者JAVA中还有很多类似的让⼈抓狂的⾯试题,你也⼀个⼀个去背吗?那要怎么彻底了解这⼀类问题呢?你最终还是要学会⾃⼰看懂这些字节码指令;
- 执⾏结果注释在了后⾯。这些莫名其妙的
-
⾸先,我们可以从 LineNumberTable 中获取到这⼏⾏代码对应的字节码指令:
-
以前⾯三⾏为例,三⾏代码对应的 PC 指令就是从 0 到 12 号这⼏条指令。把指令摘抄下来是这样的:
0 bipush 10 2 invokestatic #2 <java/lang/Integer.valueOf : (I)Ljava/lang/Integer;> 5 astore_1 6 bipush 10 8 invokestatic #2 <java/lang/Integer.valueOf : (I)Ljava/lang/Integer;> 11 astore_2 12 getstatic #3 <java/lang/System.out : Ljava/io/PrintStream;>
-
可以看到,在执⾏
astore
指令往局部变量表中设置值之前,都调⽤了⼀次Integer.valueOf()
⽅法;-
按住
Ctrl
点击我们代码中的Integer
,就可以进入到该类的源码,Ctrl + F
搜索valueOf()
方法: -
在这个⽅法中,对于[-128,127]范围内常⽤的数字,实际上是构建了缓存的;
-
每次都从缓存中获取⼀个相同的值,它们的内存地址当然就是相等的了;
-
-
在这个过程中,我们也看到了在JVM中,是通过⼀个
invokestatic
指令调⽤⼀个静态⽅法。实际上JDK中还有以下⼏个跟⽅法调⽤相关的字节码指令:invokevirtual
指令:⽤于调⽤对象的实例⽅法,根据对象的实际类型进⾏分派(虚⽅法分派),这也是 Java 语⾔中最常⻅的⽅法分派⽅式;invokeinterface
指令:⽤于调⽤接⼝⽅法,它会在运⾏时搜索⼀个实现了这个接⼝⽅法的对象,找出适合的⽅法进⾏调⽤;invokespecial
指令:⽤于调⽤⼀些需要特殊处理的实例⽅法,包括实例初始化⽅法私有⽅法和⽗类⽅法;invokestatic
指令:⽤于调⽤类静态⽅法;invokedynamic
指令:⽤于在运⾏时动态解析出调⽤点限定符所引⽤的⽅法。并执⾏该⽅法;- 前⾯四条调⽤指令的分派逻辑都固定在 Java 虚拟机内部,⽤户⽆法改变,⽽
invokedynamic
指令的分派逻辑是由⽤户所设定的引导⽅法决定的; - Java 从诞⽣到现在,只增加过⼀条指令,就是
invokedynamic
; - ⾃ JDK7 ⽀持并开始进⾏改进,这也是为 JDK8 实现Lambda表达式⽽做的技术储备;
- 前⾯四条调⽤指令的分派逻辑都固定在 Java 虚拟机内部,⽤户⽆法改变,⽽
-
-
⽅法调⽤指令与数据类型⽆关,⽽⽅法返回指令是根据返回值的类型区分的;
ireturn
(返回值是boolean, byte, char, short, int)lreturn
freturn
return
areturn
- 另外还有⼀条
return
指令,供声明为void的⽅法、实例初始化⽅法、类和接⼝的类初始化⽅法使⽤;
-
⾯试题:Java 当中的静态⽅法可以被⼦类重写吗?
- 普通答案:不能吧,因为没⻅过这么⽤的。吧啦吧啦吧啦……
- ⾼⼿答案:不能。因为在 JVM 中,调⽤⽅法提供了⼏个不同的字节码指令:
invokcvirtual
调⽤对象的虚⽅法(也就是可被⼦类重写的这些⽅法);invokespecial
根据编译时类型来调⽤实例⽅法,⽐如静态代码块(通常对应字节码层⾯的 cinit ⽅法),构造⽅法(通常对应字节码层⾯的init⽅法);invokestatic
调⽤类(静态)⽅法;invokcinterface
调⽤接⼝⽅法;- 静态⽅法和可重写的⽅法他们的调⽤指令都是不⼀样的,即静态方法是
invokestatic
指令调用,重写方法是invokcvirtual
指令调用,如果静态方法可以被重写的话,那么是该被哪一个指令调用呢?所以肯定是⽆法重写静态⽅法的。
2.4 深⼊字节码理解try-catch-finally
的执⾏流程
-
在之前解读字节码时,可能会注意到,在字节码指令的上⾯,有⼀个异常表的标签。这个异常表就是⽤来控制抛出异常的情况下的处理流程。在
ByteCodeInterView
类中编写一个inc
方法:public int inc(){ int x; try{ x=1; }catch (Exception e){ x = 2; }finally { x = 3; } return x; }
-
这个⽅法编译出的字节码是这样的:
-
try-catch-finally
的指令提现在哪⾥呢?这些就在旁边的异常表中;- 异常表中每⼀⾏代表⼀个执⾏逻辑的分⽀。表示当字节码从“起始 PC“到”结束 PC“(不包含结束 PC)之间出现了类型为“捕获异常”或者其⼦类的异常时,就跳转到”跳转 PC“处进⾏处理。可以看到,这⾥定义了三条明显的执⾏路径,分别是:
- 如果try语句块中出现了属于 Exception 或者其⼦类的异常,转到catch语句块处理;
- 如果try语句块中出现了不属于 Exception 或其⼦类的异常,转到finally语句块处理;
- 如果catch语句块中出现了任何异常,转到finally语句块处理;
- 异常表中每⼀⾏代表⼀个执⾏逻辑的分⽀。表示当字节码从“起始 PC“到”结束 PC“(不包含结束 PC)之间出现了类型为“捕获异常”或者其⼦类的异常时,就跳转到”跳转 PC“处进⾏处理。可以看到,这⾥定义了三条明显的执⾏路径,分别是:
2.5 字节码指令是如何⼯作的?
-
在 JVM 虚拟机中,会为每个线程构建⼀个线程私有的内存区域(见
1 JVM后面要学什么?
),下面讲解其中的虚拟机栈:-
虚拟机栈是⼀个先进后出的栈结构,其中会为线程中每⼀个⽅法构建⼀个栈帧。⽽栈帧先进后出的特性也就对应了我们程序中每个⽅法的执⾏顺序。每个栈帧中包含四个部分:局部变量表,操作数栈,动态链接库、返回地址;
-
局部变量表可以认为是⼀个数组结构,主要负责存储计算结果。存放⽅法参数和⽅法内部定义的局部变量。以 Slot 为最⼩单位;
-
操作数栈是⼀个先进后出的栈结构,主要负责存储计算过程中的中间变量。操作数栈中的每⼀个元素都可以是包括long型和double在内的任意 Java 数据类型;
-
动态链接库主要存储⼀些指向运⾏时常量池的⽅法引⽤。每个栈帧中都会包含⼀个指向运⾏时常量池中该栈帧所属⽅法的引用,持有这个引⽤是为了⽀持⽅法动态调⽤过程中的动态链接;
-
返回地址存放调⽤当前⽅法的指令地址。⼀个⽅法有两种退出⽅式,⼀种是正常退出,⼀种是抛异常退出。如果⽅法正常退出,这个返回地址就记录下⼀条指令的地址。如果是抛出异常退出,返回地址就会通过异常表来确定;
-
附加信息主要存放⼀些 HotSpot 虚拟机实现时需要填⼊的⼀些补充信息。这部分信息不在 JVM 规范要求之内,由各种虚拟机实现⾃⾏决定;
-
-
-
其中最为重要的就是操作数栈和局部变量表了。例如,对于初学者最头疼的
++
操作,下⾯的mathTest
⽅法:public int mathTest(){ int k = 1 ; k = k++; return k; }
-
我们都知道k的返回结果是 1,但是++⾃增操作到底有没有执⾏呢?就可以按照指令这样进⾏解释:
0 iconst_1 // 往操作数栈中压⼊⼀个常量1 1 istore_1 // 将 int 类型值从操作数栈中移出到局部变量表 1 位置 2 iload_1 // 从局部变量表 1 位置装载 int 类型的值到操作数栈中 3 iinc 1 by 1 // 将局部变量表 1 位置的数字增加 1 6 istore_1 // 将int类型值从操作数栈中移出到局部变量表 1 位置 7 iload_1 // 从局部变量表 1 位置装载 int 类型的值到操作数栈中 8 ireturn // 从操作数栈顶,返回 int 类型的值
-
这个过程中,
k++
是在局部变量表中对数字进⾏了⾃增,此时栈中还是 1。接下来执⾏=
操作,就对应⼀个istore
指令,从栈中将数字装载到局部变量表中。局部变量表中的k的值(对应索引 1 位置),就还是还原成了 1;
-
-
面试题:如何确定⼀个⽅法需要多⼤的操作数栈和局部变量?
-
实际上,每个⽅法在执⾏前都需要申请对应的资源,主要是内存。如果内存空间不够,就要在执⾏前直接抛出异常,⽽不能等到执⾏过程中才发现要⽤的内存空间申请不下来;
-
有些⾯试时,是会给你⼀个具体的⽅法,让你⾃⼰⼀下计算过程中需要⼏个操作数栈和⼏个局部变量。这是对算法的基础要求。但是在⼯作中,其实class⽂件当中就记录了所需要的操作数栈深度和局部变量表的槽位数。例如对于
mathTest
⽅法,所需的资源在⼯具中的纪录是这样的: -
这⾥会有⼀个⼩问题,如果⾃⼰推演过刚才的计算过程,可以看到,局部变量表中,明明只⽤到了索引为 1 的⼀个位置⽽已,为什么局部变量表的最⼤槽数是 2 呢?
-
这是因为对于⾮静态⽅法,JVM 默认都会在局部变量表的 0 号索引位置放⼊this变量,指向对象⾃身。所以我们可以在代码中⽤this访问⾃⼰的属性;
-
⼀个槽可以存放 Java 虚拟机的基本数据类型,对象引⽤类型和returnAddress类型。
-
3 类加载
- Class⽂件中已经定义好了⼀个Java程序执⾏的全部过程,接下来就是要扔到JVM中执⾏。既然要执⾏,就少不了类加载的模块。类加载模块是少数⼏个可以在Java代码中扩展的JVM底层功能;
- 类加载模块在JDK8之后,发⽣了⾮常重⼤的变化。
3.1 JDK8的类加载体系
-
有了 Class ⽂件之后,接下来就需要通过类加载模块将这些 Class ⽂件加载到 JVM 内存当中,这样才能执⾏。⽽关于类加载模块,以 JDK8 为例,最为重要的内容总结为三点:
- 每个类加载器对加载过的类保持⼀个缓存;
- 双亲委派机制,即向上委托查找,向下委托加载;
- 缓存优先原则:每一级加载器在查找或加载前都会先检查自己的缓存,避免重复加载;
- 自底向上委托查找:当类加载器收到加载请求时,(查完自己的缓存后)会先将请求委派给父加载器处理,递归直到顶层;
- 自顶向下尝试加载:若父加载器无法完成加载(在自己的搜索范围内找不到类),子加载器才会尝试加载;
- 沙箱保护机制;
-
下面是一段模仿类加载器的示例代码:
package com.roy.classLoader; public class LoaderDemo { public static String a ="aaa"; public static void main(String[] args) throws ClassNotFoundException { // 父子关系 AppClassLoader <- ExtClassLoader <- BootStrap Classloader ClassLoader cl1 = LoaderDemo.class.getClassLoader(); System.out.println("cl1 > " + cl1); System.out.println("parent of cl1 > " + cl1.getParent()); // BootStrap Classloader由C++开发,是JVM虚拟机的一部分,本身不是JAVA类 System.out.println("grant parent of cl1 > " + cl1.getParent().getParent()); // String,Int等基础类由BootStrap Classloader加载。 ClassLoader cl2 = String.class.getClassLoader(); System.out.println("cl2 > " + cl2); System.out.println(cl1.loadClass("java.util.List").getClass().getClassLoader()); // java指令可以通过增加-verbose:class -verbose:gc 参数在启动时打印出类加载情况 // BootStrap Classloader,加载java基础类。这个属性不能在java指令中指定,推断不是由java语言处理 System.out.println("BootStrap ClassLoader加载目录:" + System.getProperty("sun.boot.class.path")); // Extention Classloader 加载JAVA_HOME/ext下的jar包。 可通过-D java.ext.dirs另行指定目录 System.out.println("Extention ClassLoader加载目录:" + System.getProperty("java.ext.dirs")); // AppClassLoader 加载CLASSPATH,应用下的Jar包。可通过-D java.class.path另行指定目录 System.out.println("AppClassLoader加载目录:" + System.getProperty("java.class.path")); } }
- 启动应用:执行
LoaderDemo.main()
时,JVM触发AppClassLoader
加载LoaderDemo
类; - 委派流程:
- Step 1:
AppClassLoader
检查缓存,未找到LoaderDemo
,委派给父加载器ExtClassLoader
; - Step 2:
ExtClassLoader
检查缓存和JAVA_HOME/ext
目录,未找到,委派给BootstrapClassLoader
; - Step 3:
BootstrapClassLoader
检查缓存和sun.boot.class.path
目录,未找到(非核心类),返回null
; - Step 4:
ExtClassLoader
尝试在java.ext.dirs
目录加载,仍未找到,返回null
; - Step 5:
AppClassLoader
最终在java.class.path
(即应用类路径)找到LoaderDemo.class
并加载;
- Step 1:
- 验证流程:
- 示例中
String.class.getClassLoader()
返回null
,因为String
由BootstrapClassLoader
加载; - 通过
System.getProperty()
打印的路径展示了各级加载器的搜索范围。
- 示例中
- 启动应用:执行
3.2 双亲委派机制
-
JDK8中的类加载器都继承于⼀个统⼀的抽象类ClassLoader,类加载的核⼼也在这个⽗类中。其中,加载类的核⼼⽅法
loadClass
方法如下:protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { // 因为每个类加载器对它加载过的类都有⼀个缓存,所以要加载一个类的时候,就先去缓存中查看有没有加载过 Class<?> c = findLoadedClass(name); if (c == null) { // 没有加载过,⾛双亲委派,先委派给⽗加载器进⾏加载 long t0 = System.nanoTime(); try { if (parent != null) { c = parent.loadClass(name, false); } else { c = findBootstrapClassOrNull(name); } } catch (ClassNotFoundException e) { // ClassNotFoundException thrown if class not found // from the non-null parent class loader } if (c == null) { // ⽗类加载器没有加载过,就⾃⾏解析class⽂件加载 long t1 = System.nanoTime(); c = findClass(name); // this is the defining class loader; record the stats sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0); sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1); sun.misc.PerfCounter.getFindClasses().increment(); } } // 这⼀段就是加载过程中的链接Linking部分,分为验证、准备,解析三个部分 // 运⾏时加载类,默认是⽆法进⾏链接步骤的。 if (resolve) { resolveClass(c); } return c; } }
- 这个⽅法⾥,就是最为核⼼的双亲委派机制;
- 并且,这个⽅法是被
protected
声明的,意味着是可以被⼦类覆盖的,所以双亲委派机制也是可以被打破的;
-
为什么要打破双亲委派呢?想想Tomcat要如何加载webapps⽬录下的多个不同的应⽤?
-
双亲委派模型的工作流程是:当一个类加载器收到类加载请求时,首先会委托给父类加载器去完成,只有在父类加载器无法完成时,子类加载器才会尝试加载。这种机制保证了Java核心库的类型安全,但也存在局限性:
- 无法隔离不同应用的类:如果Web应用A和Web应用B都使用了不同版本的相同类库,双亲委派机制会导致它们共享同一个类;
- 无法支持热部署:一旦类被加载,就无法重新加载新版本的类;
-
而Tomcat需要同时部署多个Web应用(webapps目录下的每个文件夹都是一个独立应用),这些应用可能有:
- 不同版本的相同类库(如Spring 4和Spring 5);
- 相同全限定名的类(如都包含com.example.MyClass);
- 需要独立热部署的能力;
-
所以,Tomcat打破了双亲委派机制,实现了自己的类加载器体系:
-
WebappClassLoader:每个Web应用有自己的类加载器
- 优先加载
/WEB-INF/classes
和/WEB-INF/lib
中的类 - 无法加载时才会委托给父类加载器
- 这样就实现了应用间的类隔离
- 优先加载
-
加载顺序:
// 伪代码表示Tomcat类加载器的加载顺序 class WebappClassLoader { loadClass(String name) { // 1. 检查本地缓存 // 2. 检查JVM缓存(防止重复加载) // 3. 尝试自己加载(打破双亲委派的关键) // 4. 最后才委托给父加载器 } }
-
-
-
双亲委派机制有⼀个最⼤的作⽤就是要保护JDK内部的核⼼类不会被应⽤覆盖,关键实现方式是通过层级隔离和优先级控制,具体原理如下:
-
层级隔离:类加载器的父子关系
- 加载器层级:
BootstrapClassLoader
(顶层)→ExtClassLoader
→AppClassLoader
(应用层); - 委派顺序:子加载器必须先将加载请求委派给父加载器(如
AppClassLoader
会先问ExtClassLoader
,再问BootstrapClassLoader
),父加载器能加载的类,子加载器无权干预;
- 加载器层级:
-
优先级控制:核心类优先加载
- JDK核心类路径:
BootstrapClassLoader
固定加载JRE/lib
下的核心类(如java.lang.String
、java.util.List
等),这些路径通过JVM硬编码保护,用户无法修改; - 沙箱保护:即使用户自定义了一个
java.lang.String
类并放在应用类路径(CLASSPATH
)中:AppClassLoader
收到加载请求后,会先委派给BootstrapClassLoader
;BootstrapClassLoader
在核心路径中找到JDK自带的String.class
并直接返回,根本不会给子加载器机会加载用户伪造的类;
- JDK核心类路径:
-
代码验证:为何用户无法覆盖核心类
-
通过示例中的代码:
ClassLoader cl2 = String.class.getClassLoader(); System.out.println("cl2 > " + cl2); // 输出null,证明由BootstrapClassLoader加载
-
用户即使尝试用
AppClassLoader
加载自定义的java.lang.String
,也会因双亲委派机制被BootstrapClassLoader
拦截,确保始终返回JDK官方类;
-
-
安全设计本质
-
不可逆的委派链:父加载器加载的类对子加载器可见,反之不成立;
-
路径锁定:核心类的加载路径(如
sun.boot.class.path
)由JVM保护,用户无法通过-classpath
或代码篡改; -
总结:保护机制流程图:
用户伪造的java.lang.String.class ↓ AppClassLoader收到请求 → 委派 → ExtClassLoader → 委派 → BootstrapClassLoader ↓(Bootstrap已加载JDK官方String) ↑ ↓(返回JDK核心类) ↑ 用户类被忽略,核心类安全!
-
3.3 沙箱保护机制
-
为了保护JDK内部的核⼼类,JAVA在双亲委派的基础上,还加了⼀层保险,就是
ClassLoader
中的下⾯这个⽅法:/* Determine protection domain, and check that: - not define java.* class, - signer of this class matches signers for the rest of the classes in package. */ private ProtectionDomain preDefineClass(String name, ProtectionDomain pd) { if (!checkName(name)) throw new NoClassDefFoundError("IllegalName: " + name); // Note: Checking logic in java.lang.invoke.MemberName.checkForTypeAlias // relies on the fact that spoofing is impossible if a class has a name // of the form "java.*" if ((name != null) && name.startsWith("java.")) { // 不允许加载核心类,即类的名词不能以 java. 开头 throw new SecurityException ("Prohibited package name: " + name.substring(0, name.lastIndexOf('.'))); } if (pd == null) { pd = defaultDomain; } if (name != null) checkCerts(name, pd.getCodeSource()); return pd; }
-
这个⽅法会⽤在JAVA在内部定义⼀个类之前。这种简单粗暴的处理⽅式,当然是有很多时代的因素;
-
也因此在JDK中,可以看到很多
javax
开头的包,这个奇怪的包名也是跟这个沙箱保护机制有关系的; -
代码验证:
package com.roy.classLoader; public class ClassLoaderDemo { public static void main(String[] args) throws Exception{ //从加密后的myclass文件中加载测试类。 MyClassLoader myClassLoader = new MyClassLoader("/Users/roykingw/DevCode/JVMDemo/target/classes"); //沙箱保护机制,不允许加载java.开头的类 Class<?> clazz2 = myClassLoader.loadClass("java.Test"); System.out.println(clazz2); } }
-
结果会报错:
Exception in thread "main" java.lang.SecurityException: Prohibited package name: java at java.lang.ClassLoader.preDefineClass(ClassLoader.java:655) at java.lang.ClassLoader.defineClass(ClassLoader.java:754) at java.security.SecureClassLoader.defineClass(SecureClassLoader.java:142) at java.net.URLClassLoader.defineClass(URLClassLoader.java:473) at java.net.URLClassLoader.access$100(URLClassLoader.java:74) at java.net.URLClassLoader$1.run(URLClassLoader.java:369) at java.net.URLClassLoader$1.run(URLClassLoader.java:363) at java.security.AccessController.doPrivileged(Native Method) at java.net.URLClassLoader.findClass(URLClassLoader.java:362) at java.lang.ClassLoader.loadClass(ClassLoader.java:418) at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:352) at java.lang.ClassLoader.loadClass(ClassLoader.java:405) at java.lang.ClassLoader.loadClass(ClassLoader.java:351) at com.roy.classLoader.ClassLoaderDemo.main(ClassLoaderDemo.java:9)
-
3.4 类和对象有什么关系
-
通过类加载模块,我们写的class⽂件就可以加载到JVM当中。但是类加载模块针对的都是类,⽽我们写的Java程序都是基于对象来执⾏。类只是创建对象的模板。那么类和对象倒是什么关系呢?
-
首先:
- 类 Class 在 JVM 中的作⽤其实就是⼀个创建对象的模板,也就是说它的作⽤更多地体现在创建对象的过程当中。⽽在程序具体执⾏的过程中,主要是围绕对象在进⾏,这时候类的作⽤就不⼤了;
- 因此,在 JVM 中,类并不直接保存在最宝贵最核⼼的堆内存当中,⽽是挪到了堆内存以外的⼀部分内存中。这部分内存,在 JDK8 以前被成为永久带PermSpace,⽽在 JDK8 之后被改为了元空间 MetaSpace;
- 堆空间可以理解为JVM的客厅,所有重要的事情都在客厅处理。元空间可以理解为JVM的库房,东⻄扔进去基本上就很少管了;
- 这个元空间逻辑上可以认为是堆空间的⼀部分,但是它跟堆空间有不同的配置参数,不同的管理⽅式,因此也可以看成是单独的⼀块内存。这⼀块内存就相当于家⾥的⼯具间或者地下室,都是放⼀些⽤得⽐较少的东⻄。最主要就是类的⼀些相关信息,⽐如类的元数据、版本信息、注解信息、依赖关系等等;
- 元空间可以通过
-XX:MetaspaceSize
和-XX:MaxMetaspaceSize
参数设置⼤⼩。但是⼤部分情况下,是不需要管理元空间⼤⼩的,JVM 会动态进⾏分配; - 另外,这个元空间也是会进⾏ GC 垃圾回收的。如果⼀个类不再使⽤了,JVM 就会将这个类信息从元空间中删除。但是,显然,对类的回收效率是很低的。只有⼀些⾃定义类加载器⾃⾏加载的⼀些类有被回收的可能,⼤部分情况下,类是不会被回收的。所以堆元空间的垃圾回收基本上是很少有效果的。⼤部分情况下,我们是不需要管元空间的。除⾮你的JVM内存确实⾮常紧张,这时可以设定
-XX:MaxMetaspaceSize
参数,严格控制元空间⼤⼩;
-
然后:
- 在我们创建的每⼀个对象中,JVM也会保存对应的类信息;
- 在堆中,每⼀个对象的头部,还会保存这个对象的类指针
classpoint
,指向元空间中的类。这样我们就可以通过⼀个对象的getClass
⽅法获取到对象所属的类了。这个类指针,也是可以通过⼀个⼩⼯具观察到的;
-
下⾯这个Maven依赖就可以帮我们分析⼀个对象在堆中保存的信息:
<dependency> <groupId>org.openjdk.jol</groupId> <artifactId>jol-core</artifactId> <version>0.17</version> </dependency>
-
写个 Demo 程序简单看一下对象的内存信息:
package com.roy; import org.openjdk.jol.info.ClassLayout; /** * 对象的内存布局。 * 这个布局就跟操作系统有关系了。 */ public class JOLDemo { private String id; private String name; public static void main(String[] args) { JOLDemo o = new JOLDemo(); System.out.println(ClassLayout.parseInstance(o).toPrintable()); synchronized (o){ System.out.println(ClassLayout.parseInstance(o).toPrintable()); } } }
-
结果:
- 字节对齐是要求改对象所占的空间大小,可以被 8 (一个字节)整除;
- 这⾥类指针实际上就是⼀个指向元空间对应类的⼀个指针。当然,具体结果是被压缩过的;
- 另外Markdown标志位就是对象的⼀些状态信息。包括对象的 HashCode,锁状态,GC分代年龄等等。
-
4 执行引擎
- 在 Class ⽂件当中,已经明确的定义清楚了程序的完整执⾏逻辑。⽽执⾏引擎就是将这些字节指令转为机器指令去执⾏了。这⼀块更多的是跟操作系统打交道,对开发⼯作其实帮助就不是很⼤了。所以,如果不是专⻔研究语⾔,执⾏引擎这⼀块就没有必要研究太深了。
4.1 解释执⾏与编译执⾏
-
JVM 中有两种执⾏的⽅式:
- 解释执⾏就相当于是同声传译。JVM 接收⼀条指令,就将这条指令翻译成机器指令执⾏;
- 编译执⾏就相当于是提前翻译。好⽐领导发⾔前就将讲话稿提前翻译成对应的⽂本,上台讲话时就可以照着念了。编译执⾏也就是传说中的 JIT;
-
⼤部分情况下,使⽤编译执⾏的⽅式显然⽐解释执⾏更快,减少了翻译机器指令的性能消耗。⽽我们常⽤的 HotSpot 虚拟机,最为核⼼的实现机制就是这个 HotSpot 热点。它会搜集⽤户代码中执⾏最频繁的热点代码,形成CodeCache,放到元空间中,后续再执⾏就不⽤编译,直接执⾏就可以了;
-
但是编译执⾏起始也有⼀个问题,那就是程序预热会⽐较慢。毕竟作为虚拟机,不可能提前预知到程序员要写⼀些什么稀奇古怪的代码,也就不可能把所有代码都提前编译成模板。⽽将执⾏频率并不⾼的代码也编译保存下来,也是得不偿失的。所以,现在JDK 默认采⽤的就是⼀种混合执⾏的⽅式。它会⾃⼰检测采⽤那种⽅式执⾏更快。虽然可以⼲预 JDK 的执⾏⽅式,但是在绝⼤部分情况下,都是不需要进⾏⼲预的;
-
可以通过以下指令,让虚拟机切换编译模式:
-
另外,现在也有⼀种提前编译模式:AOT ;
- 这种模式可以直接将 Java 程序编译成机器码。⽐如GraalVM,可以直接将 Java 程序编译成可执⾏⽂件,这样就不需要 JVM 虚拟机也能直接在操作系统上执⾏;
- 关于 AOT 是不是会⼀统天下,也是现在⾯试中⽐较喜欢问的问题。虽然在 SpringBoot3 等框架中已经有了落地,但是从⽬前来看,AOT还远没有成为主流,离⼀统天下还有点距离;
- 少了 JVM 这个中间商之后,虽然⼤部分情况下是可以提升程序执⾏性能的,但是,也并不是就完美⽆缺了。毕竟很显然,这种⽅式其实是以丧失⼀定的跨平台特性作为代价的;
- 要注意,⽬前 AOT 这种⽅式还是不太安全的。毕竟 JVM 打了这么多年的怪,什么⽜⻤蛇神都⻅多了。现在 AOT 要绕开 JVM,那么这些怪就都要⾃⼰去打了。中间有个什么疏忽,那是难免的。
4.2 编译执行时的代码优化
- 热点代码会触发 JIT 实时编译,⽽ JIT 编译运⽤了⼀些经典的编译优化技术来实现代码的优化,可以智能地编译出运⾏时的最优性能代码;
- HotSpot虚拟机中内置了两个(或三个)即时编译器,其中有两个编译器存在已久,分别被称为“客户端编译器”(Client Compiler)和“服务端编译器”(Server Compiler),或者简称为C1编译器和C2编译器(部分资料和JDK源码中C2也叫Opto编译器),第三个是在JDK 10时才出现的、⻓期⽬标是代替C2的Graal编译器;
- Graal编译器采⽤ Java 语⾔编写,因此⽣态的活⼒更强;
- 并由此衍⽣出了 GraalVM 这样的⽀持实时编译的产品,也就是绕过Class ⽂件,直接将 Java 代码编译成可在操作系统本地执⾏的应⽤程序。这也就是 AOT 技术 Ahead Of Time;
- C1 会对字节码进⾏简单和可靠的优化,耗时短,以达到更快的编译速度;
- 启动快,占⽤内存⼩,执⾏效率没有server快;
- 默认情况下不进⾏动态编译,适⽤于桌⾯应⽤程序;
- C2 进⾏耗时较⻓的优化,以及激进优化,但优化的代码执⾏效率更⾼;
- 启动慢,占⽤内存多,执⾏效率⾼,适⽤于服务器端应⽤;
- 默认情况下就是使⽤的 C2 编译器。并且,绝⼤部分情况下也不建议特意去使⽤ C1;
- 由于即使编译器编译本地代码需要占⽤程序运⾏时间,通常要编译出优化程度越⾼的代码,所花费的时间便会越⻓;⽽且想要编译出优化程度更⾼的代码,解释器可能还要替编译器收集性能监控信息,这对解释执⾏阶段的速度也有所影响。为了在程序启动响应速度与运⾏效率之间达到最佳平衡,HotSpot虚拟机在编译⼦系统中加⼊了分层编译的功能,分层编译根据编译器编译、优化的规模与耗时,划分出不同的编译层次,其中包括:
- 第0层。程序纯解释执⾏,并且解释器不开启性能监控功能(Profiling);
- 第1层。使⽤C1编译器将字节码编译为本地代码来运⾏,进⾏简单可靠的稳定优化,不开启性能监控功能;
- 第2层。仍然使⽤C1编译器执⾏,仅开启⽅法及回边次数统计等有限的性能监控功能;
- 第3层。仍然使⽤C1编译器执⾏,开启全部性能监控,除了第2层的统计信息外,还会收集如分⽀跳转、虚⽅法调⽤版本等全部的统计信息;
- 第4层。使⽤C2编译器将字节码编译为本地代码,相⽐起C1编译器,C2编译器会启⽤更多编译耗时更⻓的优化,还会根据性能监控信息进⾏⼀些不可靠的激进优化;
- JDK8 中提供了参数
-XX:TieredStopAtLevel=1
可以指定使⽤哪⼀层编译模型。但是,除⾮你是JVM 的开发者,否则不建议⼲预 JVM 的编译过程。
4.3 静态执⾏与动态执⾏
- 静态执⾏指在 Class ⽂件编译过程中就已经确定了执⾏⽅法;
- 动态执⾏指需要在运⾏期间才能确定调⽤哪个⽅法。⽐如多个重载的⽅法,需要根据传⼊类型确定调⽤哪个⽅法;
- 动态执⾏更多的是关联到
invokedynamic
指令。在JVM的语⾔体系中,以Scala为代表的函数式的编程⽅式会越来越重要,到时候动态执⾏也会随之变得更为重要。
5 GC垃圾回收
- 执⾏引擎会将class⽂件扔到JVM的内存当中运⾏。在运⾏过程中,需要不断的在内存当中创建并销毁对象。在传统C/C++语⾔中,这些销毁的对象需要⼿动进⾏内存回收,防⽌内存泄漏。⽽在Java当中,实现了影响深远的GC垃圾回收机制;
- GC 垃圾⾃动回收,这个可以说是 JVM 最为标志性的功能。不管是做性能调优,还是⼯作⾯试,GC 都是 JVM 部分的重中之重。⽽对于 JVM 本身,GC 也是不断进⾏设计以及优化的核⼼。⼏乎 Java 提出的每个版本都对 GC 有或⼤或⼩的改动,此处 JDK8 作为讲解核心。
5.1 垃圾回收器的作用
-
在了解 JVM之前,推荐⼀个⼯具:阿⾥开源的 Arthas;
- 官⽹地址:https://arthas.aliyun.com/;
- 这个⼯具功能⾮常强⼤,是对 Java进程进⾏性能调优的⼀个⾮常重要的⼯具,对于了解 JVM 底层帮助也⾮常⼤;
-
运行一个简单的 Java 程序:
package com.roy; import java.util.ArrayList; import java.util.List; public class GCTest { public static void main(String[] args) throws InterruptedException { List l = new ArrayList<>(); for(int i = 0 ; i < 100_0000 ; i ++){ l.add(new String("dddddd")); Thread.sleep(100); } } }
-
运⾏后,使⽤Arthas 的DashBoard指令,可以查看到这个 Java 程序的运⾏情况;
-
进入 Arthas 的安装目录,然后执行下面的命令。之后会弹出要跟踪哪一个程序,此处就选择 4;
java -jar arthas-boot.jar
-
通过
dashboard
命令可以查看各个Java进程的信息:
-
-
重点关注中间的 Memory 部分,这⼀部分就是记录的 JVM 的内存使⽤情况,⽽后⾯的 GC 部分就是垃圾回收的执⾏情况;
- 从 Memory 部分可以看到,⼀个 Java 进程会将它管理的内存分为
heap
堆区和nonheap
⾮堆区两个部分;- ⾮堆区有几个核心部分:
code_cache
(热点指令缓存),metaspace
(元空间),compressed_class_space
(压缩类空间)。这⼀部分就相当于 Java 进程中的地下室,属于不太活跃的部分; - 而中间
heap
堆区就相当于客厅了,属于Java中最为核⼼的部分。⽽这其中,⼜⼤体分为了eden_space
,survivor_space
和old_gen
三个⼤的部分,这就是 JVM 内存的主体。之前分析的栈区,这⾥没有列出;
- ⾮堆区有几个核心部分:
- 从 Memory 部分可以看到,⼀个 Java 进程会将它管理的内存分为
-
整体内存布局如下图:
- 其中堆区是 JVM 核⼼的存放对象的内存区域,它的⼤⼩可以由
-Xms
(初始堆内存⼤⼩),-Xmx
(最⼤堆内存)这两个参数控制;- 从这两个参数可以看到,堆内存是可以扩展的;
- 如果初始内存不够,JVM 会扩⼤堆内存;
- 但是如果内存扩展到了最⼤堆内存时还不够,这时就⽆法继续扩展了,⽽是会抛出 OOM 异常;
- 这两个参数在⽣产环境中最好设置成⼀样,减少内存扩展时的性能消耗;
- ⽽GC垃圾回收器,就是要对这些内存空间进⾏及时回收,从⽽让这些内存可以重复利⽤。
- 其中堆区是 JVM 核⼼的存放对象的内存区域,它的⼤⼩可以由
5.2 分代收集模型
-
不同GC,对内存的管理和回收的⽅式都是不同的。但是这其中⾯试最喜欢问的,就是关于垃圾分代收集模型;
-
在Memor部分还可以看到多次出现了
ps_
这样的字样。这其实就代表JDK8默认的垃圾回收器Parallel Scavenge
。其整体的⼯作机制如下图:-
JAVA做过统计,80%的对象都是“朝⽣夕死”。这些对象,被集中放在了⼀块⽐较⼩的内存空间当中,快速创建,快速回收,这块内存区域就是年轻代;
- 在年轻代会⾮常频繁的进⾏垃圾回收,称为YoungGC;
- ⽽年轻代⼜会被进⼀步划分为⼀个
eden_space
和两个survivor
。这三个区域的⼤⼩⽐例默认是 8:1:1;
-
另外少部分需要⻓期使⽤的对象,则被放到另外⼀块⽐较⼤的内存空间当中,⻓期保持,这块内存就是⽼年代;
- 在⽼年代,垃圾回收的频率则会相对⽐较低,只有空间不够时才进⾏,称为OldGC;
-
年轻代与⽼年代默认的⼤⼩⽐例是 1:2;
-
-
常⻅的分代收集模型中:
- 对象会优先在
eden
区创建,经过⼀次YoungGC后,如果没有被回收,就会被移动到⼀个survivor
区; - 接下来,下⼀次YoungGC时,⼜会被移动到另⼀块
survivor
区; - 每移动⼀次,记录⼀个分代年龄;
- 直到分代年龄太⼤了(默认是16),就会被移动到⽼年代;
- 到⽼年代后,对象就不再记录分代年龄了,在⽼年代安安静静的⽤到退休;
- 对象会优先在
-
这就是JDK最有代表性的分代年龄收集机制。通过分代收集机制,JVM可以对不同的对象采取不同的回收策略,从⽽提⾼垃圾回收的效率;
- 当然,分代收集机制在具体实现过程中,还需要根据具体情况提供更多优化机制;
- ⽐如,如果⼩ O 占⽤内存⾮常⼩,那么在创建⼩ O 时,JVM 会在
eden_space
中单独划分出⼀⼩⽚线程专属的内存空间,称为 TLAB ;- ⼩ O 就在 TLAB 中创建;
- 由于 TLAB 空间是线程私有的,所以就可以避免多个线程之间的资源争抢;
- 另外,如果⼩ O 占⽤的内存⾮常⼤,
eden_space
都装不下。这时⼩ O 就会跳过年轻代,直接进⼊⽼年代。
5.3 JVM中的垃圾回收器
-
Java 从诞⽣到现在最新的 JDK21 版本,总共就产⽣了以下⼗个垃圾回收器:
- 左边的都是分代算法,也就是将内存划分为年轻代和⽼年代进⾏管理,⽽有虚线的部分表示可以协同进⾏⼯作;
- JDK8默认就是使⽤的
Parallel Scavenge
和Parallel Old
的组合,也就是在 Arthas 的 Dashboard 中看到的ps
;
- JDK8默认就是使⽤的
- 右侧的是不分代算法,也就是不再将内存严格划分位年轻代和⽼年代;
- JDK9 开始默认使⽤ G1;
- ZGC是⽬前最先进的垃圾回收器;
shennandoah
则是 OpenJDK 中引⼊的新⼀代垃圾回收器,与 ZGC 是竞品关系;- Epsilon是⼀个测试⽤的垃圾回收器,根本不⼲活。
- 左边的都是分代算法,也就是将内存划分为年轻代和⽼年代进⾏管理,⽽有虚线的部分表示可以协同进⾏⼯作;
6 GC 情况分析实例
6.1 如何定制 GC 运行参数
-
在现阶段,各种GC垃圾回收器都只适合⼀个特定的场景,因此我们也需要根据业务场景,定制合理的GC运⾏参数;
-
另外,JAVA程序在运⾏过程中要处理的问题是层出不穷的。项⽬运⾏期间会⾯临各种各样稀奇古怪的问题。⽐如 CPU 超⾼,FullGC 过于频繁,时不时的 OOM 异常等等。这些问题⼤部分情况下都只能凭经验进⾏深⼊分析,才能做出针对性的解决;
-
如何定制JVM运⾏参数呢?⾸先我们要知道有哪些参数可以供我们选择。关于 JVM 的参数,JVM 提供了三类参数:
- ⼀类是标准参数,以
-
开头,所有 HotSpot 都⽀持。例如java -version
。这类参数可以使⽤java -help
或者java -?
全部打印出来; - ⼆类是⾮标准参数,以
-X
开头,是特定 HotSpot 版本⽀持的指令。例如java -Xms200M -Xmx200M
。这类指令可以⽤java -X
全部打印出来; - 最后⼀类,不稳定参数,以
-XX
开头,这些参数是跟特定 HotSpot 版本对应的,很有可能换个版本就没有了,详细的⽂档资料也特别少。JDK8 中的以下⼏个指令可以帮助开发者了解 JDK8 中的这⼀类不稳定参数:
java -XX:+PrintFlagsFinal # 所有最终⽣效的不稳定指令 java -XX:+PrintFlagsInitial # 默认的不稳定指令 java -XX:+PrintCommandLineFlags #当前命令的不稳定指令,这⾥可以看到是⽤的哪种GC,JDK1.8默认⽤的ParallelGC
- ⼀类是标准参数,以
6.2 打印 GC 日志
-
对 JVM 虚拟机来说,绝⼤多数的问题往往都跟堆内存的 GC 回收有关,因此下⾯⼏个跟 GC 相关的⽇志打印参数是必须了解的,这通常也是进⾏ JVM 调优的基础;
-XX:+PrintGC
:打印GC信息 类似于-verbose:gc
;-XX:+PrintGCDetails
:打印GC详细信息,这⾥主要是⽤来观察FGC的频率以及内存清理效率;-XX:+PrintGCTimeStamps
:配合-XX:+PrintG
使⽤,在 GC 中打印时间戳;-XX:PrintHeapAtGC
:打印GC前后的堆栈信息;-Xloggc:filename
:GC⽇志打印⽂件;- 不同 JDK 版本会有不同的参数。 ⽐如 JDK9 中,就不⽤分这么多参数了,可以统⼀使⽤
-X-log:gc*
通配符打印所有的 GC ⽇志;
- 不同 JDK 版本会有不同的参数。 ⽐如 JDK9 中,就不⽤分这么多参数了,可以统⼀使⽤
-
代码示例:这段代码会持续向
ArrayList
中添加100KB的字节数组,最终导致堆内存耗尽,触发多次GC,最终引发Full GC
package com.roy; import java.util.ArrayList; /** * -Xms60m -Xmx60m -XX:SurvivorRatio=8 -XX:+PrintGCDetails -Xloggc:./gc.log */ public class GcLogTest { public static void main(String[] args) { ArrayList<byte[]> list = new ArrayList<>(); for (int i = 0; i < 500; i++) { byte[] arr = new byte[1024 * 100];//100KB list.add(arr); try { Thread.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); } } } }
-
在执⾏这个⽅法时,添加以下 JVM 参数:
-
运行结果:
[GC (Allocation Failure) [PSYoungGen: 16344K->2024K(18432K)] 16344K->13729K(59392K), 0.0063955 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] [GC (Allocation Failure) [PSYoungGen: 18408K->2020K(18432K)] 30113K->30160K(59392K), 0.0049509 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] [Full GC (Ergonomics) [PSYoungGen: 2020K->0K(18432K)] [ParOldGen: 28140K->29775K(40960K)] 30160K->29775K(59392K), [Metaspace: 3953K->3953K(1056768K)], 0.0103685 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] [Full GC (Ergonomics) [PSYoungGen: 16349K->5000K(18432K)] [ParOldGen: 29775K->40763K(40960K)] 46125K->45763K(59392K), [Metaspace: 3953K->3953K(1056768K)], 0.0175526 secs] [Times: user=0.22 sys=0.11, real=0.02 secs] Heap PSYoungGen total 18432K, used 10476K [0x00000000fec00000, 0x0000000100000000, 0x0000000100000000) eden space 16384K, 63% used [0x00000000fec00000,0x00000000ff63b270,0x00000000ffc00000) from space 2048K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x0000000100000000) to space 2048K, 0% used [0x00000000ffc00000,0x00000000ffc00000,0x00000000ffe00000) ParOldGen total 40960K, used 40763K [0x00000000fc400000, 0x00000000fec00000, 0x00000000fec00000) object space 40960K, 99% used [0x00000000fc400000,0x00000000febcec98,0x00000000fec00000) Metaspace used 3961K, capacity 4568K, committed 4864K, reserved 1056768K class space used 428K, capacity 460K, committed 512K, reserved 1048576K
-
这⾥⾯就记录了两次
MinorGC
和两次FullGC
的执⾏效果。另外,在程序执⾏完成后,也会打印出Heap
堆区的内存使⽤情况; -
具体解释:
- 内存分配失败触发GC
- 两次Young GC后,年轻代仍无法释放足够空间(从16MB→2MB),老年代持续增长(16MB→30MB)
- 最终触发两次Full GC,老年代占用达40MB(接近最大值41MB)
- 内存现状
- 年轻代:18MB总空间,10MB已用(63%在Eden区)
- 老年代:41MB空间,99%已满(40.7MB/41MB)
- 元空间:4MB使用量(稳定)
- 关键问题
- 内存泄漏:老年代接近占满,Full GC后仍无法回收
- 配置不当:堆内存总量59MB过小,老年代占比过高(41/59)
- 内存分配失败触发GC
-
当然,⽬前这些⽇志信息只是打印在控制台,只能凭经验⾃⼰强⾏去看。可以添加
-Xloggc
参数,将⽇志打印到⽂件⾥。然后拿⽇志⽂件进⾏整体分析。
-
6.3 GC 日志分析
-
这些GC⽇志隐藏了项⽬运⾏⾮常多隐蔽的问题,要如何发现其中的这些潜在的问题呢?
-
这⾥推荐⼀个开源⽹站:https://www.gceasy.io/,这是国外⼀个开源的GC ⽇志分析⽹站;
- 可以把 GC ⽇志⽂件直接上传到这个⽹站上,它就会分析出⽇志⽂件中的详细情况;
- 收费,但是有免费额度;
-
例如,在
6.2 打印 GC 日志
的示例中,添加⼀个参数-Xloggc:./gc.log
,就可以将GC⽇志打印到⽂件当中。接下来将⽇志⽂件直接上传到这个⽹站上,⽹站就会帮我们对GC情况进⾏分析。示例⽂件得到的报告是这样的: -
通过这个报告,可以及时发现项⽬运⾏可能出现的⼀些隐藏问题,并且这个报告也提供了⼀些具体的修改意⻅;
-
当然,如果觉得这些建议还不够满意,那么报告中还提供了⾮常详细的指标分析,通过这些指标,可以进⼀步的分析问题,寻找新的改进⽅向;
-
如果是⾃⼰开发的项⽬,那么接下来,根据这些建议和数据,做进⼀步的分析,调整参数,优化配置。