jvm 基础

初始jvm

jvm职责

jvm - java virtual machine 即 java虚拟机,是 经过java编译器(javac)编译后的 java程序 的运行环境。

jvm 是运行在计算机上的程序,他是运行 java字节码的程序,他的职责是运行即将 已经经过java编译器(javac)编译的字节码文件(.class文件)进行加载和执行时解释,执行时通过JIT(just-in-time compilation - 即时编译技术)将字节码解释成机器码,以便于计算机能够理解和执行。

在正常项目过程中,我们会先将java工程编译并打包成jar文件(jar即是包含经过编译后的字节码,资源文件以及相关文件的打包在一起的一种文件格式),jar部署在jvm虚拟机上运行,由于jvm部署所在的计算机无法直接执行字节码(字节码是由一组针对jvm设计的指令构成 - 虚拟机指令),所以jvm需要加载并通过JIT即时编译技术将字节码解释成机器码,交给计算机理解和执行。

因此,java程序不能直接在计算机上运行的,而是编译后通过jvm加载并执行。

image-20231228163553436

正是因为 针对不同程序的不同编译器所编译成的相同字节码文件 和 针对不同操作系统的jvm的加载和解释 使得java程序具有跨平台性即可以在不同的操作系统上运行。

image-20231228163900743

jvm功能

上文,我们知道jvm的功能之一就是:

  • 加载字节码文件的指令并实时解释成机器码交给计算机执行(解释过程支持通过JIT即时编译进行优化,提升热点代码的执行效率)

其他功能还有:

  • 自动为对象和方法等分配内存,并配合垃圾回收机制回收不再使用的对象

java vs c

由于java程序需要编译后成字节码(.class)通过jvm加载并实时解释,解释成计算机可以执行的机器码才可以执行;而c程序可以直接编译为计算机可以直接执行的可执行文件(.exe),即c程序无需加载和解释,c程序的性能要高于java程序。

但是java之所以保持编译字节码和jvm解释的特性,主要目的是支持跨平台,”一次编译,随处运行”。

jit - 即时编译

由于 java 在性能上的不足主要是由于 jvm解释 的过程导致的,java 后面引入了 jit (just-in-time compilation - 即时编译技术):

jvm对于热点字节码进行解释并优化成机器码,并将这个机器码保存在内存中,当再此执行时直接从内存中加载出来直接交给计算机执行,从而节省了对于热点代码解释的工作,大幅度提升了java程序的性能。

image-20231228170508653

常见的jvm

image-20231228171714480

jvm有很多但是都要满足《java虚拟机规范

上述虚拟机中,jdk默认的虚拟机是Hotspot - Oracle JDK,它是目前应用最广泛、稳定可靠的虚拟机。

Hotspot 发展历程

image-20231228172947817

jvm 组成

  • 类加载器 - class loader:
    • 功能:负责将 java字节码 加载到 jvm内存 中
    • 分类:jvm类加载器分为三个层次:启动类加载器(bootstrap class loader)、扩展类加载器(extension class loader)和 应用程序类加载器(application class loader)
  • 运行时数据区 - runtime data area:
    • 功能:负责 管理分配给jvm的内存,存储和管理程序运行时所需要的数据
    • 主要区域:
      • 方法区 - method area:存储类的结构信息,包括类的字段、方法、接口等
      • 本地方法栈 - native method stack:执行本地方法
      • 堆 - heap:存储对象实例
      • 栈 - stack:存储局部变量、操作数栈、方法出口等
      • 程序计数器 - program counter register:记录当前线程执行的字节码行号
  • 执行引擎 - execution engine:
    • 功能:执行编译后的字节码
      • 主要组件:
        • 解释器 - interpreter:逐行解析字节码并执行
        • 即时编译器 - just-in-time compiler(JIT compiler):提供字节码即时编译,提高执行效率
  • 本地接口 - native interface:
    • 功能:提供与本地库(如:c/c++)交互的接口或者方法,允许java代码调用
    • 例如:jvm 提供的c/c++方法

字节码文件

字节码文件定义

字节码文件(.class 文件)是 源代码 经过编译之后的内容,是一个以8位字节为基础单位的二进制流即通过 二进制 方式存储。由于 并没有指定字符编码方式,所以无法直接通过记事本等工具打开。

我们可以使用 jclasslib 打开字节码文件

字节码文件的组成

  • 基础信息:

    • 魔数 - Magic Number

      • 定义:每个class文件开头的4个字节(OxCAFEBABE)成为魔数

        ​ 由于文件名是可以随意修改的(但不会影响文件的内容),因此文件是无法通过文件扩展名来确定文件类型的。

        ​ 因此魔术用于校验文件的类型,很多类型的文件都有自己对应的”魔术”

      • 作用:确定文件是一个java编译后的class文件 即识别一个文件是否为特定的类型

    • 文件版本:

      • 定义:紧随着魔数的4个字节为 编译字节码文件 对应的jdk版本号,包括类主版本号和次版本号
      • 作用:判断字节码文件的版本和运行时的版本是否兼容(jdk版本是向后的兼容,低版本无法加载运行高版本的字节码文件)
    • 访问标识 - Access Flags:

      • 定义:标识文件是类、接口还是枚举等,以及他的属性和访问类型
    • 类、父类、接口索引:

      • 定义:表示当前类、父类和实现接口在常量池中的索引
      • 作用:确定类的继承关系
  • 常量池 - Constant Pool:

    • 定义:常量池是一个表,存储了各种字面量和符号引用。它包括类名、方法名、字段名等信息。

      ​ 常量池的索引从1开始,0被保留用于表示不引用任何常量。

    • 作用:节省字节码文件部分空间,避免相同的内容重复定义,而只需要引用常量池中的内容

  • 字段表 - Field Table:

    • 作用:描述接口和类中声明的字段的名称、类型和访问修饰符等信息
  • 方法表 - Method Table:

    • 作用:描述接口和类中声明的方法的名称、类型、访问修饰符以及方法信息等
  • 属性表 - Attribute Table:

    • 作用:描述类、字段或方法等附加信息

示例

下面以一个简单的例子来展示一下字节码文件的内容

1
2
3
4
5
6
7
8
9
10
11
// SimpleClass.java
public class SimpleClass {

private int m;

private String str = "Hello world!";

public int inc() {
return m + 1;
}
}

经过 javac 生成 字节码文件 SimpleClass.class

1
javac SimpleClass.java

打卡生成的class文件

image-20231229131338804

前4个字节(OxCAFEBABE)是 魔数,jvm判断并接受以”caff babe”开头的class文件,这四个字节的作用即字节码的身份识别。

紧接着的4个字节为编译器jdk的版本号,前2位(0x0000)是次版本号,后2位(0x0040)为主版本号,将0x0040转换为十进制为64,所以字节码文件编译器的jdk版本为20(64-44)。

使用 jclasslib 打开该字节码文件

一般信息
image-20231229131543978
常量池引用

字段信息 引用常量池中的内容

常量池中放数据都有一个编号,编号从1开始。在指令中,可以通过编号快速找到引用的相应数据。

字节码指令可以通过编号引用到常量池的内容称之为 符号引用

image-20231229132120760
方法

字节码文件的方法区存放了方法字节码指令

image-20231229133815275

字节码指令执行分析

在理解字节码指令的流程,我们需要分析一下两个在jvm执行时相关的重要的概念:操作数栈(operand stack)和 局部变量表(local variable table)

操作数栈(栈):用于存储方法执行过程中的操作数。各种指令将操作数压入或弹出操作数栈并进行相应的运算操作。

image-20231229142811892

局部变量表(数组):用于存储方法中方法参数和方法定义的局部变量的表,每个方法执行前都会创建一个局部变量表。

​ 当一个方法被调用时,方法的参数将被传递给局部变量表中的相应位置,并且方法内部的局部变量也会在表中占据一定的位置,供方法执行过程中使用。

示例1

下面的代码示例

1
2
3
4
public static void main(String[] args) {
int i = 0;
int j = i + 1;
}

局部变量表如下:

image-20231229141933896

上面代码的字节码指令如下

1
2
3
4
5
6
7
0 iconst_0 # 将整数常量0推送到操作数栈
1 istore_1 # 从操作数栈弹出值,放入局部变量表位置1
2 iload_1 # 局部变量表的位置1加载整数值到操作数栈
3 iconst_1 # 将整数常量1推送到操作数栈
4 iadd # 操作数栈栈顶弹出两个整数,相加,将结果推送回栈
5 istore_2 # 从操作数栈弹出值,存储到局部变量表的位置2
6 return # 从当前方法返回,方法返回值在栈顶
示例2
1
2
3
4
public static void main(String[] args) {
int i = 0;
i = i++;
}

上面的代码是i最终的赋值为0,下面从字节码指令的执行过程分析一下为什么

1
2
3
4
5
6
0 iconst_0 # 将整数常量0推送到操作数栈
1 istore_1 # 从操作数栈弹出值,存储到局部变量表的位置1
2 iload_1 # 从局部变量表的位置1加载整数值到操作数栈
3 iinc 1 by 1 # 局部变量表的位置1中的整数值增加1
6 istore_1 # 从操作数栈弹出值,存储到局部变量表的位置1 (此处和上一步是关键)
7 return

局部变量表的位置1的整数直接加1,操作栈的数保持不变直接弹出存储到局部变量表位置1中,覆盖掉了自增后的值

对比 i = ++i 的指令,指令是先自增再加载到操作栈中,因此i等于1

1
2
3
4
5
6
0 iconst_0
1 istore_1
2 iinc 1 by 1
5 iload_1
6 istore_1
7 return

字节码文件常见的工具

除了上文提到了 jclasslib 工具外,还有很多工具可以查看并分析字节码文件,它们的适用场景各不相同。

javap

javap 的 jdk 内置的反编译工具,可以反编译字节码文件并通过控制台查看字节码文件的内容。

语法:javap

适用:适合在服务器上查看字节码文件内容

options 的选项如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
--help -help -h -?               输出此帮助消息
-version 版本信息
-v -verbose 输出附加信息
-l 输出行号和本地变量表
-public 仅显示公共类和成员
-protected 显示受保护的/公共类和成员
-package 显示程序包/受保护的/公共类
和成员 (默认)
-p -private 显示所有类和成员
-c 对代码进行反汇编
-s 输出内部类型签名
-sysinfo 显示正在处理的类的
系统信息(路径、大小、日期、SHA-256 散列)
-constants 显示最终常量
--module <module> -m <module> 指定包含要反汇编的类的模块
-J<vm-option> 指定 VM 选项
--module-path <路径> 指定查找应用程序模块的位置
--system <jdk> 指定查找系统模块的位置
--class-path <路径> 指定查找用户类文件的位置
-classpath <路径> 指定查找用户类文件的位置
-cp <路径> 指定查找用户类文件的位置
-bootclasspath <路径> 覆盖引导类文件的位置
--multi-release <version> 指定要在多发行版 JAR 文件中使用的版本

常见用法:

到服务器的指定jar包路径下,执行jar -xvf命令解压jar包

1
jar -xvf xx.jar

执行解压后,会生成一个BOOT_INF目录,执行javap查看字节码信息

1
javap -v -p xx

在终端控制台可能查看的效果不好,可以直接将展示的信息写入到txt文件中

1
javap -v -p xx > /xx/xx.txt

IDEA插件jclasslib

搜索安装插件后,编译代码,选中当前类,点击 视图 - Show Bytecode With Jclasslib 来查看该类编译后的字节码显示面板

image-20231229154246747

Arthas

上述工具都是根据源代码编译后的字节码文件,来查看字节码文件的信息,而如果只有正在运行的程序,如何查看字节码的信息?

Arthas 是一款 线上java应用 性能监控和诊断工具,可以全局实时的查看应用负载,内存清空,垃圾回收,线程状态等信息。

在不修改应用代码的情况下,对业务问题进行诊断,包括查看方法调用的出入参、异常,监测方法执行耗时,类加载信息等,大大提升线上问题排查效率。

快速开始

下载arthas-boot.jar,然后用java -jar的方式启动:

1
2
curl -O https://arthas.aliyun.com/arthas-boot.jar
java -jar arthas-boot.jar
功能概览

相关命令教程可以查看地址:https://arthas.aliyun.com/doc/commands.html

image-20231229164553637

本章主要介绍如何查看字节码信息:

dump

dump 已加载类的字节码文件(到特定目录 - 可选)

参数:

参数名称 参数说明
class-pattern 类名表达式匹配
-c 类所属 ClassLoader 的 hashcode
-classLoaderClass 指定执行表达式的 ClassLoader 的 class name
-d 设置类文件的目标目录
1
dump -d /xxx/xxx class-pattern
jad

dump 反编译指定已加载类的源码

适用于 查看线上应用的源代码

参数名称 参数说明
class-pattern 类名表达式匹配
-c 类所属 ClassLoader 的 hashcode
-classLoaderClass 指定执行表达式的 ClassLoader 的 class name
1
jad class-pattern

类加载

类的生命周期

类的生命周期描述了一个类从加载到虚拟机的内存、到使用、最后到卸载出内存的整个过程。

掌握类的生命周期可以更好地理解和优化执行的过程,是后续很多知识点的基础。类加载器的作用、运行时常量池、多态、类的加密和解密 与 类的生命周期都有密不可分的关联

类的生命周期会经历 加载(Loading) - 连接(Linking) - 初始化(Initialization) - 使用(Using)- 卸载(Unloading),其中 连接 包含了三个部分分别是 验证(Verification)、准备(Preparation)和解析(Resolution)。

image-20231229215507721

加载

加载阶段是类的生命周期的第一阶段

主要是将类的字节码从磁盘或其他介质加载到内存中,并为之创建一个 java.lang.Class 类的实例。

  • 类加载器通过一个类的全限定名从磁盘、其他介质等中获取类字节码文件(二进制字节流)
  • jvm 将 字节码加载到内存中,并生成一个表示类信息的数据结构InstanceKlass 存储在 方法区 中
    • InstanceKlass 保持了类信息的基本信息、字段、方法等以及实现特定功能的信息(例如:实现多态的虚方法表)
  • 在 jvm 的堆中的创建一个 java.lang.Class 类的实例,作为对方法区类信息的访问入口
    • java.lang.Class 类是 jvm 运行时自动创建和维护的,代表一个类的元数据信息,是在 java 层面上对类信息的一种抽象表示,包含了描述类的各种信息,可以用于获取类的字段、方法、构造器等信息。
image-20231230160545240
java.lang.Class类的实例

为什么在堆中保存 java.lang.Class 类的实例?

  • java 语言层面访问:java.lang.Class 类提供了一些 java 语言层面操作类信息的方法
    • 例如获取类的名称,获取父类,获取实现的接口等,这些方法是通过 java.lang.Class 类提供的,而不是通过 InstanceKlass 类
  • 反射:java 的反射机制就是基于 java.lang.Class 类实现的。通过反射可以在运行时获取并操作类的信息,创建类的实例、调用方法等。
  • 动态代理: 动态代理也是基于 java.lang.Class 实现的。通过 Proxy.newProxyInstance 方法,可以动态地生成代理类,并在运行时处理代理类的方法调用。

总结:虽然 InstanceKlass 在虚拟机层面保存了类的底层信息(c语言),用于虚拟机层面管理类的信息,但 java.lang.Class 对象提供了更高层次的抽象,使得开发者可以更方便地在 java 代码中访问和操作类的信息,以便实例化对象和访问类的成员变量等。

如何在代码层面获取 Class 对象即 java.lang.Class 类的实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
# 使用.class字面常量
Class<?> clazz = xxClass.class;

# 使用 Class.forName 方法
try {
Class<?> clazz = Class.forName("com.example.xxClass");
} catch (ClassNotFoundException e) {
e.printStackTrace();
}

# 使用对象的 getClass 方法
XxClass classObj = new xxClass();
Class<?> clazz = classObj.getClass();

java.lang.Class 类的实例 和 new 构建的对象实例不同:

java.lang.Class 是 jvm 运行时自动创建和维护的,代表一个类的元数据信息,是在 java 层面上对类信息的一种抽象表示,而 new 构建的对象实例是在堆内存中为该类创建一个对象的实例,代表着类的一个具体对象,使用 new 关键字创建对象,都会在堆内存中分配一块新的内存空间,实例相互独立;

java.lang.Class 实例通常不会被垃圾回收,一旦创建,它就会一直存在于应用的生命周期中,直到应用结束。而 new 的对象实例在堆内存中分配,它的生命周期和引用情况有关,当对象不再被引用了就会变成一个不可达对象,在垃圾回收机制中成为一个潜在可回收的对象。

查看jvm内存结构

jdk 自带的hsdb工具 可以查看 jvm内存信息,工具位于 jdk 安装目录下的lib文件夹中的 sa-jdi.jar 中

启动命令

1
java -cp sa-jdi.jar sun.jvm.hotspot.HSDB # -cp 指定HSDB为启动类

连接

连接(Linking)阶段包含了三个部分分别是 验证(Verification)、准备(Preparation)和解析(Resolution)。

验证

验证(Verification):检测类的字节码文件是否合法以及符合《java虚拟机的规范》来确保字节码的安全性和正确性。目的是确保jvm能够安全地执行这些类的字节码,防止恶意代码和非法字节码的执行。

准备

准备(Preparation):jvm 为类的静态变量分配内存并为设置初始值。这里的初始值为一个初始默认值(初始赋值是在初始化时进行赋值),通常是数据类型的零值,如零、null、false等。

假设一个类的静态变量定义为 public static int value = 1; 在准备阶段后,初始值为0,这时候尚未执行任何java方法,把value赋值为1的会在初始化才会执行(赋值的put static指令在程序编译后存放在类构造器<clinit>()方法之中)

注意 final 修饰的基本数据类型的静态变量,在准备阶段会分配内存并进行赋值。final 表示一个常量时候,一旦赋值就不能被修改。在准备阶段就被确定,并且在后续的初始化阶段不会再改变。

解析

解析(Resolution):将类、字段、方法等符号引用解析为直接引用的过程。直接引用不再使用编号,而是使用内存地址引用,以便jvm更高效的执行。

初始化

jvm 执行类的初始化代码,为静态变量正确的赋值并执行静态代码块。

初始化阶段会执行字节码文件中<clinit>()部分的字节码指令。

类初始化是一个线程安全的操作,多个线程同时尝试初始化同一个类时,只有一个线程会执行初始化过程,其他线程会等待初始化完成。

初始化阶段的执行是有序的,按照类加载的顺序依次执行。

示例:

1
2
3
4
5
6
7
8
public class SimpleClass {

static {
value = 2;
}

public static int value = 1;
}
image-20231231014601030
1
2
3
4
5
0 iconst_2	# 将整数常量2推送到操作数栈
1 putstatic #7 <SimpleClass.value : I> # 将 栈顶整数(即常量2)存储到SimpleClass类的静态变量value中
4 iconst_1 # 将整数常量2推送到操作数栈
5 putstatic #7 <SimpleClass.value : I> # 将 栈顶整数(即常量1)存储到SimpleClass类的静态变量value中
8 return # 返回,方法执行完毕

从上面示例中,我们可以看出 初始化阶段时按照类加载的顺序依次执行 即 clinit 方法中的执行顺序与源代码编写顺序一致。

初始化的触发
  • 创建类的实例

通过new关键字创建类的实例时,如果当前类没有被初始化,则会触发类的初始化过程。

1
XxClass xxClass = new XxClass(); 
  • 访问类的静态变量和静态方法(不被final修饰的)
1
2
int staticVariable = MyClass.staticVariable;
MyClass.staticMethod();
  • 调用 class.forName 方法
1
Class<?> clazz = Class.forName("com.example.XxClass");
  • 执行 main 方法的当前类
  • 初始化子类会导致会导致父类的初始化(优先初始化父类)
1
2
3
4
5
6
7
8
9
10
11
12
13
public class SubClass extends SuperClass {
static {
System.out.println("SubClass initialization");
}
}

public class SuperClass {
static {
System.out.println("SuperClass initialization");
}
}

SubClass subClass = new SubClass(); // 先触发 SuperClass 类的初始化,再触发 SubClass 类的初始化

添加参数 -XX:+TraceClassLoading 可以打印出加载并初始化的类

使用

执行程序代码,创建类的实例

卸载

释放不再被使用的类的内存

类加载器

类加载器 (classloader)负责将字节码文件(.class文件)加载到内存中。jvm 提供给应用程序实现获取字节码的技术。

在整个加载阶段在通过类加载器将字节码文件加载到内存中后,类加载器会调用本地接口(JNI - java native interface 允许使用java语言调用jvm本地方法 c++编写的方法)来在方法区生成类信息的数据结构InstanceKlass和堆上生成java.lang.Class类的实例。所以类加载器在加载阶段只负责获取字节码并加载到内存中。

类加载器分类

从java虚拟机的角度出发,类加载器分为两类:jvm底层实现的类加载器和java代码自定义类加载器。

  • jvm 底层实现的类加载器

    源代码位于 jvm 虚拟机的源码中,实现语言和虚拟机底层实现语言一致

    作用:保存程序运行中的类被正确的加载,保证其可靠性。

  • java 代码自定义类加载器

    jdk 中默认提供了多种处理不同介质来源的类加载器,开发者也可以自定义类加载器 以实现特定的加载需求。

    自定义加载器需要继承 ClassLoader类并实现 findClass 方法。

在jdk8及之前的版本中,默认的类加载器包括以下几种:

  • jvm 底层实现的类加载器

    • Bootstrap Class Loader - 启动类加载器:加载java的核心类库
  • java 代码自定义类加载器

    • Extension Class Loader - 扩展类加载器:加载java的扩展类库
    • Application Class Loader - 应用程序类加载器:加载应用程序中的类

如何查看当前程序远行时的类加载器的详细信息,可以通过 arthas 中的 classloader命令来查看。

classloader命令 查看 classloader 的继承树,urls,类加载信息。

image-20240101144226364
启动类加载器

启动类加载器(Bootstrap ClassLoader)由虚拟机提供,由虚拟机底层实现。

默认加载jvm安装目录/jre/lib下的类文件。

由于启动类加载器由jvm底层实现,我们无法通过java源代码xxClass.getClassLoader()和arthas工具直接获取到,所以当为null时,即该类由启动类加载器加载。

如何让启动加载器加载一些开发者扩展的类,用于jdk基础类的扩展:

  • 将开发者扩展的jar包放入到/jre/lib目录下扩展(不推荐 - 文件名需要满足jvm规范,不满足则不会被加载)
  • 使用启动参数 -Xbootclasspath:/xx/xx.jar 来扩展。
扩展加载器和应用加载器

扩展加载器和应用加载器由jdk提供,使用java语言实现,它们的源码是位于 sun.misc.launcher 中的一个静态内部类,继承自 URLClassloader 以具备通过目录或者指定jar包将字节码文件加载到内存中。

下图继承关系如下:

image-20240101151806770

ClassLoader:抽象类。定义了类加载器的具体行为模式,将字节码文件加载到内存中后通过JNI调用底层的jvm方法来来在方法区生成类信息的数据结构InstanceKlass和堆上生成java.lang.Class类的实例。

SecureClassLoader:使用证书机制提升类加载器的安全性。

URLClassLoader:利用URL获取目录下或者执行的jar包进行加载,获取字节码信息。

扩展类加载器

扩展类加载默认加载 jdk 安装目录下的 /jre/lib/ext 下的类文件。

和上面一样,可以使用启动次数 -Djava.ext.dirs = /xx/xx/ 修改扩展加载器加载的目录位置进行扩展,注意该方法会覆盖掉原始目录,因此可以使用追加的方式进行添加目录(;使用分号分割) -Djava.ext.dirs = /jre/lib/ext/;/xx/xxx/

应用类加载器

应用类加载器加载 classpath 下的类文件即加载项目中开发者编写的类和接口文件和第三方jar包类和接口的文件。

通过 classloader -c hash值 可以查看类加载器的加载目录和文件

image-20240101154437880

双亲委派机制

双亲委派机制(Delegation Model)是Java中类加载器(ClassLoader)的一种工作机制,用于加载Java类和资源。

它的作用是将类加载的责任委托给父类加载器,从而形成一种层次结构,保证类加载的顺序和一致性。

简而言之,jvm 中有多个类加载器,双亲委派机制解决了一个类到底由哪一个加载器加载的问题。通过双亲委派机制可以保持类加载的有序性、避免类的冲突、提高安全性并且能够实现类的共享。

双亲委派模式对类加载器定义了层级,如图所示。

image-20240102113437845

双亲委派机制:当一个类加载器处理类加载任务时,会自低向上的查找类是否加载过,再由顶向下的进行尝试加载。

当一个类需要加载的时候,首先自低向上委派父类加载器进行检查是否加载过,如果检查加载过,会直接返回class对象,加载过程结束,这样能避免一个类的重复加载。如果未加载过,则由顶向下的进行尝试加载,如果所有父类无法加载该类,则由当前类加载器尝试加载。

本质上双亲委派机制是起到了一个加载优先级的作用。

可以通过 classloader -t 查看类加载器的层级信息。

image-20240102091656380

双亲委派机制的作用
  • 保证类加载的安全性

    • 防止恶意类的加载。java核心类库都只能由被信任的类加载器加载,确保了系统的安全性。
  • 避免同一个类的重复加载:

    • 双亲委派机制通过一层一层的委派,确保了类加载的顺序,防止同一个类被多个类加载器加载,从而避免了类的冲突和混乱。
    • 当一个类加载器需要加载类时,它首先会委派给其父类加载器去尝试加载。如果父类加载器已经加载了该类,就不会再次加载,避免了重复加载和的性能开销。提高了性能,也保证了类不会冲突和混乱。
  • 确保类的一致性

    • 同一个类在在整个类加载器层次结构中只有一份,不会出现不同加载器加载同名类导致类型不一致的问题。

如何打破双亲委派机制

为什么要打破双亲委派机制?

先举例一个场景,在tomcat中运行多个Web应用,如果两个应用中存在相同限定名的类,通常情况下双亲委派机制会导致只有第一个加载的类被使用。

为了解决这个问题,tomcat使用不同的类加载器实现应用之间类的隔离。每个web应用会有一个自己独立的类加载器加载对应的类。

双亲委派机制的实现原理

要想打破双亲委派机制,首先要了解双亲委派机制的实现。

查看类加载类的抽象类 Classloader,该类中有4个核心方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
# 类加载的入口方法,默认实现会委托给双亲类加载器来完成加载
# 在加载类的过程中,它会调用findClass方法来查找类,并最终调用defineClass方法来定义类
public Class<?> loadClass(String name);

# 用于在类加载器的查找过程中实际定位和加载类的字节码
# 默认的ClassLoader中findClass方法是抽象的,具体实现由子类加载器实现
protected Class<?> findClass(String name);

# 校验类名,调用了虚拟机底层方法将类的字节码转换为一个Class对象并加载到虚拟机内存中,
protected final Class<?> defineClass(String name, byte[] b, int off, int len);

# 用于连接并准备类,确保类的正确性
protected final void resolveClass(Class<?> c);

查看类加载的入口的方法 loadClass(String):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public Class<?> loadClass(String name) throws ClassNotFoundException {
return loadClass(name, false);
}

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) {
// 如果parent即父类加载器不为空,则委派到父加载器加载
c = parent.loadClass(name, false);
} else {
// 如果parent为空,则说明当前类为扩展加载器,其父加载器为启动器加载器,委派到启动器加载器加载
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}

if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
long t1 = System.nanoTime();
// 父类加载器都没有加载到,则调用当前类加载器进行加载,在classloader抽象类中,并没有实现findClass的具体逻辑,而是由子类继承类并重写实现方法
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();
}
}
// 判断是否执行连接阶段
if (resolve) {
resolveClass(c);
}
return c;
}
}
打破双亲委派机制的方式
  • 自定义类加载器

查看上面代码查看双亲委派机制的具体实现,因此需要打破双亲委派机制,则需要继承 ClassLoader 类并对于 loadClass 方法进行重新实现。

1
2
3
4
5
6
7
public class CustomClassLoader extends ClassLoader {
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
// 具体实现来获取字节码 byte[] bytes
return defineClass(name, bytes, 0, data.length);
}
}

自定义加载器如果没有手动设置parent,默认会指向应用程序类加载器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
 // ClassLoader类中提供了构造方法设置parent的内容
private ClassLoader(Void unused, ClassLoader parent) {
this.parent = parent;
if (ParallelLoaders.isRegistered(this.getClass())) {
parallelLockMap = new ConcurrentHashMap<>();
package2certs = new ConcurrentHashMap<>();
assertionLock = new Object();
} else {
// no finer-grained lock; lock on the classloader instance
parallelLockMap = null;
package2certs = new Hashtable<>();
assertionLock = this;
}
}

// 无参数构造方法,父类加载器由getSystemClassLoader()方法设置,该方法返回的是 appClassLoader
protected ClassLoader() {
this(checkCreateClassLoader(), getSystemClassLoader());
}

如果在自定义加载器加载相同限定名的类,并不会产生任何冲突,在jvm中,只有相同类加载器和相同类限定名才可以被认定为是同一个类

使用arthas的 sc -d 类名 查看类的具体的情况

如果不想打破双亲委派机制,从不同的渠道获取类字节码,则可以自定义类加载继承classloader抽象类并重写findclass方法,实现获取类字节码并调用defineClass来加载内存。

  • 线程上下文类加载器

    jdbc和jndi等使用了线程上下文加载器加载类,下面我们以jdbc为例,说明线程上下文加载器

    jdbc 中使用 driverManager 来管理项目中引入的不同数据库的驱动,以实现对接不同数据库的目的

    在 driverManager 加载驱动jar包中类的过程中,我们发现它打破了双亲委派机制

    driverManager类位于 rt.jar 包中,由启动类加载器bootstrapClassLoader进行加载,而 driverManager 需要加载引入不同的数据库驱动类,这些类由应用类加载器加载。driverManager 由 启动类加载加载,而 driverManager 管理的驱动类需要委托给应用程序类加载器进行加载,这违反了双亲委派机制。

    首先,我们需要了解 driverManager 如何知道jar中需要加载的驱动类的位置的,这里使用就是SPI机制(Seriver Provider Interface) - JDK 内置的一种松散耦合的服务提供发现机制。

    在数据库驱动的加载中,DriverManager通过 SPI 机制来查找和加载具体的数据库驱动。

    SPI的工作原理:

    • 驱动加载配置文件:

      在驱动jar中的classpath路径下的META-INF/services/文件夹z中,有以接口的全限定名来命名文件名,文件中有具体接口实现类的全限定名。

    • ServiceLoader 加载驱动

      ServiceLoader 是 java 提供的用于加载服务提供者的工具类,它会在类路径下查找 META-INF/services/ 目录下的配置文件,并加载其中指定接口的服务提供者的类名,通过类加载器加载对应的类并创建对象注册到 DriverManager 中。

      image-20240102112120324

    在 SPI 中,驱动的jar包的类是通过应用加载器加载的,那么 driverManager 是通过启动器加载器的,那么启动器加载器是如何获取应用程序类加载器的?

    SPI 中使用了 线程上下文 保存类加载器进行类的加载,该类加载器一般是应用程序类加载器。

    image-20240102112802722

  • osgi框架类加载器

OSGi框架的类加载机制是基于模块化的概念的。在OSGi中,应用程序被组织成模块(bundles),每个模块都有自己的类加载器。它存在同级之间的类加载器的委托加载。

热部署的类加载

热部署是指服务在不停机的情况下,动态地更新字节码文件到内存中。

使用arthas实现热部署

  • 将反编译的文件写入到执行文件中并修改文件源代码

    jad –source-only 全限定类名 > /xx/xxx.java

  • 查看当前类的类加载器的hashcode

    sc -d xx.java

  • 编译修改过的源代码

    mc -c 类加载器的hashcode /xx/xxx.java -d /jar包所在目录

  • 使用 retrantsform 命令加载新的字节码

    retransform /类字节文件所在目录/xxx.class

注意:

注意:retransform 命令是将字节码信息更新到内存中,程序重启之后,字节码文件会恢复

retransform 命令无法在已经在的类上添加字段和方法。因为 arthas 允许现有方法的修改,比如添加、修改或删除方法体中的代码,但不支持结构性的更改,比如添加字段或者方法

retransform 命令也不能作用于正在执行的方法中,正在执行的方法字节码是被锁定的,此时不能将其进行重新转换。

jdk 类加载器的变化

jdk9前后类加载器发生了变化。

在jdk9之前,扩展类加载器和应用程序加载器的源代码位于 rt.jar 包中 sun.misc 包中,这两个都继承自 java.net.URLClassLoader (通过 指定目录 找到jar包以及jar包中的字节码文件)

在jdk9之后,引入module的概念,加载器类不在存放在jar包中,java 类 会被模块化为一个一个jmod文件,字节码不在从jar包中获取,而是从jmod文件中获取。

在jdk9及之后,启动类加载器不在由jvm底层的c/c++编写,使用java编写,位于jdk.internal.loader.ClassLoaders 类中。同时启动类加载器会继承自 BuiltinClassLoader 实现从模块文件中找到要加载的字节码资源文件。值的注意的是,尽管如此,我们依旧无法在java代码中获取到启动类加载器,获取到结果依旧是 null, 这是为类保证统一。

新增的类加载器 平台类加载器(Platform Class Loader)遵循模块化方式加载字节码文件,以从模块中加载字节码文件到内存中。

总结

类加载器的作用:负责在类加载的过程中,获取字节码并加载到内存中,并通过JNT本地方法调用调用底层方法将来在方法区生成类信息的数据结构InstanceKlass和堆上生成java.lang.Class类的实例

常见的类加载器:启动类加载器 - 加载java核心类

​ 扩展类加载器 - 加载java扩展类

​ 应用程序加载器 - 加载应用classpath中的类

     自定义类加载器 - 重写 findClass 方法

双亲委派机制:类加载器的层次结构

​ 自低向上的判断是否加载过,再自顶向下的进行加载,避免核心类被应用程序重写导致安全问题,保证加载类的顺序性和一致性,避免重复加载,提高了性能

如何打破双亲委派机制:

继承classloader类重写loadClass方法,不再实现双亲委派机制。

使用SPI机制加载类字节码使用了上下文类加载器。

OSGi框架实现了一整套类加载机制,允许同级类加载器的相互调用。

内存结构

jvm 在执行java程序的过程中管理的内存区域被称为 运行时数据区,在执行过程中,运行时数据区划分为若干的不同数据区域。

根据《Java虚拟机规范》的规定,运行时数据区将会包括以下 运行时数据区域:

image-20240102141247168

线程私有:程序计数器 本地方法栈 虚拟机栈

线程共享:方法区 堆

运行时内存结构

程序计数器

程序计数器(Program Counter Register)是每个线程会通过程序计数器记录当前需要执行的字节码指令的内存地址,可以看作当前线程所执行的字节码的”行号指示器”,属于线程私有的,是一个较小的固定长度的内存空间。

字节码指令在jvm类的加载阶段被加载到内存中,会源文件中的指令偏移量转换为内存地址,每一条字节码指令都会有一个对应的内存地址

image-20240102160613196

在代码的执行过程中,程序计数器记录 下一个字节码指令的内存地址,当当前指令执行完成后,虚拟机的执行引擎会根据程序计数器记录的内存地址找到对应的指令来执行下一行指令

程序计数器的作用
  • 程序计数器是程序控制流的指示器,可以实现分支、循环、跳转、异常等逻辑。

  • 在多线程运行的情况下,线程轮流切换来分配处理器执行时间,程序计数器会记录当前线程接下来需要执行的指令,以便线程切换后恢复到正确的执行位置从而继续执行。

根据《Java虚拟机规范》,程序计数器是没有规定任何OutOfMemoryError情况的区域,同时开发者无需对于程序计数器做任何的处理。

虚拟机栈

java 虚拟机栈(Java Virtual Machine Stack)是 采用栈的数据结构来保存每个方法调用的基本数据。栈是一种先进后出的数据结构,每一个方法的调用信息将使用一个栈帧(Stack Frame)来保存即每个栈帧对应一个个调用方法调用。

虚拟机栈描述的是java方法执行的线程内存模型:每个方法被执行的时候,jvm 都会同步创建一个栈帧用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程即每个方法执行伴随着入栈(进栈/压栈),方法执行结束出栈,线程上的每一个方法都对应着一个栈帧。

虚拟机栈的生命周期和线程相同,随着线程的创建而创建,随着线程的销毁而回收。

通过IDEA的debug工具可以查看栈帧的内存

image-20240102170325870
栈帧的组成

虚拟机栈的栈帧中,存储着:局部变量表(Local Variables)、操作数栈(Operend Stack)、动态链接(Dynamic Linking)、方法返回地址(Return Address)、附加信息等。

jvm-stack-frame

image-20240110164003715

局部变量表

用于存储方法参数和方法内部定义的局部变量。局部变量表的容量是在编译期确定的,存储的数据类型包括基本数据类型(如int、float)和对象引用

栈中的局部变量表本质上是一个 数组,数组中每一个位置被称之为一个槽(slot),其中 long 和 double 类型占用两个槽,其他类型和引用类型专用一个槽

在实例方法中,局部变量序号为0的位置存放的是this,指的是当前调用方法的对象(即对象实例的引用),运行时会在内存中存放对象的地址。当静态方法中则无需在序号为0的位置存放对象的引用。方法参数也需要保存在局部变量表中,其顺序和方法参数定义顺序一致。

因此局部变量表中包含了方法参数、局部变量、对象实例的引用和返回值等。

为了节省局部变量表的空间,局部变量表中的槽是可以复用的,一旦某个局部变量不再生效,当前槽就可以被复用。

操作数栈

用于执行计算操作的栈。在方法执行过程中,操作数栈用于暂存计算过程中的中间结果。

在编译器可以确定操作数栈的最大深度,从而执行时正确的分配内存大小。

动态链接

动态链接指向方法区的运行时常量池的方法引用,用于方法调用过程中的动态链接。

在类的生命周期的连接-解析阶段,会将符号引用转换为直接引用。当前类的字节码指令引用了其他类的属性和方法时,符号引用在类的连接阶段不会直接变成直接引用,因此在运行时需要将符号引用和对应运行时常量池中的内存地址映射关系。

动态链接中保存了符号引用到运行时常量池的内存地址的映射关系。当字节码指令执行过程中,就可以根据当前的符号引用从动态链接中快速找到运行时常量池对应的数据。

jvm-dynamic-linking
方法出口

当一个方法执行结束或者异常后,会从栈顶弹出,此时程序计数器需要知道下一个方法指令的地址,而方法出口保存了指向下一个方法指令的地址。

异常表

异常表存放了代码中的异常的处理信息,包含了try代码和catch代码指令执行后跳转字节码的指令位置。

虚拟机的栈内存

在java虚拟机栈的大小一般是固定的,即在启动时通过命令行参数或者其他配置方法指定并在运行时不会动态的调整;jvm 如果栈帧过多,因此会出现内存溢出。

在《Java虚拟机规范》中,如果线程请求分配的栈容量超过虚拟机所设置允许的最大容量,将抛出栈溢出StackOverflowError异常。

在不指定栈的大小的情况下,jvm 会创建一个默认大小的栈,栈的大小取决于操作系统和计算机的体系结构。

可以通过虚拟机参数-Xss或者 -XX:ThreadStackSize 来设置线程的最大栈空间。

语法:

1
2
-Xss <size> (默认字节,此时必须1024的倍数,其他单位:k和kb,m和MB, g和GB)
-XX:ThreadStackSize=<size>

注意:hotspot jvm 对栈大小的最大值和最小值有要求

​ 栈的可达深度取决于栈的大小和每个栈帧的大小

调优:在一般情况下,栈的大小不会超过256k,通过减小栈的大小,可以在一定程度上减小每个线程消耗的内存,手动指定-Xss256k来节省内存

本地方法栈

java 虚拟机栈存储的是java方法调用的栈帧,而本地方法栈存储的是native本地方法的栈帧。

在hotspot虚拟机上,java虚拟机栈和本地方法栈实现上使用了同一个栈空间。

java堆是jvm管理的内存最大的一部分,被所有线程共享,该区域用于存放对象实例。

在java虚拟机栈的栈帧上的局部变量表上,可以存放了堆上对象的引用,方法区的静态变量可以存放堆对象的引用(因此通过静态变量可以实现对象在线程之前的共享)

java 虚拟机规范规定,堆大小存在上限,如果在java堆中没有内存完成实例分配,并且堆也无法再扩展时,java虚拟机将会抛出OutOfMemoryError异常。

堆空间有三个值得关注的值:used、total、max

used(已使用):表示当前已经被使用的堆空间大小

total(总计):已经分配的可用堆内存

max(最大):可以分配的最大堆内存

当堆内存达到max时,应用程序尝试分配更多内存超过这个值时,会触发OutOfMemoryError错误。

可以使用arthas的 dashbroad 和 memory 命令查看jvm的堆内存占用情况:

image-20240103110849137

参数设置

在不设置任何的虚拟机参数的情况下,max(即已经分配的可用堆内存)默认是系统内存的1/4,total(即可以分配的最大堆内存)默认是系统内存的1/64。在实际应用中一般都需要设置 total和max的值

可以使用虚拟机参数 -Xmx-Xms 来分别设置 total 和 max:

1
-Xms <size> -Xmx <size>

限制:Xmx必须大于2MB,Xms必须大于1MB

调优:建议将 -Xmx-Xms 设置为相同的值,这样程序启动时,堆空间一次性分配,避免了在程序运行时,内存不足动态申请和分配堆空间的开销,以及内存过剩时堆内存收缩的情况。

方法区

方法区(method area)被所有线程共享,该区域用于存储被虚拟机加载的类型信息,常量、静态变量、即时编译器优化后的代码等数据的内存区域。

方法区设计

方法区是 jvm 规范中定义的一个概念,不同 jvm 实现不同,hotspot在不同的版本的设计也不同:

  • jdk8之前的版本,方法区存放在堆区域的永久代空间(permanent generation),堆大小由虚拟机参数 -XX:MaxPermSize=<size> 控制

  • jdk8及之后的版本,方法区存放在元空间(namespcace),元空间位于操作系统维护的直接内存中,默认情况下,不超过操作系统内存上限,即可一直分配。但是可以使用参数 -XX:MaxMetaspaceSize=<size>设置元空间的最大大小

    image-20240103131055200

方法区组成
类的基础信息

方法区存储了每一个被加载的类的基础信息(即元信息),一般称为InstanceKlass对象(其中包含了类基本信息,常量池引用、字段引用、方法引用、虚方法表等)。

运行时常量池

此外,方法区存放了运行时常量池,运行时常量池存放的是字节码中的常量池的内容。在加载阶段,字节码中的常量池被加载到方法区,此时为常量池中的值为符号引用,所以被称之为静态常量池。在连接阶段后,符号引用转换为直接引用,可以通过内存地址快速定位常量池中的内容,此时常量池为运行时常量池。

字符串常量池

方法区还有一个区域是字符串常量池(StringTable),字符串常量池用于存储在代码中定义的常量字符串的内容。

image-20240103134240458

jdk7前方法区采用永久代存放在堆中,运行时常量池逻辑上包含了字符串常量;jdk7将字符串常量池从永久代中移除,放到了堆上;jdk8及之后版本,hotspot 移除了永久代使用了元空间取而代之,字符串常量却仍然存在于堆中。

image-20240103154047381

示例

1
2
3
4
5
6
7
8
9
10
11
public class SimpleClass {

public static void main(String[] args) {
String a = "1";
String b = "2";
String c = "12";
String d = a + b;
System.out.println(c == d); # 输出 false
}

}

查看字节码指令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
 0 ldc #7 <1> # 将字符串常量池中索引为7的地址引用推送至栈顶
2 astore_1 # 栈顶地址引用保存到局部变量表的第一个位置
3 ldc #9 <2>
5 astore_2
6 ldc #11 <12>
8 astore_3
9 aload_1
10 aload_2
11 invokedynamic #13 <makeConcatWithConstants, BootstrapMethods #0> # 动态调用字符串拼接,生成一个新的字符串存放在堆内存中
16 astore 4 # 将新的字符串的堆内存地址引用保存到局部变量表的第四个位置
18 getstatic #17 <java/lang/System.out : Ljava/io/PrintStream;>
21 aload_3
22 aload 4
24 if_acmpne 31 (+7)
27 iconst_1
28 goto 32 (+4)
31 iconst_0
32 invokevirtual #23 <java/io/PrintStream.println : (Z)V>
35 return

== 比较的是两者的内存地址引用是否相等,显然上面一个是c在局部变量中存储的是 字符串常量池的引用,d时在堆内存上的字符串地址引用。

1
2
String d = "1" + "2";
System.out.println(c == d); # 输出 true

字符串字面量的拼接在编译时就会被优化直接连接,d直接引用字符串常量池的地址,因此地址一样输出 true

String的intern

String的intern方法用于在运行时将字符串对象添加到字符串常量池中,并返回字符串常量池中对应的引用地址。在jdk7前,intern()方法会将第一次遇到的字符串实例从堆内存中复制到永久代的字符串常量池中, 此时返回的是永久代中字符串常量池的字符串的实例的引用。而jdk7及之后,方法会将第一次遇到的字符串的引用存入到字符串常量池中,返回到是字符串常量池的字符串的引用的引用,因此次数它的字符串地址和堆内存地址相同。

类的静态变量

类的静态变量的存储在哪里?

jdk6及之前的版本中,静态变量是存放在方法区中的 instanceKlass,也就是永久代中。

jdk7及之后的版本中,静态变量是存放在堆中的java.lang.Class对象中

直接内存

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中 定义的内存区域。但是 在jdk8及之后,方法区存储在直接内存中。

直接内存解决了jdk1.4引入NIO机制后带来了的问题:

NIO 支持使用 native 函数直接分配直接内存,通过存储在java堆中的DirectByteBuffer 对象作为这块内存的引用进行操作,避免了数据在直接内存和堆中的来回复制,提升了性能。

image-20240103152502254

使用 arthas 的 memory 命令可以看到 直接内存的情况

image-20240103152750154

直接内存的配置

直接内存的分配收到操作系统的影响,不可能无限制的增大,当各个内存区域总和大于物理内存限制,会出现OutOfMemoryError异常。

使用参数 -XX:MaxDirectMemorySize=<size> 来手动调整直接内存的大小,在默认情况下,jvm会自动选择最大分配的大小(如果系统底层使用了NIO,建议手动设置)。

内存溢出问题

内存溢出(Memory Overflow)指的是程序在运行过程中请求的内存超过了虚拟机所能提供的最大限制,导致程序运行出现错误。

垃圾回收机制

在c/c++语言中,没有自动垃圾回收机制,一个对象如果不再使用需要手动进行释放即进行垃圾回收,否则就会出现内存泄漏(Memory Leak)。

内存泄漏和内存溢出

内存泄漏Memory Leak):程序中已经不再使用的对象或者资源没有被正常释放,导致这部分内存无法再被程序访问和利用。

内存溢出Memory Overflow):程序在运行过程中需要的内存超过了系统所能提供的最大限制,导致程序无法正常运行。

内存泄漏的积累会导致内存溢出

java 简化了对象的释放,引入了自动垃圾回收(Garbage Collection,下文简称GC)机制。通过垃圾回收器对于不再使用的对象进行自动的回收。

垃圾回收器属于jvm执行引擎的一部分,主要负责对堆上的不使用的对象内存进行回收。

对于线程私有的部分(程序计数器、java虚拟机栈、本地方法栈)是无需垃圾收集器进行回收的,因为这三部分是存放在线程内部的,伴随着线程的生命周期,随着线程的创建而创建,随着线程的销毁而销毁。同时,方法栈帧会在方法执行完毕后,栈帧会自动弹出栈并释放栈的内存。

方法区的回收

对于方法区中的回收主要是 不再使用的类:

判断一个类是否可以被回收,需要同时满足以下条件:

  • 类的所有实例对象均被回收:堆内存中不存在该类实例对象和该类的子类对象
  • 类的加载器的生命周期结束
  • 类对应的 java.lang.Class 对象没有被任何地方引用

示例:

1
2
3
4
5
6
7
while(true) {
URLClassLoader urlClassLoader = new URLClassLoader(new URL("/xxx/xxx"));
Class<?> clazz = urlClassLoader.loadClass("com.xxx.xx");
Object o = clazz.newInstance();

System.gc();
}

上面代码中,循环体中的对象在进入下一个循环时就没有引用了,因此会进行回收。

执行方法时,添加虚拟机启动参数:-XX:+TraceClassLoading-XX:+TraceClassUnloading来打印类加载和卸载的信息

System.gc(): 可以手动触发垃圾回收

​ 不一定会立即触发垃圾回收,而是向jvm发送一个垃圾回收的请求,具体是否需要执行垃圾回收则需要虚拟机自行判断。

由于正常程序的类是由应用加载器来加载的,应用加载器在运行的过程中是不会被回收的,所以这些类都在运行中是不会被回收的。

使用场景

在一些例如OSGi和jsp的热部署等应用场景中,例如jsp的热部署,每一个jsp文件对应一个唯一的类加载器,当一个jsp文件修改了,就直接卸载了这个jsp的加载器和类信息,重新创建类加载器,重新加载jsp文件,从而实现jsp热部署。

堆回收

判断对象是否需要回收

java中的垃圾回收机制基于对象的引用关系。一个对象是否能够被垃圾回收主要取决于是否存在对该对象的引用。

1
Coder coder = new Coder();

image-20240103203722674

常见判断的对象是否有被引用的方法有两种:引用计数法和可达性分析法。

引用计数法

引用计数法(Reference Counting)为每个对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。

引用计数器实现简单,判定效率也很高。但是总在一些问题:

  • 每个对象都占用一些额外的内存空间来计数,并且引用的确认和失效都需要维护这个引用计数器,对于系统的开销有一定的影响。

  • 存在循环引用问题

    如果对象objA和objB都有字段instance,赋值令 objA.instance=objB及objB.instance=objA,除此之外,这两个对象再无任何引用,实际上这两个对象已 经不可能再被访问,但是它们因为互相引用着对方,导致它们的引用计数都不为零,引用计数算法也 就无法回收它们。

测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class SimpleClass {

public Object instance = null;

public static void main(String[] args) {
SimpleClass objA = new SimpleClass();
SimpleClass objB = new SimpleClass();
objA.instance = objB;
objB.instance = objA;
objA = null;
objB = null;
System.gc();
}
}

使用 -verbose:gc 查看垃圾回收日志

上图可以看到内存回收日志中内存空间变化,意味着jvm没有因为这两个对象互相引用而放弃回收它们,则jvm并不是通过引用计数算法来判定对象是否是否存活。

可达性分析法

jvm 通过 可达性算法(Reachability Analysis)算法来判定对象是否存活的。

可达性算法的对象被分为两类:垃圾回收的根对象(GC Root)和 普通对象。

可达性算法就是通过一系列称为”GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为 “引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连即从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。

image-20240103212130937

GC Root对象

GC Roots的对象包括以下几种:

  • 虚拟机栈中引用的对象(局部变量,方法参数、临时变量等)
  • 本地方法栈中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中的常量引用的对象
  • jvm 内部引用
  • 监视器对象即被同步锁持有的对象
如何查看GC ROOT

通过 arthas 和 eclipse memory analyzer(MAT) 查看 GC ROOT

MAT 工具是 java堆内存的检测工具

  • 使用 arthas 的 heapdump 命令将堆内存快照保存到本地磁盘中
image-20240103220015710
  • 使用 MAT 工具打开堆内存快照文件

  • 选择 GC Roots 功能查看所有的GC Root image-20240103222648481

四种对象引用

无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象是否引用链可达,判定对象是否存活都和”引用”有关。

在JDK 1.2版之后,java 对引用的概念进行了扩充,将引用分为强引用(Strongly Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)4种,这4种引用强度依次逐渐减弱。

强引用

强引用(Strongly Reference)是最传统和最普遍的引用的定义。

1
Object obj = new Object();

回收情况:强引用指向的对象无论任何情况都不会被回收

软引用

软引用用来描述一些还有用但是非必需的对象,软引用通过 SoftReference 类来实现(格式:new SoftReference<对象类型>(对象))。

软引用一般用于缓存中,即对象的存在对程序性能有帮助必须的场景。

1
2
3
Object obj = new Object(); # new Object() 在堆中创建Object示例并使用obj建立强引用
SoftReference<Object> softRef = new SoftReference<>(obj); # new SoftReference在堆中创建软引用对象包装obj并使用softRef建立软引用
obj = null; # 清除obj强引用关联
image-20240104104152984

回收情况:软引用中的对象只有在内存不够的情况下才会被回收

注意:内存不够的情况下,会回收掉使用SoftReference包装的对象,堆中创建的SoftReference对象本身也需要进行回收。

SoftReference 提供了一套队列机制,使用引用队列(ReferenceQueue)来监控软引用对象的回收。通过检查引用队列中是否包含软引用对象,可以得知SoftReference对象是否被回收。

1
2
3
4
5
6
7
8
ReferenceQueue<Object> referenceQueue = new ReferenceQueue<>();
SoftReference<Object> softRef = new SoftReference<>(new Object(), referenceQueue);
...
// 检查引用队列中是否包含软引用对象
SoftReference<Object> polledRef = (SoftReference<Object>) referenceQueue.poll();
if (polledRef != null) {
...
}
弱引用

弱引用用于描述一些非必须的对象,它的强度比软引用更弱一点。弱引用应用于 ThreadLocal 中。

1
2
3
Object obj = new Object();
WeakReference<Object> weakRef = new WeakReference<Object>(obj);
obj = null;

回收情况:当垃圾收集器开始垃圾回收时,无论当前内存是否足够,都会回收掉被弱引用关联的对象。

虚引用

虚引用也称为“幽灵引用”或者“幻影引用”,不能通过虚引用对象获取对象实例。虚引用的唯一用途是 当对象被收集器回收时收到一个系统通知。

jdk 提供 PhantomReference 类来实现虚引用

1
2
3
Object obj = new Object();
PhantomReference<Object> pRef = new PhantomReference<Object>(obj);
obj = null;

垃圾回收算法

对象垃圾回收分为两个阶段:标记阶段和清理阶段

  • 标记阶段(Marking):标记内存中存活的对象
    • 标记过程中,未被标记的对象将被认为是不可达的,即垃圾对象。
  • 清理阶段(Sweeping):垃圾回收器清理并回收那些不存活的对象所占用的内存空间。

jvm 垃圾回收过程会通过单独的垃圾回收线程来执行。因为垃圾回收是相对耗时的操作,如果在主线程中执行,会导致应用程序停顿,影响用户体验。

不同的垃圾回收算法和 jvm 有不同的线程管理策略,但不管使用哪一种垃圾回收算法,都会有部分阶段,整个应用程序的所有线程都被暂停,不再执行任何指令,此阶段被称为 STW 即 stop the world。如果STW的时间越长越会影响用户的体验,因此此过程越短越好。

垃圾回收算法的评价标准
  • 吞吐量:吞吐量是指在一定时间内应用程序实际运行的时间占总时间的比例。高吞吐量通常意味着垃圾回收的效率较高。
    • 总时间 = 应用程序实际运行 + 垃圾回收时间
  • 暂停时间: 应用程序的暂停时间是指在进行垃圾回收时,应用程序中断执行的时间。
    • 低延迟的应用,较短的暂停时间通常是一个关键的性能指标。尤其是最大暂停时间不能过长。
  • 内存利用率:有效提高内存利用率是一个重要的标准。同时减少内存碎片的问题也会减少对于后续的内存分配的影响。

不同的垃圾回收算法需要结合具体应用场景的需求进行选择。

常见的垃圾回收算法
标记-清除

标记-清除(Mark and Sweep):将存活的对象进行标记,然后清理掉未被标记的对象即非存活对象。

image

优点:实现简单(只需要在标记阶段给每一个对象维护一个标志位)

缺点:执行效率不高,标记和清除过程的执行效率随着对象的数量的增长而降低

​ 内存空间碎片化问题,标记清除产生大量不连续的内存碎片,导致内存较大对象无法分配以及分配速度需要遍历导致速度慢的问题(通常通过“分区空闲分配链 表”来进行内存分配)

标记-复制

复制算法(Copying):将内存划分为大小相等的两块,一半是活动对象的当前存放地(from),另一半是空闲区(to)。当这一块内存用完了就将还存活的对象复制到另一块上面并有序排列,然后再把使用过的内存空间进行一次清理。

image-20240104114033697

优点:不会出现内存碎片化问题(对象复制到另一块内存空间会按顺序存放),内存分配速度快,按顺序分配即可

缺点:内存使用效率低,可用内存空间缩小到原来的一半

​ 当有大量对象存活时会产生大量内存复制的开销

标记-整理

标记-整理(Mark and Compact):标记过程和”标记-清除”算法一样,但是后续整理阶段让所有存活的对象都向内存空间的一端移动,然后直接清理掉边界以外的内存。

image-20240104132131609

优点:内存使用率较高、不会产生内存碎片,内存分配性能好

缺点:整理阶段的效率不高并且阻塞用户程序(移动存活对象并更新所有引用这些对象的地方)

分代

分代垃圾回收算法(Generational):根据对象的存活周期将内存划分为几块,不同的块采用不同垃圾回收算法

一般将堆内存划分为 新生代和老年代, 其中新生代又被划分 Eden区,s0s1

  • 新生代:存放存活时间比较短的对象
  • 老年代:存放存活时间比较长的对象

添加启动参数 -XX:userSerialGC 参数使用分代垃圾回收器运行程序,然后使用 arthas 的 memory 命令查看内存结构和占用情况

image-20240104134610689

参数设置

1
2
3
4
5
6
7
8
java -XX:+UseSerialGC SimpleClass # -XX:+UseSerialGC 使用的是基于分代算法的串行垃圾回收器
-Xms # 设置 java 堆内存的初始大小
-Xmx # 设置 java 堆内存的最大大小
-Xmn # 设置 java 堆内存的新生代的大小
-XX:NewRatio=2 # 设置老生代和新生代的比例
-XX:SuriviorRatio # 设置新生代中 Eden 区和 单个Survivor 区的比例。SurvivorRatio 的默认值是 8 即 Eden 区占新生代的 8 部分,每个 Survivor 区各占 1 部分
-XX:PrintGCDetails # 启用垃圾回收日志详细信息的打印
-verbose:gc # 启用垃圾回收的详细输出【前者更加详细】

分代垃圾回收流程

  • 分配阶段 - Allocation:新创建的对象首先被分配到新生代的 Eden 区

  • 新生代垃圾回收 - Minor GC:当 Eden 区满时,会触发 Minor GC(也称 Young GC)

    • Minor GC 采用 标记-复制 算法识别和标记仍然存活的对象,将存活的对象复制到一个 Survivor 区并清空 Eden 区

      再次 Minor GC 时,将上一个 Survivor 区和 Eden 区仍然存活的对象复制到另一个 Survivor 区

  • 老化 - Aging:每次 Minor GC 都会为存活的对象记录年龄,初始值为0,每次 GC 存活对象的年龄增 1,当对象年龄到达阈值(最大15,默认值与垃圾回收器有关),对象会晋升到老年代(注:当新生代的空间不足时,部分对象也会晋升到老年代)

  • 老年代垃圾回收 - Major GC:当老年代空间不足时,尝试进行 Minor GC 后仍然无法满足内存需求时,就会触发 Major GC (也称 Full GC),Major GC 会对整个堆内存进行回收,包括新生代和老年代。

Minor GC 频繁发生,会导致短暂的STW

Major GC 发生频率较低,整个堆都需要被扫描和清理会导致较长的STW,开销较高

​ 如果 Major GC 后老年代仍然无法容纳新对象,会导致内存溢出

为什么分代思想将堆划分为年轻代和老年代?

新生代和老年代分别采用不同的垃圾回收算法和策略,以优化不同生命周期的对象的回收效率,减小每次垃圾回收的停顿时间,并提高整体垃圾回收的吞吐量。

开发者可以更加灵活的设置年轻代和老年代的比例来适应不同类型的应用程序(例如:并发比较高的应用可以适当增加年轻代,反正新生代内存占满后,对象提前晋升最终导致老年代占满频繁 Major GC 甚至导致内存溢出)

垃圾回收器

垃圾收集器就是内存回收算法的具体实现

常见的垃圾回收器
垃圾回收器的组合关系

各款经典收集器之间的关系如下

image-20240104145042847

垃圾回收器的使用
Serial收集器

Serial收集器是单线程串行回收的年轻代收集器,采用 标记-复制 算法

单线程强调的是它在进行垃圾回收时,必须暂停其他所有工作线程,直到它收集结束。

优点:简单、高效,适用于单线程环境,单CPU处理器下吞吐量大

缺点:单线程执行不适用于多核处理器,可能导致停顿时间较长

使用场景:单核CPU小型应用、客户端应用

SerialOld收集器

Serial Old是 Serial 收集器的老年代版本,是单线程串行回收的老年代收集器,采用 标记-整理 算法

image-20240104151248065

优缺点和使用场景和 Serial收集器 一致。

使用启动参数 -XX:+UseSerialGC 开启 Serial收集器 和 SerialOld收集器 的单线程串行垃圾收集器组合。

ParNew收集器

ParNew收集器是Serial收集器的多线程并行版本,使用多线程进行垃圾回收的年轻代收集器,采用 标记-整理 算法

使用参数 -XX:+UseParNewGC 开启新生代使用 ParNew 回收器,老年代使用串行回收器。

image-20240104161804887

优点:多线程执行垃圾回收,提高了垃圾回收的吞吐量;多核处理器停顿时间减少

缺点:不满足对停顿时间要求极高的场景

使用场景:中等规模的应用,对停顿时间要求不是特别严格的场景

CMS收集器

CMS 收集器(Concurrent Mark-Sweep GC)是一种旨在获取最短回收停顿时间为目标的老年代收集器,采用 标记-清理 算法,在部分步骤中,采用了并发的执行方式,以尽量减小在垃圾回收过程中应用程序的停顿时间。

使用参数 -XX:+UseConcMarkSweepGC 开启老年代使用 CMS 回收器。

image-20240104153222282

整个过程分为四个步骤,包括:

  • 初始标记(CMS initial mark) - STW:用较短的时间识别并标记与根对象(GC Root)直接关联的对象
  • 并发标记(CMS concurrent mark):并发标记,遍历对象图,标记所有可达的对象
  • 重新标记(CMS remark) - STW:在并发标记期间可能产生的新的存活对象,进行一次短暂的新对象重新标记并进行修正
  • 并发清除(CMS concurrent sweep):并行清理标记为垃圾的对象,释放空间

初始标记和重新标记阶段会造成短暂的停顿,并发标记和并发清除阶段是并发执行,占据上面流程中较长的时间。

优点:部分并发执行,减少了停顿时间

缺点:标记清除导致的内存碎片问题;在老年代内存分配对象不足的情况下会推化为SerialOld单线程串行垃圾回收器;

​ 产生“浮动垃圾”问题,无法处理在并法清理阶段产生的垃圾对象;

​ 并发阶段占用CPU资源造成性能影响

使用场景:适用于对于停顿时间要求较高的中大型应用,请求数据量大和频率高的场景(jdk14后,cms被废弃)

Parallel Scavenge收集器

Parallel Scavenge是jdk8默认的年轻代垃圾回收器,在诸多特性从表面上看和ParNew非常相似,多线程并行回收。

Parallel Scavenge和与其他收集器不同的关注点,CMS等收集器的关注点是尽可能 地缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐 量(Throughput)。

image-20240104162047479

Parallel Scavenge 可以通过参数打开 GC 自适应的调节策略(GC Ergonomics),jvm 会自动根据系统的配置和应用程序的行为进行调整堆参数(堆内存的大小、结构,晋升阈值等等),以提供最合适的停顿时间或者最大的吞吐量。

优点:吞吐量高,自适应调节

缺点:不能保证每一次的停顿时间

适用场景:适用于执行时间较长、计算密集型的后台任务(例如:大数据处理和大文件导出),对吞吐量要求较高并且停顿时间要求相对宽松的场景

Parallel Old收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法的老年代收集器。

使用参数 -XX:+UseParallGC或者 -XX:+UserParallelOldGC 开启使用 Parallel Scavenge 和 Parallel old 回收器的组合,但是jdk8默认启用的是这种组合,可以使用 java -XX:+PrintCommandLineFlags --version 查看当前 jvm 默认参数。

优缺点和适用场景和 Parallel Scavenge 相似。

Parallel scavenge 和 Parallel old 的组合时的参数设置:

Oracle官方建议,在使用这个组合时,不要设置堆内存的最大值,垃圾回收器会根据最大暂停时间和吞吐量 自动调整内存大小。

  • -XX:MaxGCPauseMillis=n:设置每次垃圾回收时的最大停顿毫秒数
  • -XX:GCTimeRatio=n:设置吞吐量为n(用户线程执行时间 = n/n + 1)
  • -XX:+UseAdaptiveSizePolicy:设置可以让垃圾回收器根据吞吐量和最大停顿的毫秒数自动调整内存参数的开关(默认为 true)
G1收集器

G1(Garbage First)收集器是 jdk9及之后默认的垃圾收集器,jdk9之后最推荐使用的垃圾收集器。

它是一款主要面向服务端应用的垃圾收集器,在 多CPU 和 大内存 的场景下有很好的性能。

G1之前的垃圾收集器,内存结构一般是连续的,如下图

image-20240104170509132

而G1也仍是遵循分代收集理论设计的,但是它堆内存的布局与其他收集器有非常明显的差异。

它把 连续的jvm堆 划分为 多个大小相等的独立区域(Region),使得新生代和老年代不再物理隔离,每个区域可以根据需求扮演不同的空间(新生代eden空间、Survior空间或者老年代空间),这样更有利于控制垃圾回收的时间。

image-20240104171231201
Region Size

region 的大小 = 堆空间大小/2048 M,也可以通过参数 -XX:G1HeapRegionSize=<size>指定大小。

region size必须是2的指数幂,取值范围从1M到32M(1,2,4,8,16.32)。

G1 垃圾回收方法

G1 垃圾收集器的垃圾回收方法包括 Minor GC(年轻代垃圾回收)和 Mixed GC(混合垃圾回收)两种

  • Minor GC
    • 触发时机:当年轻代中的Eden填满(Eden区最多占用60%)时,触发 Minor GC
    • 工作内容:Minor GC 主要采用 标记-复制 对年轻代进行垃圾回收。
    • 停顿时间:会导致短暂的STW,可以通过参数 -XX:MaxGCPauseMillis=n(单位:毫米,默认200) 设置每次垃圾回收时的最大暂停时间毫秒数。
  • Mixed GC
    • 触发时机:当老年代的占用达到一定的阈值(默认为45%,可通过-XX:InitiatingHeapOccupancyPercent 配置)时,触发 Mixed GC
    • 工作内容:Mixed GC 采用标记-复制收集所有年轻代、一部分老年代以及大对象区
    • 停顿时间: Mixed GC 的停顿时间相对较短,因为它只处理了部分老年代
G1 垃圾回收流程
  • Minor GC

新创建的对象会首先被分配到年轻代的Eden区,当G1判断到年轻代(包括Eden区和Survivor区)的空间不足以分配新对象时(年轻代默认初始占用堆空间百分比为5%,默认最大占用堆空间百分比为60%,可以由 -XX:G1NewSizePercent-XX:G1MaxNewSizePercent进行配置)即当年轻代占用空间接近60%时,就会触发 Minor GC。Minor GC首先标记住Eden和Survivor区域中的存活对象,然后根据”Pause Prediction Model”算法和配置的最大暂停时间(通过 -XX:MaxGCPauseMillis 选项配置)来动态选择合适的区域进行垃圾回收并将存活的对象复制到新的Survivor区中,同时对象年龄+1,然后清空之前相应的区域。当某个存活的对象年龄达到了阈值(默认15),则晋升至老年代。

此处注意一个特殊情况,如果存在大对象超过参数HumongousThreshold(默认2M),它将占用,则会将它直接放入Humongous区域。

  • Mixed GC

    当老年代的占用达到一定的阈值(通过 -XX:InitiatingHeapOccupancyPercent 配置,默认为45%)时,G1会触发 Mixed GC,主要目标是同时采用标记-复制(本质上,也是整理,不容易产生内存碎片)收集所有年轻代、一部分老年代(保证回收的最大暂停时间)以及大对象区。

  • Full GC

    当发现没有足够的空 region 存放转移的对象,会出现Full GC,此时单线程执行标记-整理算法,这个执行过程会导致用户线程暂停即STW。因此尽量保证应用的堆内存有一定的空间,防止出现内存溢出的情况。

G1 垃圾混合回收过程
  • 初始标记 - STW:用于标记根对象(GC Root)与根对象直接关联的对象。
  • 并发标记:通过 Snapshot-At-The-Beginning(SATB)原始快照算法并发标记所有存活的对象
  • 最终标记 - STW:确认已经标记的信息的准确性,不会标记 并发标记阶段产生的新对象
  • 并发清理:选择部分存活度较低的区域,将其中存活的对象复制到其他空闲的region中(本质整理,不容易产生内存碎片)

image-20240104214130716

和CMS不同点在于:最终标记不会对于并发标记阶段产生的新对象进行标记,提高了性能。

​ 并发清理采用了复制算法,而CMS采用了标记清理算法,同时G1并发清理不会清理整个堆上的,而是选择部分存活度较低的区域。

使用

jdk8版本 g1 收集器还不够成熟,可以通过 -XX:UseG1GC 打开,jdk9 默认采用了g1收集器,无需参数启用。

可以通过 -XX:MaxGCPauseMillis=<millis> 设置最大暂停时间,默认200ms

优点
  • 面对大堆的场景,延迟可控
  • 不会产生内存碎片
  • 并发标记采用 SATB算法 效率和准确性高
垃圾收集器的选择
  • jdk8
    • 单核or客户端应用:serial + serialold
    • 关注暂停时间 - 互联网用户应用:parNew + CMS
    • 关注吞吐量 - 后台任务:parallel scavenge + parallel old
  • jdk9 g1

参考