Skip to content

JVM

JVM底层原理看这一篇就够了,带你彻底搞懂JVM底层原理 - 知乎 (zhihu.com)

什么是JVM

Java Virtual Machine Java程序的运行环境(java二进制字节码的运行环境)

好处:

  • 一次编写,到处运行
  • 自动内存管理,垃圾回收机制

JVM组成部分

JVM组成部分

image-20230821210727282

  • 类装载子系统
  • 字节码执行引擎
  • 运行时数据区

JDK体系结构图

img

类加载器

JVM只会运行二进制文件,类加载器的作用就是将字节码文件加载到UJVM中,从而让Java程序能够启动起来。

image-20230822164112329

双亲委派模型

加载某一个类,先委托上一级的加载器进行加载,如果上级加载器也有上级,则会继续向上委托,如果该类委托上级没有被加载,子加载器尝试加载该类

好处

  1. 通过双亲委派机制可以避免某一个类被重复加载,当父类已经加载后则无需重复加载,保证唯一性。
  2. 为了安全,保证类库API不会被修改

类装载的执行过程

类从加载到虚拟机中开始,直到卸载为止,它的整个生命周期包括了:加载、验证、准备、解析、初始化、使用和卸载这7个阶段。其中,验证、准备和解析这三个部分统称为连接(linking)

image-20230822170130610

加载

  • 通过类的全名,获取类的二进制数据流。
  • 解析类的二进制数据流为方法区内的数据结构(Java类模型)
  • 创建java.lang.Class类的实例,表示该类型。作为方法区这个类的各种数据的访问入口

验证

image-20230822171148372

准备

为类变量分配内存并设置类变量初始值

  • static变量,分配空间在准备阶段完成(设置默认值),赋值在初始化阶段完成
  • static变量是final的基本类型,以及字符串常量,值已确定,赋值在准备阶段完成
  • static变量是final的引用类型,那么赋值也会在初始化阶段完成

解析

把类中的符号引用转换为直接引用

初始化

对类的静态变量,静态代码块执行初始化操作

  • 如果初始化一个类的时候,其父类尚未初始化,则优先初始化其父类。
  • 如果同时包含多个静态变量和静态代码块,则按照自上而下的顺序依次执行。

使用

JVM开始从入口方法开始执行用户的程序代码

  • 调用静态类成员信息(比如:静态字段、静态方法
  • 使用new关键字为其创建对象实例

卸载

当用户程序代码执行完毕后,JVM便开始销毁创建的Class对象

运行数据区

Java编译器输入的指令流基本上是一种基于栈的指令集架构,另外一种指令集架构则是基于寄存器的指令集架构。 具体来说:这两种架构之间的区别:

基于栈式架构的特点

  • 设计和实现更简单,适用于资源受限的系统;
  • 避开了寄存器的分配难题:使用零地址指令方式分配。
  • 指令流中的指令大部分是零地址指令,其执行过程依赖于操作栈。指令集更小,
  • 编译器容易实现。
  • 不需要硬件支持,可移植性更好,更好实现跨平台·

基于寄存器架构的特点

  • 典型的应用是x86的二进制指令集:比如传统的Pc以及Android的Davlik虚拟机。
  • 指令集架构则完全依赖硬件,可移植性差>性能优秀和执行更高效;
  • 花费更少的指令去完成一项操作。
  • 在大部分情况下,基于寄存器架构的指令集往往都以一地址指令、二地址指令和三地址指令为主,而基于栈式架构的指令集却是以零地址指令为主。有难学的

举例如下:

image-20230805093603096

1. 堆

**堆是由一个或多个线程共享的运行时数据区。**它是用于存储对象实例的地方,几乎所有的对象实例和数组都会被分配在堆上。堆的存储空间是动态分配的,其大小可以在创建JVM时设定,也可以根据需要进行扩展。对象实例在堆中分配和回收,由Java的垃圾回收机制自动处理。

2. 虚拟机栈(线程)

**栈是一个线程私有的运行时数据区,用于存储局部变量、方法参数、方法返回值以及方法调用时的临时数据。**每个线程在执行过程中都有一个对应的栈帧(Stack Frame),每个方法调用时都会在栈上创建一个栈帧,方法执行完毕后,栈帧会被销毁。栈的大小是固定的,由虚拟机在创建线程时设定。

  • 局部变量
  • 操作数栈
  • 动态链接
  • 方法出口

垃圾回收是否设计栈内存

垃圾回收主要指就是堆内存,当栈帧弹栈以后,内存就会释放

栈内存分配越大越好吗?

未必,默认的栈内存通常为1024k 栈帧过大会导致线程数变少,例如,机器总内存为512m,目前能活动的线程数则为512个,如果把栈内存改为2048k,那么能活动的栈帧就会减半.

方法内的局部变量是否线程安全?

  • 如果方法内局部变量没有逃离方法的作用范围,它是线程安全的
  • 如果是局部变量引用了对象,并逃离方法的作用范围,需要考虑线程安全

栈内存溢出情况

  • 栈帧过多导致栈内存溢出,典型问题:递归调用
  • 栈帧过大导致栈内存溢出

3. 方法区(元空间)

  • 方法区也是一个各个线程共享的运行时数据区,用于存储类信息、常量池、静态变量、即时编译器编译后的代码等数据
  • 方法区的大小也是固定的,由虚拟机在创建JVM时设定,虚拟机启动的时候创建,关闭虚拟机时释放
  • 在Java 8之前,方法区是一个逻辑上连续的内存空间,而在Java 8及之后的版本中,方法区被移除,类的元数据和静态变量被放在了堆中的一个称为"元空间"的区域。
java
class Animal
{
	private String name;//实例成员变量
	private int age;
	public static void color(){//静态方法(属于类,直接用类名访问)
		System.out.println("White");
	}
	public void identity(){//实例方法(属于对象,必须用对象访问)
		System.out.println(name+age);
	}
	public static void main(String[] args) 
	{
		Animal.color();//类名.静态方法
		Animal puppy=new Animal();//创建对象
		puppy.name="张三";
		puppy.age=20;
		puppy.identity();//对象.实例方法
		puppy.color();//也可对象.静态方法
	}
}

img

可能会提问的问题

image-20230822162318623

4. 程序计数器

程序计数器:线程私有的,内部保存的字节码的行号。用于记录正在执行的字节码指令的地址。

我们对以下代码进行编译

java
public class Apk {
    public static void main(String[] args) {
        System.out.println("hello word");
    }
}

运行以下代码

bash
javac Apk.java

通过命令反编译java代码

bash
javap -v Apk.class

得到的反编译结果为

txt
Classfile /D:/code/k1/src/Apk.class
  Last modified 2023年8月21日; size 410 bytes
  SHA-256 checksum 7aaeb0dd543ec63b5814f0ef0d379c698838178431aadfa5a4af26b3e6b9e63f
  Compiled from "Apk.java"
public class Apk
  minor version: 0
  major version: 61
  flags: (0x0021) ACC_PUBLIC, ACC_SUPER
  this_class: #21                         // Apk
  super_class: #2                         // java/lang/Object
  interfaces: 0, fields: 0, methods: 2, attributes: 1
Constant pool:
   #1 = Methodref          #2.#3          // java/lang/Object."<init>":()V
   #2 = Class              #4             // java/lang/Object
   #3 = NameAndType        #5:#6          // "<init>":()V
   #4 = Utf8               java/lang/Object
   #5 = Utf8               <init>
   #6 = Utf8               ()V
   #7 = Fieldref           #8.#9          // java/lang/System.out:Ljava/io/PrintStream;
   #8 = Class              #10            // java/lang/System
   #9 = NameAndType        #11:#12        // out:Ljava/io/PrintStream;
  #10 = Utf8               java/lang/System
  #11 = Utf8               out
  #12 = Utf8               Ljava/io/PrintStream;
  #13 = String             #14            // hello word
  #14 = Utf8               hello word
  #15 = Methodref          #16.#17        // java/io/PrintStream.println:(Ljava/lang/String;)V
  #16 = Class              #18            // java/io/PrintStream
  #17 = NameAndType        #19:#20        // println:(Ljava/lang/String;)V
  #18 = Utf8               java/io/PrintStream
  #19 = Utf8               println
  #20 = Utf8               (Ljava/lang/String;)V
  #21 = Class              #22            // Apk
  #22 = Utf8               Apk
  #23 = Utf8               Code
  #24 = Utf8               LineNumberTable
  #25 = Utf8               main
  #26 = Utf8               ([Ljava/lang/String;)V
  #27 = Utf8               SourceFile
  #28 = Utf8               Apk.java
{
  public Apk();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 1: 0

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #7                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #13                 // String hello word
         5: invokevirtual #15                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 3: 0
        line 4: 8
}
SourceFile: "Apk.java"

getstatic:就是获取静态数据,System.out

ldc:加载常量

5. 本地方法栈

直接内存

  • 直接内存:并不属于JVM中的内存结构,不由VM进行管理。是虚拟机的系统内存,
  • 常见于NIO操作时,用于数据缓冲区,它分配回收成本较高,但读写性能高。

image-20230822163707042

image-20230822163801294

垃圾回收机制

为啥需要垃圾回收机制

对于系统而言,内存迟早都会被消耗完,因为不断的分配内存空间而不进行回溯,就好像不停的产生生活垃圾 但是除了释放垃圾对象,也需要对于内存空间进行碎片管理,没有垃圾回收就不能保证应用程序的正常化进行

什么时候垃圾回收机制

在Java内存运行时区域的各个部分中,堆和方法区这两个区域则有着很显著的不确定性:一个接口的多个实现类需要的内存可能会不一样,一个方法所执行的不同条件分支所需要的内存也可能不一样,只有处于运行期间,我们才能知道程序究竟会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的。

堆区分为老年代新生代,新生代又分为Eden区、s0区和s1区(99%的对象能new到Eden区,1%大对象new到老年代),当对象去堆区申请空间时

  1. 先去Eden区看有无足够空间,有分配,无mirror GC
  2. 有分配,无去s区,有Eden区对象移到s区,无去old区
  3. 有s移到old,Eden移到s,无full GC
  4. old区有同上,无OOM

判断对象是否存活的方法

引用计数算法

垃圾收集器所关注的正是堆和方法区的内存该如何管理的问题,我们平时所说的内存分配与回收也仅仅特指这一部分内存。

在对象中添加一个引用计数器:

  • 每当有一个地方引用它时,计数器值就加一;
  • 当引用失效时,计数器值就减一;

==任何时刻计数器为零的对象就是不可能再被使用的==。

但是,在Java领域,至少主流的Java虚拟机里面都没有选用引用计数算法来管理内存,主要原因是,这个看似简单的算法有很多例外情况要考虑,必须要配合大量额外处理才能保证正确地工作,譬如单纯的引用计数就很难解决对象之间相互循环引用的问题

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

例子2

例:一个对象A只要有任何一个对象引用了A则A的引用计数器就+1,当引用失效时,引用计数器就-1.只要对象A的引用计数器的值为0,即标识对象A不可能再被使用,可进行回收

  • 优点:实现简单,垃圾对象便于识别,判断效率高
  • 缺点: 他需要单独的字段存储计数器,这样的做法增加的存储空间的开销 每次赋值需要额外的加减法计算,增加了时间开销 引用计数算法最大的问题是无法处理循环引用的情况,这是一个比较致命的缺陷

循环引用问题

可达性分析算法

当前主流的商用程序语言的内存管理子系统,都是通过可达性分析(Reachability Analysis)算法来判定对象是否存活的。

相对于引用计数算法,他有效的解决了在引用计数算法中的循环引用问题,防止内存泄漏发生

这种类型的垃圾收集也叫作追踪性垃圾收集

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

如下图所示,对象object 5、object 6、object 7虽然互有关联,但是它们到GC Roots是不可达的,因此它们将会被判定为可回收的对象:

在这里插入图片描述

总结的图

可达性分析算法

GC Roots的对象

在Java技术体系里面,固定可作为GC Roots的对象包括以下几种:

  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
  • 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
  • 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
  • 在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
  • Java虚拟机内部的引用,如基本数据类型对应的Class对象一些常驻的异常对象(比如 NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器
  • 所有被同步锁(synchronized关键字)持有的对象。
  • 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

总结:一个指针,他保存了堆里面的对象,但自己又不在堆当中,那么他就是一个Root

垃圾回收算法

1. 标记清除法

  • 背景: 标记清除算法是一种非常基础和常见的垃圾收集算法,该算法被J.McCarthy等人在1960年提出并应用于Lisp语言

  • 执行过程:

    (1)当堆空间中有效内存空间被耗尽时,就会停止这个程序(Stop the world),然后进行两项工作,标记,清除这两部分 (2)标记:从引用根节点上开始遍历(可达性分析算法)标记所有被引用的对象。一般是在对象Header中记录为可达对象。 (3)清除:对堆内存从头到尾进行线性遍历,如果发现某个对象在其Header中没有标记为可达对象,则将其回收

  • 缺点:效率不高;在进行GC的时候需要停止整个应用程序,导致用户体验差;且会产生的大量的内存碎片。

  • 注意: 在这里的清除不是去干掉具体内存中的数据,而是本身分配的是一组连续的内存编码给我们使用,清除就是在回收这些空闲地址,将他们保存在空闲地址表当中,下次有心得对象需要空间时去判断是否够用

image-20230822202926219

2. 复制算法

  • 背景: 为了解决标记-清除算法在垃圾收集效率方面的缺陷,M.LMinsky与1963年发表了著名论文,”使用双存储区的Lisp语言垃圾收集器“,该论文中被描述的算法被人们称之为复制算法。
  • 执行过程: 将内存空间分为两块,每次只使用其中一块,在垃圾回收的时候,将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块,交换两个内存角色。
  • 缺点: 1.需要两倍空间 2.GC需要维护对象的引用关系,时间开销加大 此种方案使用与垃圾对象较少,量级不大的情况

image-20230822203703427

3. 标记整理

  • 背景: 复制算法的高效是简历在存货对象少、垃圾对象多的前提下。这种情况在新生代中经常法神,但是在老年代,更常见的情况是大部分对象都是存货的。如果依然使用复制算法,由于存货对象多,复制成本也会非常高。因此基于老年代使用复制算法并不适用。
  • 执行过程: 第一阶段与标记清除算法一致。 第二阶段将所有的存货对象压缩到内存的一段,按照顺讯排放,之后清理边界外所有空间

垃圾回收算法

4. 分代回收算法

背景:为了满足垃圾回收的效率最优性,所以分代回收算法应运而生。

分代回收算法基于一个事实:不同的对象生命周期是不一样的,因此,不同生命周期的对象可以采取不同的手机方式,以便于提高回收效率。一般是把JAVA堆分为新生代和老年代,这样就可以根据各个年代的特点使用不同回收算法,相对提高效率。

在系统运行过程汇总,会产生大量对象,其中有些对象是业务信息相关,如HTTP请求的Session、线程、Socket连接等对象,这类对象跟业务挂钩,因此生命周期长,还有一部分是运行过程汇总生成的临时变量,这些对象生命周期短,比如:String,这些对象甚至只使用一次即可回收

目前所有GC都采用分代收集算法进行执行 对象的状态经过大量的调研研究划分为年青代与老年代两个类别

(1)年轻代:区域相对小,对象生命周期短、存活率低,且产生应用频繁 复制算法回收整理速度是最快的。复制算法效率只与当前存活对象大小有关,因此很实用与年青代的回收,而空间问题,因为存活率问题,所以单独开辟S0,S1两块空间处理清除后结果 (2)老年代:区域较大,生命周期长、存活率高,回收不及年青代频繁 这种情况存在大量存过对象下,复制不适用,所以一般是用清除与整理算法混合实现。

image-20230822204149328

image-20230822204945348

image-20230822205107271

MinorGC、Mixed GC、FullGC的区别是什么

  • MinorGC【young GC】发生在新生代的垃圾回收,暂停时间短(STw)
  • Mixed GC新生代+老年代部分区域的垃圾回收,G1收集器特有
  • FullGC:新生代+老年代完整垃圾回收,暂停时间长(STW),应尽力避免

Mark阶段的开销与存活对象的数量成正比 Sweep阶段的开销与所管理的大小成正比 Compact阶段的开销与存活对象的数据成正比 在这里插入图片描述

垃圾回收器

1. 串行垃圾收集器

Serial和Serial Old串行垃圾收集器,是指使用单线程进行垃圾回收,堆内存较小,适合个人电脑

  • Serial 作用于新生代,采用复制算法
  • Serial Old 作用于老年代,采用标记-整理算法

垃圾回收时,只有一个线程在工作,并且java应用中的所有线程都要暂停(STW),等待垃圾回收的完成。

image-20230822210732645

2. 并行垃圾收集器

Parallel New和Parallel Old是一个并行垃圾回收器,JDK8默认使用此垃圾回收器

  • Parallel New作用于新生代,采用复制算法

  • Parallel Old作用于老年代,采用标记-整理算法

垃圾回收时,多个线程在工作,并且java应用中的 所有线程都要暂停(STW),等待垃圾回收的完成。

image-20230822210854698

3. CMS (并发)垃圾收集器

CMS全称Concurrent Mark Sweep,是一款并发的、使用标记-清除算法的垃圾回收器,该回收器是针对老年代垃圾回收的,是一款以获取最短回收停顿时间为目标的收集器,停顿时间短,用户体验就好。其最大特点是在进行垃圾回收时,应用仍然能正常运行。

image-20230822210908948

4. G1垃圾收集器

  • 应用于新生代和老年代,在JDK9之后默认使用G1
  • 划分成多个区域,每个区域都可以充当eden,survivor,old,humongous,其中 humongous 专为大对象准备
  • 采用复制算法
  • 响应时间与吞吐量兼顾
  • 分成三个阶段:新生代回收、并发标记、混合收集
  • 如果并发失败(即回收速度赶不上创建新对象速度),会触发Full GC

Young Collection(年轻代垃圾回收)

  • 初始时,所有区域都处于空闲状态
  • 创建了一些对象,挑出一些空闲区域作为伊甸园区存储这些对象
  • 当伊甸园需要垃圾回收时,挑出一个空闲区域作为幸存区,用复制算法复制存活对象,需要暂停用户线程
  • 随着时间流逝,伊甸园的内存又有不足
  • 将伊甸园以及之前幸存区中的存活对象,采用复制算法,复制到新的幸存区,其中较老对象晋升至老年代

image-20230822215126738

Young Collection + Concurrent Mark(年轻代垃圾回收+并发标记)

当老年代占用内存超过阈值(默认是45%)后,触发并发标记,这时无需暂停用户线程

  • 并发标记之后,会有重新标记阶段解决漏标问题,此时需要暂停用户线程。
  • 这些都完成后就知道了老年代有哪些存活对象,随后进入混合收集阶段。此时不会对所有老年代区域进行回收,而是根据暂停时间目标优先回收价值高(存活对象少)的区域(这也是Gabage First名称的由来)。

Mixed Collection(混合垃圾回收)

复制完成,内存得到释放。进入下一轮的新生代回收、并发标记、混合收集

image-20230822215556410

总结

垃圾回收器

  • serial:针对新生代,jdk1.3之前,单线程,复制算法,垃圾回收会stop the world(停止用户代码执行)
  • serial old:针对老年代,jdk1.3之前,标记整理
  • parNew,parallel Scavenge:新生代,多线程
  • parallel:老年代

Serial和Parallel

G1:jdk1.7之后,新生代/老年代,可预测停顿(提供最优的停顿时间),空间整理(提供最大的吞吐量)

G1

CMS:jdk1.7之后,老年代 使用空闲列表回收,不对老年代进行整理

CMS

垃圾回收算器底层算法:

垃圾收集器底层算法

常用GC垃圾回收器性能对比

在这里插入图片描述

强引用,弱引用,软引用,虚引用

强引用:只有所有GC Roots对象都不通过【强引用】引用该对象,该对象才能被垃圾回收

java
User user = new User();

image-20230822220044392

软引用:仅有软引用引用该对象时,在垃圾回收后,内存仍不足时会再次出发垃圾回收

java
User user = new User():
SoftReference softReference = new SoftReference(user);

image-20230822220122073

弱引用:仅有弱引用引用该对象时,在垃圾回收时,无论内存是否充足,都会回收弱引用对象

java
User user = new User();
WeakReference weakReference = new WeakReference(user);

image-20230822220223393

虚引用:必须配合引用队列使用,被引用对象回收时,会将虚引用入队,由Reference Handler线程调用虚引用相关方法释放直接内存

java
User user = new User();
ReferenceQueue referenceQueue = new ReferenceQueue();
PhantomReference phantomReference = new PhantomReference(user,queue);

image-20230822220311171

image-20230822220748531

JVM调优

哪里设置参数值

  • war包部署在tomcat中设置

    image-20230823100942393

  • jar包部署在启动参数设置

    java
    java -Xms512m -Xmx1024m -jar xxxx.jar

参数有哪些

对于JVM调优,主要就是调整年轻代、老年代、元空间的内存空间大小及使用的垃圾回收器类型。

  • 设置堆空间大小

    image-20230823101541108

    堆空间设置多少合适?

    • 最大大小的默认值是物理内存的1/4,初始大小是物理内存的1/64
    • 堆太小,可能会频繁的导致年轻代和老年代的垃圾回收,会产生stw,暂停用户线程
    • 堆内存大肯定是好的,存在风险,假如发生了fullgc,它会扫描整个堆空间,暂停用户线程的时间长
    • 设置参考推荐:尽量大,也要考察一下当前计算机其他程序的内存使用情况
  • 虚拟机栈的设置

    虚拟机栈的设置:每个线程默认会开启1M的内存,用于存放栈帧、调用参数、局部变量等,但一般256K就够用。通常减少每个线程的堆栈,可以产生更多的线程,但这实际上还受限于操作系统。

    bash
    -Xss  #对每个线程stack大小的调整,-Xss128k
  • 年轻代中Eden区和两个Survivor区的大小比例

    设置年轻代中Eden区和两个Survivor区的大小比例。该值如果不设置,则默认比例为8:1:1。通过增大Eden区的大小,来减少YGC发生的次数,但有时我们发现,虽然次数减少了,但Eden区满的时候,由于占用的空间较大,导致释放缓慢,此时STW的时间较长,因此需要按照程序情况去调优。

    bash
    -XXSurvivorRatio=8  #表示年轻代中的分配比率:survivor:eden = 2:8
  • 年轻代晋升老年代阈值

    bash
    -XX:MaxTenuringThreshold=threshold

    默认为15取值范围0-15

  • 设置垃圾回收收集器

    通过增大吞吐量提高系统性能,可以通过设置并行垃圾回收收集器。

    bash
    -XX:+UseParallelGc
    -XX:+UseParallelOldGc

JVM调优工具

image-20230823102403702

jstat工具

是JVM统计监测工具。可以用来显示垃圾回收信息、类加载信息、新生代统计信息等。

image-20230823104042077

Java内存泄漏的排查思路

image-20230823104402915

参考资料

Java垃圾回收机制(GC原理)解析_java垃圾回收机制原理_浮空over的博客-CSDN博客