JVM基础(JVM内存区域,HotSpot虚拟机,垃圾回收机制,类加载过程,类加载器,双亲委派机制)

目录

1. 运行时数据区域(重要)

1.1 程序计数器

1.2 Java虚拟机栈

1.2.1 局部变量表

1.2.2 操作数栈

局部变量表和操作数栈的工作过程

1.2.3 动态链接

1.3 本地方法栈

1.4 堆

1.4.1 堆内存的分类

1.4.2 堆内存的分配

1.4.3 为什么年龄只能是0-15

1.4.4 堆中常见的异常

1.4.5 字符串常量池(存在于堆)

1.4.5.1 存储的变迁

1.5 方法区

1.5.1 方法区,永久代,元空间之间的关系

1.5.2 为什么要用元空间代替永久代

1.5.3 方法区的常用参数

1.5.4 运行时常量池(存在于方法区)

1.6 直接内存

2. HotSpot虚拟机

2.1 对象的创建(重要)

2.2 内存分配的两种方式

2.3 内存分配并发问题

2.4 对象的内存布局

2.5 对象的访问定位(重要)

3. 垃圾回收机制(重要)

3.1 内存分配和回收原则

3.1.1 对象优先再Eden区分配

3.1.2 大对象直接进入老年代

3.1.3 长期存活的对象将进入老年代

3.1.4 主要进行gc的区域

3.1.5 空间分配担保

3.2 死亡对象判断方法

3.2.1 引用计数法

3.2.2 可达性分析算法(可解决循环引用)

3.2.2.1 哪些对象可以作为GC Roots?

3.2.2.2 对象可以被回收,就代表一定会被回收吗?

3.2.3 引用类型总结

虚引用与软引用和弱引用的区别

3.2.4 如何判断一个常量是废弃常量

3.2.5 如何判断一个类是无用的类

3.3 垃圾收集算法

3.3.1 标记-清除算法

标记-清除的大致过程

3.3.2 标记-复制算法

3.3.3 标记-整理算法

标记-整理算法的步骤

 与标记-清除的区别

优缺点比较

3.3.4 分代收集算法

3.4 垃圾收集器

3.4.1 Serial收集器(新生代)

3.4.2 ParNew收集器(新生代)

3.4.3 Parallel Scavenge 收集器(新生代)

3.4.4 Serial Old收集器

3.4.5 Parallel Old收集器

3.4.6 CMS收集器

3.4.7 G1收集器

G1 收集器的运作大致分为以下几个步骤

3.4.8 ZGC收集器

4. 类加载器

4.1 类的生命周期

4.2 类的加载过程

1. 加载(Loading)

2. 链接(Linking)

2.1 验证(Verification)

2.2 准备(Preparation)

2.3 解析(Resolution)

3. 初始化(Initialization)

4.3 类的卸载

4.4 类加载器(其类的加载过程中的加载)

4.4.1 类加载器概述

4.4.2 类加载器加载规则

4.4.3 类加载器类型

4.4.4 自定义类加载器

4.5 双亲委派机制

4.5.1 双亲委派机制概述

 4.5.2 双亲委派机制的执行流程

双亲委派机制的相关代码

4.5.3 双亲委派模型的好处

4.5.4 打破双亲委派模型

参考


1. 运行时数据区域(重要)

        Java 虚拟机在执行 Java 程序的过程中会把它管理的内存划分成若干个不同的数据区域

 

线程私有的:

  • 程序计数器
  • 虚拟机栈
  • 本地方法栈

线程共享的:

  • 方法区
  • 直接内存 (非运行时数据区的一部分)

         Java 虚拟机规范对于运行时数据区域的规定是相当宽松的。以堆为例:堆可以是连续空间,也可以不连续。堆的大小可以固定,也可以在运行时按需扩展 。虚拟机实现者可以使用任何垃圾回收算法管理堆,甚至完全不进行垃圾收集也是可以的。

1.1 程序计数器

        程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完成。程序计数器的作用:

  • 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
  • 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。

注:

  1. 为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存
  2. 程序计数器是唯一一个不会出现 OutOfMemoryError 的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡

1.2 Java虚拟机栈

        与程序计数器一样,Java 虚拟机栈(后文简称栈)也是线程私有的,它的生命周期和线程相同,随着线程的创建而创建,随着线程的死亡而死亡(注意与栈帧的区别)

        栈绝对算的上是 JVM 运行时数据区域的一个核心,除了一些 Native 方法调用是通过本地方法栈实现的(后面会提到),其他所有的 Java 方法调用都是通过栈来实现的(也需要和其他运行时数据区域比如程序计数器配合)。

        方法调用数据需要通过栈进行传递每一次方法调用都会有一个对应的栈帧被压入栈中,每一个方法调用结束后,都会有一个栈帧被弹出。栈由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法返回地址。和数据结构上的栈类似,两者都是先进后出的数据结构,只支持出栈和入栈两种操作。

注:

  1. 栈空间虽然不是无限的,但一般正常调用的情况下是不会出现问题的。不过,如果函数调用陷入无限循环的话,就会导致栈中被压入太多栈帧而占用太多空间,导致栈空间过深。如果栈的内存不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError 错误。
  2. 除了 StackOverFlowError 错误之外,栈还可能会出现OutOfMemoryError错误,这是因为如果栈的内存大小可以动态扩展, 如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常
  3. Java 方法有两种返回方式,一种是 return 语句正常返回,一种是抛出异常。不管哪种返回方式,都会导致栈帧被弹出。也就是说, 栈帧随着方法调用而创建,随着方法结束而销毁。无论方法正常完成还是异常完成都算作方法结束。

1.2.1 局部变量表

        局部变量表 主要存放了编译期可知的各种数据类型的局部变量(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置),即方法参数(形参)和定义在方法体内的局部变量

1.2.2 操作数栈

        操作数栈 主要作为方法调用的中转站使用,用于存放方法执行过程中产生的中间计算结果和操作数。另外,计算过程中产生的临时变量也会放在操作数栈中,也可以存储各种类型的数据,如整数、浮点数、引用等。

注:

  • 局部变量表主要承载了方法的参数和局部变量,为方法的执行提供了数据支持。
  • 操作数栈则主要承载了方法的计算过程,是JVM执行引擎的一个工作区。当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这个方法的操作数栈是空的。随着方法的执行,操作数栈会不断地进行入栈和出栈操作,以完成各种计算任务。
局部变量表和操作数栈的工作过程
int a = 5;  
int b = 10;  
int c = a + b;

这段代码编译成字节码后,可能会有类似于以下的指令序列(注意:实际生成的字节码可能会因编译器和JVM的不同而略有差异):

  1. iconst_5:将整数5推送到操作数栈
  2. istore_1:从操作数栈弹出一个整数,并将其存储在局部变量表的第一个位置(索引为1,因为索引0通常用于方法参数或this引用)。此时局部变量a被赋值为5
  3. iconst_10:将整数10推送到操作数栈
  4. istore_2:从操作数栈弹出一个整数,并将其存储在局部变量表的第二个位置。此时局部变量b被赋值为10
  5. iload_1:将局部变量表中索引为1的整数(即变量a的值)推送到操作数栈。
  6. iload_2:将局部变量表中索引为2的整数(即变量b的值)推送到操作数栈。此时操作数栈顶的两个元素分别是ba
  7. iadd:从操作数栈中弹出两个整数ba),将它们相加,并将结果推送到操作数栈
  8. istore_3:从操作数栈中弹出一个整数(即a + b的结果),并将其存储在局部变量表的第三个位置。此时局部变量c被赋值为a + b的结果(即15)。

总结:数值一般先要压入操作数栈,再出栈赋值局部变量表中的变量。当进行数值操作或其他操作时,局部变量表中的操作数压入操作数栈再出栈进行操作,将结果压入栈中,最后出栈并将其赋值局部变量表中对应的变量

1.2.3 动态链接

        动态链接 主要服务一个方法需要调用其他方法的场景。Class 文件的常量池(常量池的主要目的是实现Java的“运行时常量池”概念里保存有大量的符号引用比如方法引用(方法的名称,描述符,句柄和方法类型)的符号引用。当一个方法要调用其他方法,JVM会查找这个符号引用所对应的类的元数据(这些信息存储在方法区的Class文件中),并找到实际的方法实现(即方法表入口)。最后,JVM会将这个符号引用替换指向方法实现的直接引用,将其存储在栈帧(指向目标的指针,相对偏移量或是一个能间接定位到目标的句柄。动态链接的作用就是为了将方法的符号引用转换为调用方法的直接引用,这个过程也被称为 动态连接

        当JVM执行一个方法调用时,它会根据当前对象的实际类型(也称为运行时类型)来查找对应类的方法表,并找到要调用的方法的直接引用。这个查找过程通常涉及以下几个步骤:

  1. 确定调用者的类型:JVM首先确定是哪个对象调用了该方法。这通常是通过检查栈帧中的引用变量来完成的。

  2. 获取对象实际类型的方法表:一旦确定调用者对象类型,JVM就会加载该对象所属类的元信息(包括方法表)。这个类可能是一个具体类,也可能是一个接口(如果是接口方法,则通过接口方法表来查找)。

  3. 查找方法:在方法表中,JVM会查找与要调用的方法签名相匹配的方法条目。方法签名包括方法名称和参数类型列表

  4. 检查访问权限:在找到匹配的方法条目后,JVM会检查调用者是否有权限访问该方法。这涉及到Java的访问控制规则,例如方法是否为privateprotecteddefault(包内访问)或public

  5. 获取方法的直接引用:如果访问权限检查通过,JVM就会从方法表中获取该方法的直接引用。这个直接引用通常是一个指向该方法代码的内存地址(在JIT编译器中可能是指向已编译成本地代码的指针)。

  6. 执行方法:最后,JVM会跳转到该直接引用所指向的内存地址并执行方法。

注:方法表通常是一个数组或者类似于数组的结构,其中每个索引位置对应一个方法。由于JVM知道每个类及其父类中定义的方法的准确数量和顺序,因此可以直接使用数组索引来快速定位方法。

1.3 本地方法栈

        和虚拟机栈所发挥的作用非常相似,区别是:虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一

        本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowErrorOutOfMemoryError 两种错误。

1.4 堆

        Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存

        Java 世界中“几乎”所有的对象都在堆中分配,但是,随着 JIT 编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。从 JDK 1.7 开始已经默认开启逃逸分析,如果某些方法中的对象引用没有被返回或者未被外面使用(也就是未逃逸出去),那么对象可以直接在栈上分配内存

        Java 堆垃圾收集器管理的主要区域,因此也被称作 GC 堆(Garbage Collected Heap)。从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以 Java 堆还可以细分为:新生代和老年代;再细致一点有:Eden、Survivor、Old 等空间。进一步划分的目的是更好地回收内存,或者更快地分配内存

对象的引用本身并不存储在堆中,而是存储在栈的局部变量表或者作为类的成员变量,....

1.4.1 堆内存的分类

在 JDK 7 版本及 JDK 7 版本之前,堆内存被通常分为下面三部分:

  1. 新生代内存(Young Generation)
  2. 老生代(Old Generation)
  3. 永久代(Permanent Generation) (这里可能有问题)

下图所示的 Eden 区、两个 Survivor 区 S0 和 S1 都属于新生代,中间一层属于老生代,最下面一层属于永久代:

注:JDK 8 版本之后 PermGen(永久代) 已被 Metaspace(元空间) 取代,元空间使用的是本地内存。

1.4.2 堆内存的分配

        大部分情况,对象都会首先在 Eden 区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入 S0 或者 S1,并且对象的年龄还会加 1(Eden 区->Survivor 区后对象的初始年龄变为 1),当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。不过,设置的值应该在 0-15,否则会爆出以下错误:

MaxTenuringThreshold of 20 is invalid; must be between 0 and 15

注:

  1. Hotspot 遍历所有对象时,按照年龄从小到大对其所占用的大小进行累加,当累加到某个年龄时,所累加的大小超过了 Survivor 区的一半,则取这个年龄和 MaxTenuringThreshold 中更小的一个值,作为新的晋升年龄阈值
  2. 如果固定按照MaxTenuringThreshold设定的阈值作为晋升条件,可能会遇到两种情况。一是该阈值设置得过大,导致原本应该晋升的对象一直停留在Survivor区,直到Survivor区溢出,进而影响GC效率。二是阈值设置得过小,导致对象“过早晋升”,即对象不能在新生代充分被回收大量短期对象被晋升到老年代,引起频繁的Major GC,影响系统性能。

1.4.3 为什么年龄只能是0-15

        因为记录年龄的区域在对象头中,这个区域的大小通常是 4 位。这 4 位可以表示的最大二进制数字是 1111,即十进制的 15。因此,对象的年龄被限制为 0 到 15。

1.4.4 堆中常见的异常

        堆这里最容易出现的就是 OutOfMemoryError 错误,并且出现这种错误之后的表现形式还会有几种,比如:

  1. java.lang.OutOfMemoryError: GC Overhead Limit Exceeded:当 JVM 花太多时间执行垃圾回收并且只能回收很少的堆空间时,就会发生此错误。
  2. java.lang.OutOfMemoryError: Java heap space :假如在创建新的对象时, 堆内存中的空间不足以存放新创建的对象, 就会引发此错误。(和配置的最大堆内存有关,且受制于物理内存大小。最大堆内存可通过-Xmx参数配置,若没有特别配置,将会使用默认值)

1.4.5 字符串常量池(存在于堆)

        字符串常量池 是 JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。案例如下:

// 在堆中创建字符串对象”ab“
// 将字符串对象”ab“的引用保存在字符串常量池中
String aa = "ab";
// 直接返回字符串常量池中字符串对象”ab“的引用
String bb = "ab";
System.out.println(aa==bb);// true

        HotSpot 虚拟机中字符串常量池的实现是 src/hotspot/share/classfile/stringTable.cpp ,StringTable 可以简单理解为一个固定大小的HashTable ,容量为 StringTableSize(可以通过 -XX:StringTableSize 参数来设置),保存的是字符串(key)和 字符串对象的引用(value)的映射关系,字符串对象的引用指向堆中的字符串对象

1.4.5.1 存储的变迁

        JDK1.7 之前,字符串常量池存放在永久代JDK1.7 字符串常量池静态变量从永久代移动了 Java 堆中

为什么JDK1.7要将字符串常量池移动到堆中?

        主要是因为永久代(方法区实现)的 GC 回收效率太低,只有在整堆收集 (Full GC)的时候才会被执行 GC。Java 程序中通常会有大量的被创建的字符串等待回收,将字符串常量池放到堆中,能够更高效及时地回收字符串内存。

1.5 方法区

        方法区属于是 JVM 运行时数据区域的一块逻辑区域,是各个线程共享的内存区域。在不同的虚拟机实现上,方法区的实现是不同的。当虚拟机要使用一个类时,它需要读取并解析 Class 文件获取相关信息,再将信息存入到方法区。方法区会存储已被虚拟机加载的 类信息、字段信息(成员变量)、方法信息、常量、静态变量、即时编译器编译后的代码缓存等数据

1.5.1 方法区,永久代,元空间之间的关系

        方法区和永久代以及元空间的关系很像 Java 中接口和类的关系,类实现了接口,这里的就可以看作是永久代和元空间接口可以看作是方法区,也就是说永久代以及元空间是 HotSpot 虚拟机对虚拟机规范中方法区的两种实现方式。并且,永久代JDK 1.8 之前的方法区实现,JDK 1.8 及以后方法区的实现变成了元空间

1.5.2 为什么要用元空间代替永久代

  1. 整个永久代有一个 JVM 本身设置的固定大小上限(尽管MaxPermSize允许设置一个更大的上限,但是其实际上会受到 JVM 内存的限制),而元空间使用的是本地内存受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小
  2. 元空间里面存放的是类的元数据(类的描述性信息,如类的接口和基本信息,这样加载多少类的元数据就不由 MaxPermSize(永久代的初始大小)控制了(因为元空间使用的时本地内存), 而由系统的实际可用空间(本地内存)来控制,这样能加载的类就更多了。
  3. JDK8合并 HotSpot 和 JRockit 的代码时, JRockit 从来没有一个叫永久代的东西, 合并之后就没有必要额外的设置这么一个永久代的地方了。
  4. 永久代会为 GC 带来不必要的复杂度,并且回收效率偏低

注: 在JDK 8之前,Java类的元数据主要存放在永久代。从JDK 8开始,Java类的元数据主要存放在元空间。

1.5.3 方法区的常用参数

        JDK 1.8 之前永久代还没被彻底移除的时候通常通过下面这些参数来调节方法区大小:

-XX:PermSize=N //方法区 (永久代) 初始大小
-XX:MaxPermSize=N //方法区 (永久代) 最大大小,超过这个值将会抛出 OutOfMemoryError 异常:java.lang.OutOfMemoryError: PermGen

相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入方法区后就“永久存在”了。

        JDK 1.8 的时候,方法区(HotSpot 的永久代)被彻底移除了(JDK1.7 就已经开始了),取而代之是元空间,元空间使用的是本地内存。下面是一些常用参数:

-XX:MetaspaceSize=N //设置 Metaspace 的初始(和最小大小)
-XX:MaxMetaspaceSize=N //设置 Metaspace 的最大大小

        与永久代很大的不同就是,如果不指定大小的话,随着更多类的创建,虚拟机会耗尽所有可用的系统内存

1.5.4 运行时常量池(存在于方法区)

        Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有用于存放编译期生成的各种字面量(Literal)和符号引用(Symbolic Reference)的 常量池表(Constant Pool Table) 

  1. 字面量是源代码中的固定值的表示法,即通过字面我们就能知道其值的含义。字面量包括整数、浮点数和字符串字面量。
  2. 常见的符号引用包括类符号引用、字段符号引用、方法符号引用、接口方法符号引用,符号引用的格式是一致的。
  3. 常量池表会在类加载后存放到方法区的运行时常量池中。存储了编译时期生成各种字面量和符号引用,这些常量在后续的类链接(Linking)和初始化(Initialization)阶段中会被解析为相应的内存地址或直接引用。还存储了方法句柄(Java方法的一个引用方法类型,动态变量(值在运行时才确定,通常用于动态链接库(如JNI)或动态计算)。

注:

  1. 既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 错误
  2. 符号引用(Symbolic References)

    • 类和接口的全限定名(Fully Qualified Name)
    • 字段的名称和描述符(Name and Descriptor)
    • 方法的名称和描述符(Name and Descriptor)

    描述符用于描述字段的数据类型方法的参数列表(包括返回值类型)。

1.6 直接内存

        直接内存是一种特殊的内存缓冲区,并不在 Java 堆或方法区中分配的,而是通过 JNI (允许Java代码调用本地方法的方式在本地内存上分配的。直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致 OutOfMemoryError 错误出现。

        直接内存是Java NIO(New I/O)引入的一种内存管理机制。JDK1.4 中新加入的 NIO(Non-Blocking I/O,也被称为 New I/O),引入了一种基于通道(Channel)缓存区(Buffer)的 I/O 方式,它可以直接使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。它允许Java程序直接访问本地系统的内存,而不是通过Java堆进行内存分配和回收。这样做的好处是可以减少Java堆和本地内存(Native堆,堆外内存)之间的数据复制,从而提高I/O操作的效率

注:

  1. 直接内存的分配不会受到 Java 堆的限制,操作系统分配和管理,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制
  2. 堆外内存和直接内存的区别堆外内存就是把内存对象分配在堆外的内存,这些内存直接受操作系统管理(而不是虚拟机),这样做的结果就是能够在一定程度上减少垃圾回收对应用程序造成的影响直接内存是堆外内存的一种。

2. HotSpot虚拟机

2.1 对象的创建(重要)

  1. 类加载检查 虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过解析初始化过。如果没有,那必须先执行相应的类加载过程
  2. 分配内存 类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。分配方式“指针碰撞”“空闲列表” 两种,选择哪种分配方式 Java 堆是否规整决定,而 Java 堆是否规整由所采用的垃圾收集器是否带有压缩整理功能决定。
  3. 初始化零值 内存分配完成后,虚拟机需要将分配到的内存空间初始化为零值不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
  4. 设置对象头 初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
  5. 执行init方法 在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,<init> 方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 <init> (类的构造函数)方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

2.2 内存分配的两种方式

  • 指针碰撞:
    • 适用场合:堆内存规整(即没有内存碎片)的情况下。
    • 原理:用过的内存全部整合到一边,没有用过的内存放在另一边,中间有一个分界指针,只需要向着没用过的内存方向将该指针移动对象内存大小位置即可。
    • 使用该分配方式的 GC 收集器:Serial, ParNew
  • 空闲列表:
    • 适用场合:堆内存不规整的情况下。
    • 原理:虚拟机会维护一个列表,该列表中会记录哪些内存块是可用的,在分配的时候,找一块儿足够大的内存块儿来划分给对象实例,最后更新列表记录
    • 使用该分配方式的 GC 收集器:CMS

        选择以上两种方式中的哪一种,取决于 Java 堆内存是否规整。而 Java 堆内存是否规整,取决于 GC 收集器的算法"标记-清除",还是"标记-整理"(也称作"标记-压缩"),值得注意的是,复制算法内存也是规整的。

2.3 内存分配并发问题

        在创建对象的时候有一个很重要的问题,就是线程安全,在并发环境中,多个线程可能同时尝试为对象分配内存。这可能导致竞态条件,即一个线程正在为对象A分配内存尚未更新指针,而另一个线程此时已经使用旧的指针对象B分配内存,从而造成内存分配冲突或数据不一致。通常来讲,虚拟机采用两种方式来保证线程安全:

  • CAS+失败重试: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。
  • TLAB:TLAB为每个线程在Java堆中预先分配一小块内存(Eden区)。当线程需要为新对象分配内存时,会首先尝试在自己的TLAB中进行分配。如果TLAB中的内存足够,则分配过程不会受到其他线程的干扰,从而避免了并发问题。如果TLAB中的内存不足,线程会进行正常的内存分配流程,但此时会涉及到同步和CAS等机制来保证操作的原子性。

2.4 对象的内存布局

        在 Hotspot 虚拟机中,对象在内存中的布局可以分为 3 块区域:对象头(Header)实例数据(Instance Data)对齐填充(Padding)

对象头包括两部分信息:

  1. 标记字段(Mark Word):用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。
  2. 类型指针(Klass Word):对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

        实例数据部分是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容

        对齐填充部分不是必然存在的,也没有什么特别的含义,仅仅起占位作用。 因为 Hotspot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,换句话说就是对象的大小必须是 8 字节的整数倍。而对象头部分正好是 8 字节的倍数(1 倍或 2 倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全

2.5 对象的访问定位(重要)

        建立对象就是为了使用对象,我们的 Java 程序通过栈上的 reference 数据来操作堆上的具体对象。对象的访问方式由虚拟机实现而定,目前主流的访问方式有:使用句柄直接指针

  • 如果使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据对象类型数据各自的具体地址信息
  • 如果使用直接指针访问,reference 中存储的直接就是对象的地址,对象类型数据则仍然是指向方法区类信息的指针

        这两种对象访问方式各有优势。使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改(介不就是解耦吗)。使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销

3. 垃圾回收机制(重要)

        Java 堆是垃圾收集器管理的主要区域,因此也被称作 GC 堆(Garbage Collected Heap)。从垃圾回收的角度来说,由于现在收集器基本都采用分代垃圾收集算法,所以 Java 堆被划分为了几个不同的区域,这样我们就可以根据各个区域的特点选择合适的垃圾收集算法

3.1 内存分配和回收原则

3.1.1 对象优先再Eden区分配

        大多数情况下,对象在新生代中 Eden 区分配。当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC

        当 Eden 区没有足够空间进行分配给新对象时,虚拟机将发起一次 Minor GC。GC 期间虚拟机又发现新对象无法存入 Survivor 空间,所以只好通过 分配担保机制 把新生代的对象提前转移到老年代中去,老年代上的空间足够存放旧对象,所以不会出现 Full GC。执行 Minor GC 后,新对象如果能够存在 Eden 区的话,还是会在 Eden 区分配内存

3.1.2 大对象直接进入老年代

        大对象就是需要大量连续内存空间的对象(比如:字符串、数组)。

        大对象直接进入老年代的行为是由虚拟机动态决定的,它与具体使用的垃圾回收器和相关参数有关。大对象直接进入老年代是一种优化策略,旨在避免将大对象放入新生代,从而减少新生代的垃圾回收频率和成本

  • G1 垃圾回收器会根据 -XX:G1HeapRegionSize 参数设置的堆区域大小和 -XX:G1MixedGCLiveThresholdPercent 参数设置的阈值,来决定哪些对象会直接进入老年代。
  • Parallel Scavenge 垃圾回收器中,默认情况下,并没有一个固定的阈值(XX:ThresholdTolerance是动态调整的)来决定何时直接在老年代分配大对象。而是由虚拟机根据当前的堆内存情况和历史数据动态决定。

3.1.3 长期存活的对象将进入老年代

        既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代中。为了做到这一点,虚拟机给每个对象一个对象年龄(Age)计数器。

  • 大部分情况,对象都会首先在 Eden 区域分配。如果对象在 Eden 出生并经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间(s0 或者 s1)中,并将对象年龄设为 1(Eden 区->Survivor 区后对象的初始年龄变为 1)。
  • 对象在 Survivor 中每熬过一次 MinorGC,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为15岁,Hotspot 遍历所有对象时,按照年龄从小到大对其所占用的大小进行累积,当累积的某个年龄大小超过了 survivor 区的 50% 时,取这个年龄MaxTenuringThreshold 中更小的一个值,作为新的晋升年龄阈值”),就会被晋升到老年代中。

注:默认晋升年龄并不都是 15,这个是要区分垃圾收集器的,CMS 就是 6

3.1.4 主要进行gc的区域

针对 HotSpot VM 的实现,它里面的 GC 其实准确分类只有两大种:

部分收集 (Partial GC):

  • 新生代收集(Minor GC / Young GC):只对新生代进行垃圾收集;
  • 老年代收集(Major GC / Old GC):只对老年代进行垃圾收集。需要注意的是 Major GC 在有的语境中也用于指代整堆收集;
  • 混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集。

整堆收集 (Full GC):收集整个 Java 堆和方法区。

3.1.5 空间分配担保

        空间分配担保是为了确保在 Minor GC 之前老年代本身还有容纳新生代所有对象的剩余空间。

  • JDK 6 Update 24 之前,在发生 Minor GC 之前,虚拟机必须先检查老年代最大可用连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次 Minor GC 可以确保是安全的。如果不成立,则虚拟机会先查看 -XX:HandlePromotionFailure 参数的设置值是否允许担保失败(Handle Promotion Failure);如果允许,那会继续检查老年代最大可用连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次 Minor GC,尽管这次 Minor GC 是有风险的;如果小于,或者 -XX: HandlePromotionFailure 设置不允许冒险,那这时就要改为进行一次 Full GC
  • JDK 6 Update 24 之后的规则变为:在发生 Minor GC 之前,只要老年代连续空间大于新生代对象总大小或者历次晋升的平均大小,就会进行 Minor GC否则将进行 Full GC。(直接允许冒险了属于是)

3.2 死亡对象判断方法

        堆中几乎放着所有的对象实例,对堆垃圾回收前的第一步就是要判断哪些对象已经死亡(即不能再被任何途径使用的对象)。

3.2.1 引用计数法

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

  • 每当有一个地方引用它,计数器就加 1
  • 引用失效(引用被设置为null),计数器就减 1
  • 任何时候计数器为 0 的对象就是不可能再被使用的。
  • 这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间循环引用的问题。

循环引用案例:

public class ReferenceCountingGc {
    Object instance = null;
    public static void main(String[] args) {
        ReferenceCountingGc objA = new ReferenceCountingGc();
        ReferenceCountingGc objB = new ReferenceCountingGc();
        objA.instance = objB;
        objB.instance = objA;
        objA = null;
        objB = null;
    }
}

        所谓对象之间的相互引用问题,如上面代码所示:除了对象 objAobjB 相互引用着对方之外,这两个对象之间再无任何引用。但是他们因为互相引用对方,导致它们的引用计数器都不为 0(除非objA.instance = null & objB.instance = null),于是引用计数算法无法通知 GC 回收器回收他们。

3.2.2 可达性分析算法(可解决循环引用)

        这个算法的基本思想就是通过一系列的称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的,需要被回收。算法步骤:

  1. 从根对象开始:将根对象标记为“活动”状态。
  2. 遍历引用:遍历根对象的引用,将所有被引用对象也标记为“活动”状态。
  3. 迭代遍历:迭代遍历被引用对象的引用,将被引用对象也标记为“活动”状态。
  4. 重复上述步骤:直到没有更多的对象能够被标记为“活动”状态。
  5. 判断与回收:最后,未被标记的对象即为不可达对象,可以被回收释放内存。

        图中的 Object 6 ~ Object 10 之间虽有引用关系,但它们到 GC Roots 不可达,因此为需要被回收的对象。

注:可达性分析算法可以解决循环引用的问题。在可达性分析中,算法从GC Roots开始,基于图的递归地搜索所有可达的对象。即使两个或多个对象之间存在循环引用(即它们相互引用彼此),只要这些对象没有通过任何GC Roots直接或间接可达,它们就会被标记为不可达,并最终被垃圾收集器回收

3.2.2.1 哪些对象可以作为GC Roots?
  • 虚拟机栈(栈帧中的局部变量表)中引用的对象
  • 本地方法栈(Native 方法)中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区常量引用的对象
  • 所有被同步锁持有的对象
  • JNI(Java Native Interface)引用的对象
3.2.2.2 对象可以被回收,就代表一定会被回收吗?

        即使在可达性分析法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑阶段”,要真正宣告一个对象死亡,至少要经历两次标记过程。

        可达性分析法中不可达的对象第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize 方法。当对象没有覆盖 finalize 方法,或 finalize 方法已经被虚拟机调用过时,虚拟机将这两种情况视为没有必要执行

       当对象覆盖了finalize方法,则对被判定为需要执行,被判定为需要执行的对象将会被放在一个名为F-Queue队列中进行第二次标记,稍后由一条虚拟机自动建立的、低优先级的Finalizer线程去执行。这是对象在被回收前的最后一次自救机会。

3.2.3 引用类型总结

        JDK1.2 之前,Java 中引用的定义很传统:如果 reference 类型的数据存储的数值代表的是另一块内存的起始地址,就称这块内存代表一个引用。

        JDK1.2 以后,Java 对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用、虚引用四种(引用强度逐渐减弱)

1.强引用(StrongReference)

        以前我们使用的大部分引用实际上都是强引用,这是使用最普遍的引用。如果一个对象具有强引用,那就类似于必不可少的生活用品,垃圾回收器绝不会回收它。当内存空间不足,Java 虚拟机宁愿抛出 OutOfMemoryError 错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足问题。

2.软引用(SoftReference)

        如果一个对象只具有软引用,那就类似于可有可无的生活用品。如果内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可用来实现内存敏感的高速缓存

        软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收,JAVA 虚拟机就会把这个软引用加入到与之关联的引用队列中。

3.弱引用(WeakReference)

        如果一个对象只具有弱引用,那就类似于可有可无的生活用品弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。

        弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java 虚拟机就会把这个弱引用加入到与之关联的引用队列中。

4.虚引用(PhantomReference)

        "虚引用"顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样在任何时候都可能被垃圾回收

注:虚引用主要用来跟踪对象被垃圾回收的活动

虚引用与软引用和弱引用的区别

        虚引用必须和引用队列(ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。程序如果发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动

:特别注意,在程序设计中一般很少使用弱引用与虚引用,使用软引用的情况较多,这是因为软引用可以加速 JVM 对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出(OutOfMemory)等问题的产生

3.2.4 如何判断一个常量是废弃常量

字符串常量池和运行时常量池的变化:

  • JDK1.7 之前运行时常量池逻辑包含字符串常量池存放在方法区, 此时 hotspot 虚拟机对方法区的实现为永久代
  • JDK1.7 字符串常量池被从方法区拿到了堆中, 运行时常量池剩下的东西还在方法区, 也就是 hotspot 中的永久代
  • JDK1.8 hotspot 移除了永久代用元空间(Metaspace)取而代之, 这时候字符串常量池还在堆, 运行时常量池还在方法区, 只不过方法区的实现从永久代变成了元空间(Metaspace)

        假如在字符串常量池中存在字符串 "abc",如果当前没有任何 String 对象引用该字符串常量的话,就说明常量 "abc" 就是废弃常量,如果这时发生内存回收的话而且有必要的话,"abc" 就会被系统清理出常量池了。

3.2.5 如何判断一个类是无用的类

        方法区主要回收的是无用的类,判定一个常量是否是“废弃常量”比较简单,而要判定一个类是否是“无用的类”的条件则相对苛刻许多。类需要同时满足下面 3 个条件才能算是 “无用的类”

  • 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
  • 加载该类的 ClassLoader 已经被回收
  • 该类对应的 java.lang.Class 对象没有在任何地方被引用无法在任何地方通过反射访问该类的方法。

        虚拟机可以对满足上述 3 个条件的无用类进行回收,这里说的仅仅是“可以”,而并不是和对象一样不使用了就会必然被回收。

3.3 垃圾收集算法

3.3.1 标记-清除算法

        标记-清除(Mark-and-Sweep)算法分为“标记(Mark)”和“清除(Sweep)”阶段:首先标记出所有不需要回收的对象,在标记完成统一回收掉所有没有被标记的对象。它是最基础的收集算法,后续的算法都是对其不足进行改进得到。这种垃圾收集算法会带来两个明显的问题:

  1. 效率问题:标记和清除两个过程效率都不高。
  2. 空间问题:标记清除后会产生大量不连续的内存碎片。

标记-清除的大致过程
  1. 当一个对象被创建时,给一个标记位,假设为 0 (false);
  2. 在标记阶段,我们将所有可达对象(或用户可以引用的对象)的标记位设置为 1 (true);
  3. 扫描阶段清除的就是标记位为 0 (false)的对象。

3.3.2 标记-复制算法

        为了解决标记-清除算法的效率和内存碎片问题,复制(Copying)收集算法出现了。它可以将内存分为大小相同的两块每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收

虽然改进了标记-清除算法,但依然存在下面这些问题

  • 可用内存变小:可用内存缩小为原来的一半。
  • 不适合老年代:如果存活对象数量比较大,复制性能会变得很差(因为大对象都会存在老年代)

3.3.3 标记-整理算法

        复制算法的高效性是建立在存活对象少、垃圾对象多的前提下的。这种情况在新生代经常发生,但是在老年代,更常见的情况是大部分对象都是存活对象。如果依然使用复制算法,由于存活对象较多,复制的成本也将很高。因此,基于老年代垃圾回收的特性,需要使用其他的算法。

        标记-清除算法的确可以应用在老年代中,但是该算法不仅执行效率低下,而且在执行完内存回收后还会产生内存碎片,所以 JVM 的设计者需要在此基础之上进行改进。标记-压缩(Mark-Compact)算法由此诞生。

标记-整理算法的步骤
  1. 第一阶段和标记清除算法一样,从根节点开始标记所有被引用对象。
  2. 第二阶段将所有的存活对象压缩到内存的一端,按顺序排放。之后,清理边界外所有的空间

 与标记-清除的区别
  • 标记-清除算法是一种非移动式的回收算法,标记-压缩移动式的。
  • 是否移动回收后的存活对象是一项优缺点并存的风险决策。可以看到,标记的存活对象将会被整理,按照内存地址依次排列,而未被标记的内存会被清理掉。如此一来,当我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可,这比维护一个空闲列表(JVM维护,用于跟踪空闲的内存块显然少了许多开销
优缺点比较

1 优点

  • 消除标记-清除算法当中,内存区域分散的缺点,我们需要给新对象分配内存时,JVM 只需要持有一个内存的起始地址即可。
  • 消除复制算法当中,内存减半的高额代价。

2 缺点

  • 效率上来说,标记-整理算法要低于复制算法
  • 移动对象的同时,如果对象被其他对象引用,则还需要调整引用的地址
  • 移动过程中,需要全程暂停用户应用程序。即:STW。

3.3.4 分代收集算法

        当前虚拟机的垃圾收集都采用分代收集算法,这种算法没有什么新的思想,只是根据对象存活周期的不同将内存分为几块。一般将 Java 堆分为新生代老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。

        比如在新生代中,每次收集都会有大量对象死去,所以可以选择”标记-复制“算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾收集。

3.4 垃圾收集器

        虽然我们对各个收集器进行比较,但并非要挑选出一个最好的收集器。因为直到现在为止还没有最好的垃圾收集器出现,更加没有万能的垃圾收集器,我们能做的就是根据具体应用场景选择适合自己的垃圾收集器

JDK 默认垃圾收集器(使用 java -XX:+PrintCommandLineFlags -version 命令查看):

  • JDK 8:Parallel Scavenge(新生代)+ Parallel Old(老年代)
  • JDK 9 ~ JDK20: G1

3.4.1 Serial收集器(新生代)

        Serial(串行)收集器是最基本、历史最悠久的垃圾收集器了。大家看名字就知道这个收集器是一个单线程收集器了。它的 “单线程” 的意义不仅仅意味着它只会使用一条垃圾收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集工作的时候必须暂停其他所有的工作线程"Stop The World" ),直到它收集结束

新生代采用标记-复制算法,老年代采用标记-整理算法。

        虚拟机的设计者们当然知道 Stop The World 带来的不良用户体验,所以在后续的垃圾收集器设计中停顿时间在不断缩短(仍然还有停顿,寻找最优秀的垃圾收集器的过程仍然在继续)。

        但是 Serial 收集器有没有优于其他垃圾收集器的地方呢?当然有,它简单而高效(与其他收集器的单线程相比)。Serial 收集器由于没有线程交互的开销,自然可以获得很高的单线程收集效率。Serial 收集器对于运行在 Client 模式下的虚拟机来说是个不错的选择。

3.4.2 ParNew收集器(新生代)

        ParNew 收集器其实就是 Serial 收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为(控制参数、收集算法、回收策略等等)和 Serial 收集器完全一样。

新生代采用标记-复制算法,老年代采用标记-整理算法。

        它是许多运行在 Server 模式下的虚拟机的首要选择,除了 Serial 收集器外,只有它能 CMS 收集器(真正意义上的并发收集器,后面会介绍到)配合工作

并行和并发概念补充:

  • 并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态

  • 并发(Concurrent):指用户线程垃圾收集线程同时执行(但不一定是并行,可能会交替执行),用户程序在继续运行,而垃圾收集器运行在另一个 CPU 上。

3.4.3 Parallel Scavenge 收集器(新生代)

        Parallel Scavenge 收集器也是使用标记-复制算法的多线程收集器,JDK1.8 默认使用的是 Parallel Scavenge + Parallel Old。

与ParNew的区别:

  • Parallel Scavenge的主要目标是最大化应用程序的吞吐量,即应用程序运行时间与总运行时间(包括垃圾回收时间)的比值。它更适合后台运算为主的场景,如大型计算任务、批处理等。
  • ParNew注重的是降低暂停时间,保证低延迟,可能会牺牲部分吞吐量,因此更适合需要低延迟的应用,如Web服务器、交互式应用等。

        Parallel Scavenge 收集器关注点是吞吐量高效率的利用 CPU)。CMS 等垃圾收集器的关注点更多的是用户线程的停顿时间(提高用户体验)。所谓吞吐量就是 CPU 中用于运行用户代码的时间与 CPU 总消耗时间的比值。 Parallel Scavenge 收集器提供了很多参数供用户找到最合适的停顿时间或最大吞吐量,如果对于收集器运作不太了解,手工优化存在困难的时候,使用 Parallel Scavenge 收集器配合自适应调节策略,把内存管理优化交给虚拟机去完成也是一个不错的选择。

新生代采用标记-复制算法,老年代采用标记-整理算法。

3.4.4 Serial Old收集器

        Serial 收集器的老年代版本,它同样是一个单线程收集器,采用标记-整理算法。它主要有两大用途:一种用途是在 JDK1.5 以及以前的版本中与 Parallel Scavenge 收集器搭配使用,另一种用途是作为 CMS 收集器的后备方案。

3.4.5 Parallel Old收集器

        Parallel Scavenge 收集器的老年代版本。使用多线程和标记-整理算法。在注重吞吐量以及 CPU 资源的场合,都可以优先考虑 Parallel Scavenge 收集器和 Parallel Old 收集器。

3.4.6 CMS收集器(标记-清除,老年代)

        CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用上使用,主要针对老年代进行垃圾回收。CMS收集器是 HotSpot 虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作

注:CMS 垃圾回收器在 Java 9 中已经被标记为过时(deprecated),并在 Java 14 中被移除。

        从名字中的Mark Sweep这两个词可以看出,CMS 收集器是一种 “标记-清除”算法实现的,它的运作过程相比于前面几种垃圾收集器来说更加复杂一些。整个过程分为四个步骤:

  • 初始标记: 暂停所有的其他线程,并记录下直接与 root 相连的对象,速度很快 ;
  • 并发标记: 同时开启 GC 和用户线程,用一个闭包结构记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用域,所以 GC 线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方。
  • 重新标记: 重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短。
  • 并发清除: 开启用户线程,同时 GC 线程开始对未标记的区域做清扫

        从它的名字就可以看出它是一款优秀的垃圾收集器,主要优点:并发收集、低停顿。但是它有下面三个明显的缺点

  • 对 CPU 资源敏感:当CPU个数较少时,CMS收集器可能会占用较多的CPU资源,从而影响程序的运行效率;
  • 无法处理浮动垃圾:CMS垃圾收集器在并发标记阶段本来可达的对象,由于用户线程的作用变得不可达了,即产生新的垃圾对象,这些对象被称为“浮动垃圾”。如果浮动垃圾过多,可能会导致“Concurrent Mode Failure”失败,从而触发一次Full GC;
  • 对象消失问题,即在扫描完这个节点所有相连节点前原本可达变成不可达
  • 它使用的回收算法-“标记-清除”算法会导致收集结束时会有大量空间碎片产生
对象消失问题

产生的条件:

  1. 赋值器插入了一条或者多条从黑色对象(全都遍历完)到白色对象(还没开始遍历)的新引用。
  2. 赋值器删除了全部从灰色对象(遍历了但还有没遍历的)到该白色对象的直接或间接引用。

黑色对象5到白色对象9之间的引用是新建的,对应条件一。

黑色对象6到白色对象9之间的引用被删除了,对应条件二。

如何解决并发扫描时对象消失问题?

CMS是基于增量更新来做并发标记的,G1则采用的是原始快照的方式。

  • 增量更新破坏的是第一个条件(赋值器插入了一条或者多条从黑色对象到白色对象的新引用),当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。可以理解为:黑色对象一旦插入了指向白色对象的引用之后,它就变回了灰色对象,在对这些灰色对象进行遍历扫描
  • 原始快照破坏的是第二个条件(赋值器删除了全部从灰色对象到该白色对象的直接或间接引用),当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根重新扫描一次。可以简化理解为:无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照开进行搜索

注:对引用关系记录的插入还是删除,虚拟机的记录操作都是通过写屏障实现的

3.4.7 G1收集器(标记-整理,标记-复制,不依赖其他收集器)

        G1 (Garbage-First) 是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征.

被视为 JDK1.7 中 HotSpot 虚拟机的一个重要进化特征。它具备以下特点:

  • 并行与并发:G1 能充分利用 CPU、多核环境下的硬件优势,使用多个 CPU(CPU 或者 CPU 核心)来缩短 Stop-The-World 停顿时间。部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 java 程序继续执行。
  • 分代收集:虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但是还是保留了分代的概念。
  • 空间整合:与 CMS 的“标记-清除”算法不同,G1 从整体来看是基于“标记-整理”算法实现的收集器;从局部上来看是基于“标记-复制”算法实现的。
  • 可预测的停顿:这是 G1 相对于 CMS 的另一个大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒。
G1 收集器的运作大致分为以下几个步骤
  • 初始标记
  • 并发标记
  • 最终标记
  • 筛选回收

        G1 收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的 Region(这也就是它的名字 Garbage-First 的由来) 。这种使用 Region 划分内存空间以及有优先级的区域回收方式,保证了 G1 收集器在有限时间内可以尽可能高的收集效率(把内存化整为零)。

注:从 JDK9 开始,G1 垃圾收集器成为了默认的垃圾收集器。

3.4.8 ZGC收集器(标记-复制,不依赖收集器)

        ZGC 也采用标记-复制算法,不过 ZGC 对该算法做了重大改进。ZGC 可以将暂停时间控制在几毫秒以内,且暂停时间不受堆内存大小的影响,出现 Stop The World 的情况会更少,但代价是牺牲了一些吞吐量。ZGC 最大支持 16TB 的堆内存。

        ZGC 在 Java11 中引入,处于试验阶段。经过多个版本的迭代,不断的完善和修复问题,ZGC 在 Java15 已经可以正式使用了。

4. 类加载器

4.1 类的生命周期

        类从被加载到虚拟机内存中开始到卸载出内存为止,它的整个生命周期可以简单概括为 7 个阶段::加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)。其中,验证、准备和解析这三个阶段可以统称为连接(Linking)。

4.2 类的加载过程

        类加载(Class Loading)是Java运行时环境(JRE)的一个重要部分,它负责在运行时动态地加载Java类到Java虚拟机(JVM)中。类加载过程主要可以分为三个步骤:加载(Loading)、链接(Linking)和初始化(Initialization)。而链接过程又可以细分为验证(Verification)、准备(Preparation)和解析(Resolution)三个阶段。

1. 加载(Loading)

  • 加载 是类加载的第一个阶段。在这个阶段,JVM需要完成以下三件事情:
    1. 通过一个类的全限定名来获取定义此类的二进制字节流
    2. 将这个字节流所代表的静态存储结构转化方法区运行时数据结构
    3. 内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口(存储类的元数据)

注:虚拟机规范上面这 3 点并不具体,因此是非常灵活的,比如:"通过全类名获取定义此类的二进制字节流" 并没有指明具体从哪里获取( ZIP、 JAREARWAR、网络、动态代理技术运行时动态生成、其他文件生成比如 JSP...)。加载这一步主要是通过 类加载器 完成的。类加载器有很多种,当我们想要加载一个类的时候,具体是哪个类加载器加载由 双亲委派模型 决定(不过,我们也能打破由双亲委派模型)。

2. 链接(Linking)

2.1 验证(Verification)
  • 验证 的目的是确保被加载的类的正确性安全性,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。验证阶段包括文件格式验证、元数据验证、字节码验证和符号引用验证四个验证点。
  • 验证阶段主要由四个检验阶段组成:文件格式验证(Class 文件格式检查),元数据验证(字节码语义检查),字节码验证(程序语义检查),符号引用验证(类的正确性检查)

注:文件格式验证这一阶段是基于该类的二进制字节流进行的,主要目的是保证输入的字节流能正确地解析并存储于方法区之内,格式上符合描述一个 Java 类型信息的要求。除了这一阶段之外,其余三个验证阶段都是基于方法区的存储结构上进行的,不会再直接读取、操作字节流了

2.2 准备(Preparation)
  • 准备 阶段是为类的静态变量分配内存,并将其初始化为默认值。这里所说的“默认值”并不是Java代码中的初始值,而是数据类型的默认值(如0、false、null等)。

注:

  1. 这时候进行内存分配的仅包括类变量( Class Variables ,即静态变量,被 static 关键字修饰的变量,只与类相关,因此被称为类变量),而不包括实例变量实例变量会在对象实例化时随着对象一块分配在 Java 堆中。
  2. 从概念上讲,类变量所使用的内存都应当在 方法区 中进行分配。不过有一点需要注意的是:JDK 7 之前,HotSpot 使用永久代来实现方法区的时候,实现是完全符合这种逻辑概念的。 而在 JDK 7 及之后,HotSpot 已经把原本放在永久代的字符串常量池、静态变量移动到堆中,这个时候类变量则会随着 Class 对象一起存放在 Java 堆中。
  3. 这里所设置的初始值"通常情况"下是数据类型默认的零值(如 0、0L、null、false 等),比如我们定义了public static int value=111 ,那么 value 变量在准备阶段的初始值就是 0 而不是 111(初始化阶段才会赋值)。特殊情况:比如给 value 变量加上了 final 关键字public static final int value=111 ,那么准备阶段 value 的值就被赋值为 111
2.3 解析(Resolution)
  • 解析 是虚拟机将常量池内的符号引用替换为直接引用的过程(即内存地址)。解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符 7 类符号引用进行。
  • 符号引用在Java源文件被编译成字节码文件时产生,而直接引用则在类加载的解析阶段将符号引用替换为指向方法区中目标的指针相对偏移量或能直接定位的句柄(这些都是直接引用)等。解析阶段有可能会伴随Java虚拟机类加载器一起进行。

注:

  1. 并非所有的符号引用都会在这个阶段被解析。特别是对于一些需要在运行时才能确定具体引用的方法(如虚方法调用),它们的符号引用可能会被保留到运行时才进行解析
  2. 虚方法(Virtual Method)是一个可以在子类中被重写(Override)的方法。当一个方法在父类中被声明为virtual(在C++中)或默认(在Java中,所有非静态、非私有、非final和非static的方法都是隐式的虚方法

3. 初始化(Initialization)

  • 初始化 阶段是执行初始化方法 <clinit> ()方法的过程,是类加载过程的最后一步。这一步 JVM 才开始真正执行类中定义的 Java 程序代码(字节码)。对于<clinit> () 方法的调用,虚拟机会自己确保其在多线程环境中的安全性。因为 <clinit> () 方法是带锁线程安全,所以在多线程环境下进行类初始化的话可能会引起多个线程阻塞,并且这种阻塞很难被发现。
  • 在准备阶段,变量已经赋过一次系统要求的初始值,而在初始化阶段,则是根据程序员通过程序制定的主观计划去初始化类变量和其他资源。初始化阶段是执行类构造器<clinit>()方法的过程。此方法是由编译器自动收集类中的所有类变量的赋值动作静态代码块(static{}块)中的语句合并产生的。

注:<clinit> ()方法是编译之后自动生成的。

4.3 类的加载时机

对于初始化阶段,虚拟机严格规范了有且只有 6 种情况下,必须对类进行初始化(加载)。

  1. 当遇到 newgetstaticputstaticinvokestatic 这 4 条字节码指令时,比如 new 一个类,读取一个静态字段(未被 final 修饰)、或调用一个类的静态方法时。
    • 当 jvm 执行 new 指令时会初始化类。即当程序创建一个类的实例对象。
    • 当 jvm 执行 getstatic 指令时会初始化类。即程序访问类的静态变量(不是静态常量,常量会被加载到运行时常量池)。
    • 当 jvm 执行 putstatic 指令时会初始化类。即程序给类的静态变量赋值。
    • 当 jvm 执行 invokestatic 指令时会初始化类。即程序调用类的静态方法。
  2. 使用 java.lang.reflect 包的方法对类进行反射调用时如 Class.forName("..."), newInstance() 等等。如果类没初始化,需要触发其初始化。
  3. 初始化一个类,如果其父类还未初始化,则先触发该父类的初始化。
  4. 当虚拟机启动时,用户需要定义一个要执行的主类 (包含 main 方法的那个类),虚拟机会先初始化这个类。
  5. MethodHandleVarHandle 可以看作是轻量级的反射调用机制,而要想使用这 2 个调用,
    就必须先使用 findStaticVarHandle 来初始化要调用的类。
  6. 当一个接口中定义了 JDK8 新加入的默认方法(被 default 关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

4.4 类的卸载

卸载类即该类的 Class 对象被 GC。

卸载类需要满足 3 个要求:

  1. 该类的所有的实例对象都已被 GC,也就是说堆不存在该类的实例对象。
  2. 该类没有在其他任何地方被引用
  3. 该类的类加载器的实例已被 GC

        所以,在 JVM 生命周期内,由 jvm 自带的类加载器加载的类是不会被卸载的。但是由我们自定义的类加载器加载的类是可能被卸载的。

        只要想通一点就好了,JDK 自带的 BootstrapClassLoader, ExtClassLoader, AppClassLoader 负责加载 JDK 提供的类,所以它们(类加载器的实例)肯定不会被回收。而我们自定义的类加载器的实例是可以被回收的,所以使用我们自定义加载器加载的类可以被卸载掉的。

注:

  1. 需要注意的是,类的加载过程是由类加载器(ClassLoader)来完成的,而类加载器本身也是一个Java类java.lang.ClassLoader)。JVM中提供了三种类加载器:引导类加载器(Bootstrap ClassLoader)、扩展类加载器(Extension ClassLoader)和系统类加载器(System ClassLoader)。此外,开发人员还可以自定义类加载器。
  2. 类中声明的静态变量(static变量)的引用会存储在方法区中,但静态变量的实际值通常存储在堆内存中。静态变量在方法区中的引用仅是一个指向其值的内存地址

4.4 类加载器(其类的加载过程中的加载)

加载是类加载过程的第一步,主要完成下面 3 件事情:

  1. 通过全类名获取定义此类的二进制字节流
  2. 字节流所代表的静态存储结构转换为方法区的运行时数据结构
  3. 在内存中生成一个代表该类的 Class 对象,作为方法区这些数据的访问入口

4.4.1 类加载器概述

  • ClassLoader 是一个抽象类,所有的类加载器都是java.lang.ClassLoader类或其子类的实例,用于实现类加载过程中的加载这一步。
  • 每个 Java 类都有一个引用指向加载它的 ClassLoader
  • 数组类不是通过 ClassLoader 创建的(数组类没有对应的二进制字节流),是由 JVM 直接生成的。

        简单来说,类加载器的主要作用就是加载 Java 类的字节码( .class 文件)到 JVM 中(在内存中生成一个代表该类的 Class 对象)。 字节码可以是 Java 源程序(.java文件)经过 javac 编译得来,也可以是通过工具动态生成或者通过网络下载得来。

注:其实除了加载类之外,类加载器还可以加载 Java 应用所需的资源如文本、图像、配置文件、视频等等文件资源。

4.4.2 类加载器加载规则

        JVM 启动的时候,并不会一次性加载所有的类,而是根据需要去动态加载。也就是说,大部分类具体用到的时候才会去加载,这样对内存更加友好。

        对于已经加载的类会被放在 ClassLoader 中。在类加载的时候,系统会首先判断当前类是否被加载过已经被加载的类会直接返回否则才会尝试加载。也就是说,对于一个类加载器来说,相同二进制名称的类只会被加载一次

4.4.3 类加载器类型

JVM 中内置了三个重要的 ClassLoader

  • BootstrapClassLoader(启动类加载器):最顶层的加载类,由 C++实现,通常表示为 null,并且没有父级,主要用来加载 JDK 内部的核心类库%JAVA_HOME%/lib目录下的 rt.jarresources.jarcharsets.jar等 jar 包和类)以及被 -Xbootclasspath参数指定的路径下的所有类。
  • ExtensionClassLoader(扩展类加载器):主要负责加载 %JRE_HOME%/lib/ext 目录下的 jar 包和类以及被 java.ext.dirs 系统变量所指定的路径下的所有类。
  • AppClassLoader(应用程序类加载器):面向我们用户的加载器,负责加载当前应用 classpath 下的所有 jar 包和类。

注:

  1. rt.jar:rt 代表“RunTime”,rt.jar是 Java 基础类库,包含 Java doc 里面看到的所有的类的类文件。也就是说,我们常用内置库 java.xxx.*都在里面,比如java.util.*java.io.*java.nio.*java.lang.*java.sql.*java.math.*
  2. Java 9 引入了模块系统,并且略微更改了上述的类加载器。扩展类加载器被改名为平台类加载器(platform class loader)。Java SE 中除了少数几个关键模块,比如说 java.base 是由启动类加载器加载之外,其他的模块均由平台类加载器所加载。

        除了这三种类加载器之外,用户还可以继承 java.lang.ClassLoader 类并重写其相关方法来实现自定义的类加载器来进行拓展,以满足自己的特殊需求。就比如说,我们可以对 Java 类的字节码( .class 文件)进行加密,加载时再利用自定义的类加载器对其解密。

        每个 ClassLoader 可以通过getParent()获取其父 ClassLoader,如果获取到 ClassLoadernull的话,那么该类是通过 BootstrapClassLoader 加载的。这是因为BootstrapClassLoader 由 C++ 实现,由于这个 C++ 实现的类加载器在 Java 中是没有与之对应的类的,所以拿到的结果是 null。

4.4.4 自定义类加载器

如果我们要自定义自己的类加载器,需要继承 java.lang.ClassLoader 抽象类

ClassLoader 类有两个关键的方法:

  • protected Class loadClass(String name, boolean resolve)加载指定二进制名称的类实现了双亲委派机制name 为类的二进制名称,resolve 如果为 true,在加载时调用 resolveClass(Class<?> c) 方法解析该类。
  • protected Class findClass(String name)根据类的二进制名称来查找类,默认实现是空方法。

        如果我们不想打破双亲委派模型,就重写 ClassLoader 类中的 findClass() 方法即可无法被父类加载器加载的类最终会通过这个方法被加载。但是,如果想打破双亲委派模型则需要重写 loadClass() 方法。

4.5 双亲委派机制

4.5.1 双亲委派机制概述

  • ClassLoader 使用委托模型搜索类和资源
  • 双亲委派模型要求了顶层的启动类加载器外其余的类加载器都应有自己的父类加载器
  • ClassLoader 实例会在试图亲自查找类或资源之前,将搜索类或资源的任务委托给其父类加载器

注:

  1. 双亲委派模型并不是一种强制性的约束,只是 JDK 官方推荐的一种方式。如果我们因为某些特殊需求想要打破双亲委派模型,也是可以的。
  2. 这个双亲翻译的容易让别人误解,我们一般理解的双亲都是父母,这里的双亲更多地表达的是“父母这一辈”的人而已,并不是说真的有一个 MotherClassLoader 和一个FatherClassLoader 。其实翻译成单亲委派模型更好一些。
  3. 类加载器之间的父子关系一般不是以继承的关系来实现的,而是通常使用组合关系来复用父加载器的代码。
public abstract class ClassLoader {
  ...
  // 组合
  private final ClassLoader parent;
  protected ClassLoader(ClassLoader parent) {
       this(checkCreateClassLoader(), parent);
  }
  ...
}
// 组合优于继承,多用组合少用继承

4.5.2 双亲委派机制的执行流程

双亲委派机制的相关代码
protected Class<?> loadClass(String name, boolean resolve)
    throws ClassNotFoundException
{
    synchronized (getClassLoadingLock(name)) {
        //首先,检查该类是否已经加载过
        Class c = findLoadedClass(name);
        if (c == null) {
            //如果 c 为 null,则说明该类没有被加载过
            long t0 = System.nanoTime();
            try {
                if (parent != null) {
                    //当父类的加载器不为空,则通过父类的loadClass来加载该类
                    c = parent.loadClass(name, false);
                } else {
                    //当父类的加载器为空,则调用启动类加载器来加载该类
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                //非空父类的类加载器无法找到相应的类,则抛出异常
            }

            if (c == null) {
                //当父类加载器无法加载时,则调用findClass方法来加载该类
                //用户可通过覆写该方法,来自定义类加载器
                long t1 = System.nanoTime();
                c = findClass(name);

                //用于统计类加载器相关的信息
                sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                sun.misc.PerfCounter.getFindClasses().increment();
            }
        }
        if (resolve) {
            //对类进行link操作,resolveClass用于解析该类
            resolveClass(c);
        }
        return c;
    }
}

每当一个类加载器接收到加载请求时,它会先将请求转发给父类加载器。在父类加载器没有找到所请求的类的情况下,该类加载器才会尝试去加载

结合上面的源码,简单总结一下双亲委派模型的执行流程:

  • 在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载(每个父类加载器都会走一遍这个流程)。
  • 类加载器在进行类加载的时候,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成(调用父加载器 loadClass()方法来加载类)。这样的话,所有的请求最终都会传送到顶层的启动类加载器 BootstrapClassLoader 中。
  • 只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载(调用自己的 findClass() 方法来加载类)。
  • 如果子类加载器也无法加载这个类,那么它会抛出一个 ClassNotFoundException 异常。

注:

  1. JVM 判定两个 Java 类是否相同的具体规则:JVM 不仅要看类的全名是否相同,还要看加载此类的类加载器是否一样。只有两者都相同的情况,才认为两个类是相同的。即使两个类来源于同一个 Class 文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相同。
  2. 每个类加载器都有一个自己的命名空间,用于存储它加载的类,即使两个类加载器加载了具有相同名称和字节码的类,这两个类在JVM中也是不同的,因为它们属于不同的命名空间。
  3. Java的类型安全机制要求每个类都必须有唯一的类型标识符。这个标识符是由类名和类加载器共同决定的。由于类加载器的隔离性,两个不同的类加载器加载的同名类具有不同的类型标识符,因此在类型检查时会被视为不同的类

4.5.3 双亲委派模型的好处

        双亲委派模型保证了 Java 程序的稳定运行,可以避免类的重复加载(JVM 区分不同类的方式不仅仅根据类名,相同的类文件被不同的类加载器加载产生的是两个不同的类),也保证了 Java 的核心 API 不被篡改

        如果没有使用双亲委派模型,而是每个类加载器加载自己的话就会出现一些问题,比如我们编写一个称为 java.lang.Object 类的话,那么程序运行的时候,系统就会出现两个不同的 Object 类。双亲委派模型可以保证加载的是 JRE 里的那个 Object 类,而不是你写的 Object 类。这是因为 AppClassLoader 在加载你的 Object 类时,会委托给 ExtClassLoader 去加载,而 ExtClassLoader 又会委托给 BootstrapClassLoaderBootstrapClassLoader 发现自己已经加载过了 Object 类,会直接返回,不会去加载你写的 Object 类。

4.5.4 打破双亲委派模型

        自定义加载器的话,需要继承 ClassLoader 。如果我们不想打破双亲委派模型,就重写 ClassLoader 类中的 findClass() 方法即可,无法被父类加载器加载的类最终会通过这个方法被加载。但是,如果想打破双亲委派模型则需要重写 loadClass() 方法。

        例如:我们比较熟悉的 Tomcat 服务器为了能够优先加载 Web 应用目录下的类,然后再加载其他目录下的类,就自定义了类加载器 WebAppClassLoader 来打破双亲委托机制。这也是 Tomcat 下 Web 应用之间的类实现隔离的具体原理。

注:

  1. 这是因为类加载器在进行类加载的时候,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成(调用父加载器 loadClass()方法来加载类)。
  2. 重写 loadClass()方法之后,我们就可以改变传统双亲委派模型的执行流程。例如,子类加载器可以在委派给父类加载器之前先自己尝试加载这个类,或者在父类加载器返回之后,再尝试从其他地方加载这个类。具体的规则由我们自己实现,根据项目需求定制化。

        单纯依靠自定义类加载器没办法满足某些场景的要求,例如,有些情况下,高层的类加载器需要加载低层的加载器才能加载的类

例如:

  1. SPI 中,SPI 的接口(如 java.sql.Driver)是由 Java 核心库提供的,由BootstrapClassLoader 加载。而 SPI 的实现(如com.mysql.cj.jdbc.Driver)是由第三方供应商提供的,它们是由应用程序类加载器或者自定义类加载器来加载的。默认情况下,一个类及其依赖类由同一个类加载器加载。所以,加载 SPI 的接口的类加载器(BootstrapClassLoader)也会用来加载 SPI 的实现。按照双亲委派模型,BootstrapClassLoader 是无法找到 SPI 的实现类的,因为它无法委托给子类加载器去尝试加载。
  2. 再比如,假设我们的项目中有 Spring 的 jar 包,由于其是 Web 应用之间共享的,因此会由 SharedClassLoader 加载(Web 服务器是 Tomcat)。我们项目中有一些用到了 Spring 的业务类,比如实现了 Spring 提供的接口、用到了 Spring 提供的注解。所以,加载 Spring 的类加载器(也就是 SharedClassLoader)也会用来加载这些业务类。但是业务类在 Web 应用目录下,不在 SharedClassLoader 的加载路径下,所以 SharedClassLoader 无法找到业务类,也就无法加载它们。

这个时候就需要用到 线程上下文类加载器(ThreadContextClassLoader 了。

        拿 Spring 这个例子来说,当 Spring 需要加载业务类的时候,它不是用自己的类加载器,而是用当前线程的上下文类加载器。每个 Web 应用都会创建一个单独的 WebAppClassLoader,并在启动 Web 应用的线程里设置线程线程上下文类加载器为 WebAppClassLoader。这样就可以让高层的类加载器(SharedClassLoader)借助子类加载器( WebAppClassLoader)来加载业务类,破坏了 Java 的类加载委托机制,让应用逆向使用类加载器。

        线程线程上下文类加载器的原理是将一个类加载器保存在线程私有数据里,跟线程绑定,然后在需要的时候取出来使用。这个类加载器通常是由应用程序或者容器(如 Tomcat)设置的。

参考

Java内存区域详解(重点) | JavaGuide

JVM垃圾回收详解(重点) | JavaGuide

类加载过程详解 | JavaGuide

类加载器详解(重点) | JavaGuide

标记-整理算法_mark-compact-CSDN博客

  • 5
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值