当前位置:   article > 正文

JavaEE初阶(18)(JVM简介:发展史,运行流程、类加载:类加载的基本流程,双亲委派模型、垃圾回收相关:死亡对象的判断算法,垃圾回收算法,垃圾收集器)_java ee的运作过程

java ee的运作过程

 接上次博客:初阶JavaEE(17)Linux 基本使用和 web 程序部署-CSDN博客

目录

JVM 简介 

JVM 发展史

JVM 运行流程  

JVM的内存区域划分

JVM 执行流程

堆的作用

JVM参数设置

堆的组成

垃圾回收

堆内存管理

类加载

类加载的基本流程

1. 加载阶段(Loading)

1.1 文件定位与打开

1.2 获取二进制字节流

1.3 转化为运行时数据结构

1.4 生成Class对象

2. 验证阶段(Verification)

2.1 目的

2.2 验证选项

3. 准备阶段(Preparation)

3.1 定义

3.2 示例

4. 解析阶段(Resolution)

4.1 过程

5. 初始化阶段(Initialization)

双亲委派模型

背景

类加载器概念

各类加载器的详细说明

自定义类加载器

类加载器层次结构

双亲委派模型的工作原理

双亲委派模型的优点

类加载的详细过程(查找.class文件的过程)

垃圾回收相关

① 死亡对象的判断算法

a. 引用计数算法

b. 可达性分析算法

② 垃圾回收算法

a.标记-清除算法——比较简单粗暴

b.复制算法

c. 标记-整理算法

d. 分代算法

③ 垃圾收集器
​​​​​​​


JVM的初心本来就是为了把很多操作封装起来以方便Java程序猿的……

但是这个时代越来越卷,面试也开始问JVM相关的问题,我们还是得好好学习一下所谓“八股文”的。

JVM 简介 

1. 什么是 JVM?

  • JVM 是 Java Virtual Machine 的缩写,即 Java 虚拟机。
  • 它是一个虚拟计算机,通过软件模拟硬件功能,运行在隔离的环境中,执行 Java 字节码。

2. 虚拟机的概念

  • 虚拟机是一个完整计算机系统的软件模拟,具有完整的硬件功能,但在实际物理硬件之上运行。
  • JVM 是一种虚拟机,专门设计用于执行 Java 字节码。

3. 常见虚拟机

  • JVM (Java Virtual Machine): 用于执行 Java 字节码。
  • VMware: 通过软件模拟物理 CPU 指令集,用于虚拟化整个操作系统。
  • VirtualBox: 与 VMware 类似,用于虚拟化操作系统。

4. JVM 与其他虚拟机的区别

  • VMware 和 VirtualBox:

    • 模拟物理 CPU 的指令集,包含多个寄存器。
    • 用于虚拟化整个操作系统,可以运行各种操作系统,不仅限于 Java 应用。
  • JVM:

    • 模拟 Java 字节码的指令集,主要保留 PC 寄存器,对其他寄存器进行了裁剪。
    • 专门用于执行 Java 程序,不直接运行操作系统。

5. JVM 的定制

  • JVM 是一台被定制过的计算机,通过软件模拟的方式,提供了一个 Java 运行环境。
  • Java 程序在 JVM 上运行,实现了跨平台的特性,使得 Java 程序具有高度的可移植性。

总结: JVM 是 Java 程序运行的核心,通过模拟 Java 字节码的执行环境,实现了 Java 跨平台运行的特性。与其他虚拟机相比,JVM 更专注于执行 Java 程序,对计算资源进行了更精细的裁剪,使得 Java 应用具有较高的性能和可移植性。

JVM 发展史

1. Sun Classic VM (1996)

  • 初代商业 Java 虚拟机,Java 1.0 版本。
  • 内部仅提供解释器,若使用 JIT 编译器,需要外挂。
  • 解释器和编译器无法配合工作。
  • 完全被淘汰,不再使用。

2. Exact VM (JDK 1.2)

  • 针对 Classic VM 的问题,提供了热点探测和编译器与解析器混合工作模式。
  • 在 Solaris 平台短暂使用,其他平台仍使用 Classic VM。
  • 被 HotSpot VM 取代。

3. HotSpot VM

  • 历史:
    • 由 "Longview Technologies" 公司设计,1997 年被 Sun 收购,2009 年 Sun 被甲骨文收购。
    • JDK 1.3 时成为默认虚拟机。
  • 特点:
    • 热点代码探测技术,通过计数器找到最具编译价值的代码。
    • 即时编译(JIT)或栈上替换,平衡程序响应时间与执行性能。
    • 默认虚拟机,占据市场主导地位。
  • 应用:
    • 广泛用于各种 Java 应用,从服务器、桌面到移动端和嵌入式。

4. JRockit

  • 面向服务器端应用,专注于性能。
  • 基于 HotSpot,整合 JRockit 的优秀特性。
  • 不包含解释器,全部代码通过即时编译器执行。
  • 2008 年,BEA 被 Oracle 收购。
  • 整合工作在 JDK 8 中完成。

5. J9 JVM (IBM)

  • IBM Technology for Java Virtual Machine,市场定位类似 HotSpot。
  • 开源版本称为 Eclipse OpenJ9。
  • 2017 年左右,IBM 将 J9 VM 开源给 Eclipse 基金会。

6. Taobao JVM

  • 由 AliJVM 团队发布,阿里巴巴自主研发。
  • 基于 OpenJDK 开发,定制版本为 AlibabaJDK(AJDK)。
  • 特点:
    1. GCIH 技术实现 off-heap,提高 GC 效率。
    2. 对象在多个 JVM 进程中实现共享。
    3. 使用 crc32 指令实现 JVM intrinsic 降低 JNI 调用开销。
    4. 针对大数据场景的 ZenGC。
  • 应用在阿里产品上,性能高,但依赖 Intel CPU,兼容性受损。在淘宝、天猫上线,替换 Oracle 官方 JVM 版本。

JVM 运行流程  

JVM 是 Java 运行的基础,也是实现一次编译到处执行的关键,那么 JVM 是如何执行的呢?

我们得先了解一下JVM的内存区域划分:

JVM的内存区域划分

  1. 程序计数器 (Program Counter Register):

    • 作用: 存储当前线程执行的 Java 虚拟机字节码指令地址。在多线程环境下,每个线程都有独立的程序计数器,确保线程切换后能正确恢复执行。
    • 解释: 程序计数器是线程私有的,它是一块较小的内存空间。在 Java 虚拟机中,每个线程都有自己的程序计数器,用于记录当前执行的指令位置。它是线程私有的一部分内存,不会发生内存溢出异常。
    • 方法入口地址记录: 当一个线程开始执行一个方法时,程序计数器会记录该方法的入口地址。这是因为每个方法在内存中都有一个唯一的入口地址,程序计数器通过记录这个地址,能够追踪线程的执行位置。

      指令逐条执行: 随着线程的执行,程序计数器的值会自动更新,指向下一条即将执行的指令的地址。每一条指令对应着Java字节码中的一条指令,这些指令在方法区中以二进制形式存储。

      顺序执行的递增: 对于顺序执行的代码,程序计数器的值会递增,指向下一条顺序执行的指令的地址。这确保了指令的有序执行,按照代码的编写顺序逐条执行。

      条件或循环跳转: 在遇到条件语句或循环结构时,程序计数器可能会跳转到其他地址。这是因为条件语句或循环结构的执行需要根据特定的条件或循环条件决定下一条执行的指令,程序计数器负责记录这个跳转的地址。

  2. Java 虚拟机栈 (JVM Stack):

    • 作用: 存储局部变量表、操作数栈、动态链接、方法出口等信息。这里存储的内容就是代码执行过程中方法之间的调用关系。
    • 解释: Java 虚拟机栈是每个线程私有的,它的生命周期与线程相同。每个方法的执行都会创建一个栈帧,栈帧包含了该方法的局部变量表、操作数栈、动态链接、方法出口等信息。栈帧随着方法的进入和退出而动态地入栈和出栈。
  3. 栈帧 (Stack Frame):

    • 作用: 存储方法的局部变量表、操作数栈、动态链接、方法出口等信息。每个方法在运行时都会创建一个栈帧,栈帧会被推入到 Java 虚拟机栈或本地方法栈中。
    • 解释: 栈帧是用于支持方法调用和返回的数据结构。每个方法调用都会创建一个栈帧,包含了该方法的局部变量表、操作数栈、动态链接、方法出口等信息。栈帧在方法调用和返回的过程中动态地入栈和出栈。
    • 栈帧中的局部变量表用于存储方法中的局部变量,包括方法参数和方法内部定义的变量。操作数栈用于执行计算,动态链接用于支持方法调用,方法返回地址用于指示方法调用结束后的返回位置。

      当一个方法被调用时,Java 虚拟机会创建一个新的栈帧,并将其推入栈中,成为当前方法栈的栈顶。在方法执行过程中,栈帧中的信息会不断变化,包括局部变量表的变化、操作数栈的变化等。当方法调用结束后,对应的栈帧会被弹出,控制权返回到上一个方法的栈帧。

  4. 本地方法栈 (Native Method Stack):

    • 作用: 类似于 Java 虚拟机栈,为本地方法服务。本地方法是使用native关键字声明的方法,其实现不是用Java实现的,而是通过JVM内部的C++代码来实现的,是JVM内部的C++代码调用关系。这些本地方法的二进制指令与Java方法一样,最终都会加载到内存中,但其执行的具体逻辑是由C++代码实现的。
    • 解释: 本地方法栈与 Java 虚拟机栈类似,但用于执行本地方法(用其他语言如 C 或 C++ 编写的方法)。它也是线程私有的,与线程生命周期一致。
    • 本地方法栈(Native Method Stack)与 Java 虚拟机栈(JVM Stack)类似,都是用于支持方法调用的内存区域。它们存储的是方法调用过程中的相关信息,包括局部变量表、操作数栈、动态链接、方法出口等。然而,本地方法栈专门为执行本地方法(用其他语言如 C 或 C++ 编写的方法)而设计。

      在 Java 虚拟机栈中,存储的是 Java 方法的调用信息,而在本地方法栈中,存储的是执行本地方法的调用信息。本地方法栈的作用是支持 Java 与其他语言(通常是本地语言)之间的交互,允许 Java 调用本地方法,同时也支持本地方法调用 Java 方法。

      因此,本地方法栈可以被看作是 Java 虚拟机栈的补充,用于处理与本地方法相关的调用和执行。在涉及到本地方法调用的情况下,本地方法栈会记录执行的过程,包括方法之间的调用关系。

  5. Java 堆 (Java Heap):

    • 作用: 存储对象实例,也就是代码中new的对象。
    • 它是整个区域占据空间最大的。
    • 解释: Java 堆是 Java 虚拟机管理的最大的一块内存区域,用于存储创建的对象实例。在堆中,由垃圾回收器负责管理内存,回收不再使用的对象。
  6. 方法区 (Method Area)(Java1.7及其之前,Java1.8之后称为元数据区):

    • 这里存储的内容就是类对象,.class文件加载到内存之后就成为类对象了。
    • 作用: 存储类信息、常量、静态变量、即时编译器编译后的代码等数据。
    • 解释: 方法区是用于存储类相关的信息,包括类的结构信息、运行时常量池、静态变量、即时编译器编译后的代码等。它在 Java 虚拟机启动时被创建,随着虚拟机的退出而销毁。
  7. 运行时常量池 (Runtime Constant Pool):

    • 作用: 存储编译期生成的各种字面量和符号引用。
    • 解释: 运行时常量池是方法区的一部分,用于存储编译期生成的各种字面量和符号引用。与之前的版本不同,运行时常量池在 Java 7 中被移到了堆中。
  8. 直接内存:

    • 作用: 为 NIO 提供直接内存访问,减少了 Java 堆和 Native Heap 之间的拷贝。
    • 解释: 直接内存是一块在堆外分配的内存区域,由操作系统管理。在 NIO(New I/O)中,可以使用直接内存进行文件的映射和 IO 操作。

当一个Java程序启动时,会在操作系统中启动一个Java虚拟机进程。在这个Java虚拟机进程中,每个线程都拥有自己的独立的虚拟机栈和程序计数器(这也衍生出了一种说法:每个线程都有自己私有的栈空间)——这些是线程私有的内存区域,为每个线程提供了独立的执行环境。

这种说明也可以认为是正确的,

  1. 虚拟机栈: 每个线程都有自己的虚拟机栈,它的生命周期与线程相同。虚拟机栈会保存线程执行方法时的局部变量表、操作数栈、动态链接、方法出口等信息。每个方法在执行的时候都会在虚拟机栈中创建一个栈帧,栈帧用于存储方法的局部变量和运算过程中的临时数据。虚拟机栈的大小可以在启动时或运行时进行调整。

  2. 程序计数器: 每个线程都有自己的程序计数器,用于记录当前线程执行的字节码指令的地址。程序计数器是线程私有的,它在线程切换时能够保持线程独立的执行位置,确保每个线程都能够从正确的地方恢复执行。在Java虚拟机规范中,对程序计数器的操作都是原子性的。

  3. 堆和方法区: 堆和方法区是Java虚拟机中的内存区域,是所有线程共享的。堆用于存储对象实例和数组等动态分配的数据,而方法区用于存储类信息、常量、静态变量等。这两个区域的内存是在Java虚拟机启动时就分配好的,并且在整个运行过程中都存在。堆和方法区的大小可以通过启动参数进行调整。

总体来说,虚拟机栈和程序计数器是线程私有的,而堆和方法区是线程共享的。这种设计保证了每个线程都拥有独立的执行环境,同时又能够共享一些静态的数据和类信息。

常见的面试题:

  1. class Test {
  2. public int n = 100;
  3. public static int a = 10;
  4. }
  5. public class Main {
  6. public static void main(String[] args) {
  7. Test t = new Test();
  8. }
  9. }

 

  1. 成员变量 n:

    • n 是一个实例变量,因为它没有被 static 关键字修饰。
    • 它属于对象的一部分,当创建 Test 类的实例时,每个实例都会有一个 n 变量。
    • n 存储在对象的堆内存中。
  2. 静态变量 a:

    • a 是一个静态变量,因为它被 static  修饰。
    • 它属于类而不是实例,即使没有创建 Test 类的实例,我们也可以通过 Test.a 来访问它。
    • a 存储在方法区(或称为静态区)中。
  3. 局部变量 t:

    • t 是一个局部变量,因为它在 main 方法内声明。
    • 它是一个对象引用,存储对象的引用地址。
    • t 存储在栈内存中。

综上所述,不同变量的存储区域如下:

  • n 存储在堆内存中。
  • a  存储在方法区(静态区)中。
  • t 存储在栈内存中,而 Test 类的实例存储在堆内存中。

有一个问题容易让我们误会:变量处于哪个空间上,和变量是不是引用类型?是不是基本类型?没有关系!!!:

引用变量本身存储在栈内存中,但实际对象的数据存储在堆内存中: 

  1. class Test {
  2. public int n = 100; // 基本类型变量,存储在栈内存中
  3. public static int a = 10; // 静态变量,存储在方法区中
  4. public static void main(String[] args) {
  5. Test t = new Test(); // 引用类型变量,t存储在栈内存中,实际对象存储在堆内存中
  6. }
  7. }

 我们再来看一个实例:

  1. class Person {
  2. private String name; // 引用类型变量,存储在堆内存中
  3. private int age; // 基本类型变量,存储在栈内存中
  4. public Person(String name, int age) {
  5. this.name = name;
  6. this.age = age;
  7. }
  8. public void celebrateBirthday() {
  9. age++;
  10. }
  11. public String getName() {
  12. return name;
  13. }
  14. public int getAge() {
  15. return age;
  16. }
  17. }
  18. public class Main {
  19. public static void main(String[] args) {
  20. Person person1 = new Person("Alice", 25); // 引用类型变量,person1存储在栈内存中,实际对象存储在堆内存中
  21. Person person2 = new Person("Bob", 30); // 引用类型变量,person2存储在栈内存中,实际对象存储在堆内存中
  22. System.out.println(person1.getName() + "'s age: " + person1.getAge()); // 输出:Alice's age: 25
  23. System.out.println(person2.getName() + "'s age: " + person2.getAge()); // 输出:Bob's age: 30
  24. person1.celebrateBirthday(); // 调用方法,person1的age递增
  25. System.out.println(person1.getName() + "'s age after birthday: " + person1.getAge()); // 输出:Alice's age after birthday: 26
  26. }
  27. }

JVM 执行流程

JVM(Java Virtual Machine)执行流程可以简要概括为以下四个主要部分:

编写和编译Java代码:

  • 开发者使用Java语言编写源代码。
  • 使用Java编译器(javac)将源代码编译成字节码文件(.class文件)。
  1. 类加载器(ClassLoader):

    • 功能: 负责加载Java字节码文件(.class文件)到内存中。
    • 过程: 类加载器按照特定的规则和顺序加载类,包括加载类的二进制数据、连接阶段的验证、准备和解析,最终形成可以被JVM直接使用的Java类型。
  2. 运行时数据区(Runtime Data Area):

    • 功能: 用于存储程序运行时的数据,包括方法区、堆、栈、本地方法栈和程序计数器等。
    • 详细划分:
      • 方法区: 存储类的结构信息、常量、静态变量等。
      • 堆: 存储对象实例。
      • 栈: 存储局部变量、操作数栈、方法调用和返回等信息。
      • 本地方法栈: 用于支持native方法调用。
      • 程序计数器: 记录当前线程执行的位置。
  3. 执行引擎(Execution Engine):

    • 功能: 将字节码翻译成底层系统指令,实现Java程序的执行。
    • 过程: 执行引擎通过解释器或即时编译器(JIT)将字节码转化为机器码,然后由CPU执行。解释器逐条解释执行,即时编译器将整个方法编译为本地机器码后执行。
  4. 本地库接口(Native Interface):

    • 功能: 提供了与底层操作系统和硬件交互的接口,用于实现Java程序中调用非Java语言编写的本地方法(native方法)。
    • 过程: Java程序通过JNI(Java Native Interface)调用本地方法,JNI将Java数据类型转换为本地数据类型,并调用本地库接口实现与底层的交互。

综合以上四个部分,JVM能够加载、解释或编译Java程序,并在运行时管理内存、执行字节码,通过本地库接口与底层系统交互,实现了Java程序的跨平台特性。

堆的作用

  • 存储对象: 所有通过new关键字创建的对象实例都存储在堆中。
  • 动态分配内存: 堆内存的分配是动态的,程序运行时可以动态地创建对象,并由垃圾回收器负责管理对象的生命周期。

JVM参数设置

  • -Xms10m: 设置堆的初始大小为10兆字节。
  • -Xmx10m: 设置堆的最大大小为10兆字节。
    • -Xms-Xmx分别代表了堆的最小启动内存和最大运行内存。

堆的组成

  • 新生代和老生代: 堆可以分为新生代(Young Generation)和老生代(Old Generation)两部分。
  • 新生代内部结构: 包括Eden空间和两个Survivor空间(S0和S1)。新创建的对象首先存储在Eden空间,经过一定次数的垃圾回收后仍然存活的对象将被移到Survivor空间。
  • 老生代: 存放长时间存活的对象。

垃圾回收

  • Minor GC(新生代GC): 主要发生在新生代,清理Eden和Survivor空间。存活的对象被移到Survivor空间。
  • Major GC(老生代GC)或Full GC: 发生在老生代,清理整个堆,包括新生代和老生代。耗时较长。

堆内存管理

  • 内存分配策略: 对象在堆中的分配由垃圾回收器负责,一般采用分代收集算法。
  • 对象的生命周期: 新生代的对象可能在Minor GC后被清理,而老生代的对象可能在Major GC后被清理。

堆的合理设置对于程序性能和内存利用率非常重要,通过调整-Xms-Xmx参数,可以根据应用的需求进行优化。

类加载

类加载的基本流程

对于一个类来说,它的生命周期是这样的:

其中前 5 步是固定的顺序并且也是类加载的过程,Java代码会被编译成.class文件(包含了一些字节码),Java程序想要运行起来就需要让JVM读取到这些.class文件,并且把里面的内容构造成类对象,保存到内存的方法区里。 

所谓的“执行代码”,就是调用方法,我们就需要先知道每个方法,编译后生成的指令都是啥?

其中中间的 3 步我们都属于连接,所以对于类加载来说,总共分为以下几个步骤 :

1. 加载

2. 连接

  • 验证
  • 准备
  • 解析 

3. 初始化

下面我们分别来看每个步骤的具体执行内容:

1. 加载阶段(Loading)

1.1 文件定位与打开

在加载阶段,系统首先要找到对应的 .class 文件。通常,代码中会提供某个类的“全限定类名”(例如:java.lang.String)。JVM会根据这个类名在特定的指定目录范围内查找相应的 .class 文件,找到后就打开并读取文件内容。

1.2 获取二进制字节流

通过打开的文件,系统获取到该类的二进制字节流。这个字节流包含了类的静态存储结构的表示,其中包括类的字段、方法、接口等信息。

1.3 转化为运行时数据结构

获取的字节流表示的静态存储结构需要被转化为方法区的运行时数据结构。这个步骤是将类的信息在虚拟机中表示出来,以备后续的验证、准备、解析等步骤做准备。

1.4 生成Class对象

在内存中生成代表这个类的 java.lang.Class 对象,成为方法区中类数据的访问入口。Class 对象包含了类的各种信息,通过它可以访问类的结构和内容。这个步骤为程序运行时提供了访问和操作类的入口。

2. 验证阶段(Verification)

2.1 目的

验证阶段是连接阶段的首步,其目的在于确保Class文件的字节流符合《Java虚拟机规范》的约束要求,以保障在代码运行时不会危害虚拟机自身的安全。

Java SE Specifications

 

这个就是.class文件要遵守的格式:

 u2就是2个字节的无符号整数 unsigned short;

u4就是4个字节的无符号整数 unsigned int。

ClassFile结构表示Java类文件的格式。让我们分析ClassFile结构中的每个元素:

  1. magic(u4):表示一个魔数(4字节),用于标识文件为有效的Java类文件。这是Java虚拟机(JVM)识别和验证文件格式的一种方式。一般二进制文件开头的几个字节都是固定的数字,用来表示文件的格式,这个数字又被称为“魔幻数字”。
  2. minor_version(u2):表示类文件格式的次版本。用于指示与主版本向后兼容的更改。
  3. major_version(u2):表示类文件格式的主版本。它指示类文件支持的兼容性和特性。
  4. constant_pool_count(u2):指定常量池表中的条目数。常量池包含类使用的各种数据,如文字、符号引用和其他常数。
  5. constant_pool(cp_info[]):包含常量池条目的数组。每个条目的实际类型由cp_info结构定义,数组有constant_pool_count - 1个元素。
  6. access_flags(u2):表示类或接口的访问权限标志,例如是否为public、final、abstract等。
  7. this_class(u2):在常量池表中的索引,指示当前类文件表示的类或接口。
  8. super_class(u2):在常量池表中的索引,指示当前类或接口的直接超类。对于接口,这是java.lang.Object类。
  9. interfaces_count(u2):指定当前类或接口实现的接口数量。
  10. interfaces(u2[]):包含索引的数组,每个索引指向常量池表中的一个接口。
  11. fields_count(u2):类中的字段数量。
  12. fields(field_info[]):包含每个字段的结构的数组,包括字段的名称、描述符和访问标志。
  13. methods_count(u2):类中的方法数量。
  14. methods(method_info[]):包含每个方法的结构的数组,包括方法的名称、描述符、访问标志和代码。
  15. attributes_count(u2):与类相关联的属性数量。
  16. attributes(attribute_info[]):包含与类相关的每个属性的结构的数组,例如源文件信息、注解等。

总体而言,ClassFile结构全面展示了Java类文件的基本组成部分,包括版本信息、常量池、访问标志、类层次结构、字段、方法以及其他属性。这个结构对于JVM在运行时正确加载和理解类文件至关重要。

2.2 验证选项

  • 文件格式验证: 确保字节流符合Class文件格式规范。
  • 字节码验证: 确保字节码语义合法,不违反虚拟机规范。
  • 符号引用验证: 确保符号引用转换成直接引用的动作在虚拟机运行时能正确执行。

3. 准备阶段(Preparation)

3.1 定义

准备阶段是为类中的变量(静态变量,即被static修饰的变量)分配内存空间的正式阶段。

在准备阶段,主要是为类中的静态变量分配内存并设置默认初始值,这个值通常是零(zero)。此时并没有执行初始化代码,只是为静态变量分配了内存空间,并设置了默认初始值。

在Java中,基本数据类型的静态变量会被设置为默认值,例如,整数型为0,浮点型为0.0,布尔型为false等。引用类型的静态变量会被设置为null。

因此,在准备阶段结束后,虽然内存空间已经分配,但初始化代码尚未执行,所以如果尝试访问类的静态成员,会看到它们的默认初始值,即通常是全0。这是因为在准备阶段,只有分配了内存和设置了默认值,但还没有执行其他初始化操作。

3.2 示例

public class MyClass { public static int value = 123; }

在准备阶段,value 会被初始化为0,而不是123。

4. 解析阶段(Resolution)

4.1 过程

针对类对象中包含的字符串常量进行处理,进行一些初始化操作。Java代码中用到的字符串常量在编译之后也会进入到.class文件中。

解析阶段是Java虚拟机将常量池中的符号引用替换为直接引用的过程,即将符号引用转化为可以直接被虚拟机使用的直接引用。

在这个过程中,特别针对类对象中包含的字符串常量进行处理。在Java代码中,字符串常量(如String s = "java";)在编译后会被添加到类的常量池中,并在.class文件中保留。

在.class文件的二进制指令中,对于这样的字符串常量,会创建一个引用(例如,s),而这个引用在文件中本质上保存的是一个偏移量,指向这个字符串常量在文件中的位置。

在.class文件中,由于它是一个文件而非内存地址,引用(例如,s)的初始化语句会被设置成这个字符串常量在文件中的偏移量。通过这个偏移量,可以在文件中找到实际的字符串内容。

当这个类真正被加载到内存中时,解析阶段会将这个偏移量替换回真正的内存地址,从而建立直接引用。这个过程被称为“符号引用(文件偏移量)”替换成“直接引用(内存地址)”。

这样,通过解析阶段,字符串常量在类加载过程中能够正确地转化为在内存中的实际地址,确保在程序运行时可以正确地访问和操作这些字符串。

5. 初始化阶段(Initialization)

初始化阶段是Java虚拟机开始执行类中编写的Java程序代码的阶段,主要任务是执行类构造器方法。在这个阶段,对类对象进行初始化,确保类的状态和结构在运行时处于正确的状态。以下是一些关键的初始化任务:

  1. 执行类构造器方法: 初始化阶段的主要任务之一是执行类构造器方法。对于类而言,这是<clinit>方法,它包含了静态变量的初始化和静态代码块的执行。对于接口而言,也会有类似的初始化步骤。

  2. 初始化静态成员: 在执行类构造器方法时,静态成员(静态变量或静态代码块)会被初始化。静态变量的初始化可以是在声明时赋值或在静态代码块中执行的。

  3. 加载父类: 如果当前类有父类,并且父类尚未被加载和初始化,那么会先递归地执行父类的加载和初始化阶段,确保继承链上的类都得到正确的初始化。

  4. 为类对象中的属性设置值: 初始化阶段还会为类对象中的各个属性设置具体的值,这些值可能是默认值或者在构造器中指定的初值。

  5. 执行静态代码块: 如果类中包含静态代码块,这些代码块也会在初始化阶段执行。静态代码块中的代码会按照它们在类中的顺序执行。

通过以上步骤,初始化阶段确保了类对象在使用之前处于一个合适的状态。这包括正确的静态变量值、已加载的父类、各个属性的初始化值等。这个阶段的完成为类的正确运行提供了必要的条件。 

双亲委派模型

提到类加载机制,我们就不得不提的一个概念就是“双亲委派模型”。

“双亲委派模型”属于类加载中第一个步骤,是“加载”过程中期中的一个环节,负责根据全限定类名找到.class文件。而类加载器是JVM中的一个模块。

背景

双亲委派模型是Java类加载机制中的一种设计思想,旨在解决类加载的重复和安全性问题。它属于类加载的第一个步骤——“加载”过程的一部分,负责根据全限定类名找到对应的.class文件。

类加载器概念

在Java虚拟机中,类加载器是一个独立的模块,负责加载类文件并将其转化为运行时的Java类。JVM内置了三个类加载器,分别是启动类加载器、扩展类加载器、和应用程序类加载器。这些类加载器之间的关系不是继承关系,而是通过一个parent属性指向父类加载器,形成了一个层次结构。

程序员也可以手动创建自定义的类加载器。

各类加载器的详细说明

  • 启动类加载器(BootStrap ClassLoader):

    • 负责加载Java的核心类库,如java.lang.Object等,实际上是使用C++语言实现的一部分,不是Java类。
  • 扩展类加载器(Extension ClassLoader):

    • 继承自ClassLoader类,加载lib/ext目录下的类。可以通过系统属性java.ext.dirs指定扩展类库的目录。
  • 应用程序类加载器(Application ClassLoader):

    • 继承自URLClassLoader类,加载我们写的应用程序。可以通过系统属性java.class.path指定应用程序的类路径。 

自定义类加载器

程序员可以根据需求创建自定义的类加载器,继承自 java.lang.ClassLoader。这样可以实现一些特殊的类加载需求,比如从网络加载类、动态生成类等。

通过双亲委派模型,自定义的类加载器可以在必要时覆盖默认的类加载行为。

类加载器层次结构

  • 启动类加载器(Bootstrap ClassLoader):

    • 加载JDK中lib目录中Java的核心类库,即$JAVA_HOME/lib目录。
  • 扩展类加载器(Extension ClassLoader):

    • 继承自ClassLoader类,加载lib/ext目录下的类。
  • 应用程序类加载器(Application ClassLoader):

    • 继承自URLClassLoader类,加载我们写的应用程序。

双亲委派模型的工作原理

  1. 加载请求的传递:

    • 当一个类加载器收到加载请求时,它不会自己尝试加载,而是将请求委派给父类加载器。
  2. 递归委派:

    • 这一过程一直递归进行,直到请求达到启动类加载器。
  3. 启动类加载器加载:

    • 如果启动类加载器能够加载该类,加载过程结束。
  4. 反馈给子类加载器:

    • 如果启动类加载器无法加载该类,便会反馈给子类加载器,让子类加载器尝试加载。
  5. 最终加载:

    • 这样可以确保所有加载请求最终都会传送到最顶层的启动类加载器中。

双亲委派模型的优点

  1. 避免重复加载: 双亲委派模型通过层次结构,确保同一个类只被加载一次。当一个类加载器收到加载请求时,它会委派给父类加载器,直到达到顶层的启动类加载器。这样可以避免在不同的类加载器中重复加载相同的类。
  2. 确保类的一致性: 标准库的类由启动类加载器负责加载,扩展库的类由扩展类加载器加载,应用程序的类由应用程序类加载器加载。这样可以确保类的来源是明确的,防止外部类库覆盖Java核心类库,确保了类的一致性和稳定性。
  3. 提高安全性: 双亲委派模型可以防止恶意代码通过自定义的类加载器加载替代Java核心类库中的类,从而提高了系统的安全性。只有通过启动类加载器加载的类才能访问JDK中的核心类。
  4. 规范类加载过程: 双亲委派模型规范了类加载的过程,定义了类加载器的层次结构和委派规则。这样有助于保持整个Java应用的稳定性和一致性,减少了不确定性和潜在的冲突。
  5. 适应动态更新: 双亲委派模型使得动态更新类成为可能。当一个类需要更新时,只需替换掉旧的.class文件,重新加载即可。这种机制适应了动态变化的需求,比如热部署。

总体来说,双亲委派模型的优点在于提供了一种清晰、规范的类加载机制,保障了Java应用的稳定性、一致性和安全性。

类加载的详细过程(查找.class文件的过程)

  1. 给定全限定类名: 例如,java.lang.String。

  2. Application ClassLoader(应用程序类加载器)开始查找:

    • 作为类加载的入口,Application ClassLoader 不会立即扫描自己负责的目录,而是将查找任务委派给它的父类加载器,即Extension ClassLoader。
  3. Extension ClassLoader(扩展类加载器)继续查找:

    • Extension ClassLoader 也不会立即扫描自己负责的目录,即JDK中的扩展库目录。它将查找任务再次委派给它的父类加载器,即Bootstrap ClassLoader。
  4. Bootstrap ClassLoader(启动类加载器)继续查找:

    • Bootstrap ClassLoader 负责加载标准库目录中的类。但它同样不会立即扫描,而是尝试将任务再次委派给它的父类加载器。然而,Bootstrap ClassLoader 没有父类加载器,因此只能亲自负责扫描标准库目录。
  5. 查找过程的结束:

    • 如果给定的类是标准库中的类,Bootstrap ClassLoader 在标准库目录中找到对应的.class文件,完成查找过程,可以打开文件并读取文件。比如我们刚刚举的例子:java.lang.String:这种类就能够在标准库中找到对应的.class文件,就可以进行打开文件、读取文件……
  6. 任务回溯:

    • 如果给定的类不是标准库中的类,任务会回溯到 Extension ClassLoader,它会扫描负责的扩展库目录。如果找到对应的.class文件,执行后续的类加载操作,否则将任务传递给其子类加载器。
  7. 继续回溯:

    • 如果 Extension ClassLoader 也没有找到对应的.class文件,任务回溯到 Application ClassLoader,它会扫描当前项目和第三方库的目录。如果找到,执行后续的类加载操作,否则抛出 ClassNotFoundException 异常。

通过这一层层的委派和回溯,实现了类加载的双亲委派模型,确保了类的一致性和安全性。如果类能够在上述的加载器层次结构中的某一个找到对应的.class文件,加载过程就算完成。

双亲委派模型通过层次结构的加载方式,确保了类的一致性和安全性,使得Java的类加载机制更加健壮和可靠。之所以搞这一套流程,就是为了确保标准库的类,被加载的优先级最高,其次是扩展库,最后是自己写的类和第三方库。所以所谓的“双亲委派模型”就是一个简单的查找优先级问题。 

双亲委派模型的设计目的就是确保类的一致性和安全性,通过委派机制和查找优先级,保证了以下几个关键点:

  1. 避免重复加载: 通过层级结构,同一个类只被加载一次,避免了重复加载,提高了运行效率。

  2. 优先使用标准库: 标准库中的类具有最高的优先级,确保了核心API的一致性,防止被外部类库篡改。

  3. 确保安全性: 阻止每个类加载器加载自己的类,防止出现多个不同版本的同一个类,提高了系统的安全性。

  4. 规范类加载流程: 通过定义类加载器的层次结构和委派规则,使得类加载的过程更加规范和可控。

这种设计思想保证了Java应用的稳定性和可靠性,特别是在复杂的应用场景中,通过双亲委派模型,可以避免很多潜在的类加载问题。

当然,我们自己实现类加载器的时候不一定要符合上述流程,正如我们之前部署博客系统的时候,tomcat就是在我们指定的自定义的目录——webapps里面找,找不到就直接抛出异常并返回了。

垃圾回收相关

在C语言和C++等语言中,程序员负责手动分配和释放内存。这种手动管理内存的方式可能导致内存泄漏、野指针等问题,使程序更容易出现错误。为了解决这些问题,Java引入了垃圾回收机制(Garbage Collection)。

垃圾回收机制的主要目标是自动管理程序中使用的内存,减轻程序员的负担。在Java中,程序员不需要手动释放不再使用的内存,而是由Java虚拟机(JVM)的垃圾回收器负责定期检测和回收不再被引用的对象。注意,垃圾回收是在程序运行时进行的,而不是在编译过程中。

关于垃圾回收的一些重要概念和原理包括:

  1. 可达性分析: 垃圾回收器通过可达性分析来确定哪些对象是"活动"的,即仍然被引用的对象。从根对象(如堆栈、静态变量等)开始,通过引用链追踪对象的引用关系,标记所有可达的对象。

  2. 标记-清除算法: 是一种常见的垃圾回收算法。在可达性分析后,标记阶段标记出所有活动对象,清除阶段将未标记的对象回收。这可能导致内存碎片问题。

  3. 复制算法: 一种通过将存活对象复制到一个新的空间,并清理旧空间的算法,解决了碎片问题。但它需要额外的空间。

  4. 标记-整理算法: 是一种结合了标记-清除和复制算法的算法,旨在减少内存碎片。

总的来说,垃圾回收机制使Java程序员不必过多关心内存管理,提高了开发效率并减少了潜在的内存错误。然而,了解垃圾回收的原理和机制有助于编写更加健壮和高效的Java程序。

既然GC那么好,为什么C++不引入? 

GC也是有缺陷的……

C++没有引入垃圾回收的主要原因包括系统开销和效率问题:

  1. 系统开销: 垃圾回收需要一个专门的线程不断扫描内存中的对象,判断是否可回收。这个过程需要额外的内存和CPU资源。在一些配置特别低的系统中,这种额外的开销可能会对系统性能产生负面影响。C++通常被设计用于对系统资源更加细粒度的控制,以满足对性能和资源利用的高要求。

  2. 效率问题: 垃圾回收的扫描线程不一定能够及时释放内存,因为扫描通常是有周期的。这可能导致一些对象在被标记为可回收后,并不立即被释放,而是等待下一轮垃圾回收。在某些场景下,特别是对于对实时性要求较高的系统,这种延迟可能是不可接受的。C++通常被用于开发对实时性能要求较高的应用,如游戏引擎、嵌入式系统等。

  3. 长时间停顿(STW): 当进行垃圾回收时,可能会发生全局的停顿(Stop-The-World,STW),即整个应用程序的执行会暂时停止。在对于要求低延迟和高并发性能的场景中,这种长时间停顿是无法接受的。C++的设计目标之一是追求极致的性能和响应速度,因此避免了这类全局性的停顿。

总的来说,C++注重对系统资源和性能的细粒度控制,而垃圾回收机制引入了一些不可避免的系统开销和延迟,因此在某些特定的应用场景下,C++的设计理念更符合需求。

在Java运行时内存管理中,我们探讨了各个区域的作用,其中程序计数器、虚拟机栈、本地方法栈这三个区域的生命周期与相关线程紧密相连,随着线程的创建而生,随着线程的结束而消失。这三个区域的内存分配与回收是具有确定性的,因为当方法执行结束或者线程终结时,相应的内存也自然地随之回收。

因此,本讲探讨的关于内存分配和回收的内容主要关注于Java堆与方法区这两个区域。Java堆是存放几乎所有对象实例的地方,而垃圾回收器在执行堆的垃圾回收之前,首先需要判断哪些对象仍然存活,哪些已经变得"死去"。为了判断对象的生死状态,垃圾回收器采用了多种算法,其中包括引用计数算法和可达性分析算法等。

总的来说,通过对Java堆和方法区的探讨,我们能够深入了解内存管理中关键的区域及其内存分配和回收的特性。这为理解Java内存模型和垃圾回收机制提供了基础。

内存 VS 对象

在Java中,所有的对象都需要占用内存空间,可以说内存中存储的是一个个对象。因此,对于不再被使用的对象,我们需要进行内存回收,这个过程也可以称为死亡对象的回收。

GC回收的目标和生命周期管理

GC(垃圾回收)的目标是释放内存中的不再使用的对象,而对于Java来说,这主要指的是通过new关键字创建的对象。

  1. 局部变量和栈帧的生命周期:

    • 局部变量: 存储在栈帧中的局部变量随着方法的执行而创建,方法执行结束后,栈帧销毁,局部变量的内存也会自然释放。这是由方法的生命周期控制的。

    • 栈帧: 栈帧是用来支持方法调用和执行的数据结构,栈帧的生命周期与方法的调用关系密切相关。每次方法调用都会创建一个新的栈帧,方法执行结束后,相应的栈帧就会被销毁,伴随着局部变量的释放。这保证了栈中的局部变量在不再需要时会被及时释放。

  2. 静态变量的生命周期:

    • 静态变量是属于类的,它的生命周期与整个程序的运行周期一致。在程序启动时被初始化,在程序结束时被销毁。因此,静态变量的内存是在程序运行期间一直存在的,它的释放不受方法的调用关系影响。静态变量的释放通常发生在程序结束时。

① 死亡对象的判断算法

a. 引用计数算法

算法描述

引用计数算法的基本思想是为对象增加一个引用计数器,具体过程如下:

  1. 给对象初始化时,设置引用计数器初始值为0。
  2. 每当有一个地方引用该对象时,计数器加1。
  3. 当引用失效时,计数器减1。
  4. 任何时刻计数器为0的对象就是不能再被使用的,即对象已经"死亡"。

特点

  • 实现简单: 引用计数法的实现相对简单,容易理解和操作。
  • 判定效率高: 在大部分情况下,判定对象的引用状态效率较高。

应用场景

引用计数法在某些编程语言中被广泛应用,例如Python语言采用引用计数法进行内存管理。

限制和问题

尽管引用计数法具有一些优点,但在主流的Java虚拟机(JVM)中并未选用该算法,主要原因有两个:

1、比较浪费内存空间:每个对象都需要安排一个计数器来保持它的引用,一个计数器最少也需要2个字节。如果对象很少,或者对象都比较大,那么影响不大,但是如果你的对象本身就很小了,那么计数器占据的空间就会难以忽视。

2、无法解决循环引用问题: 引用计数法无法处理对象之间存在循环引用的情况。

循环引用指的是对象之间形成了循环的引用关系,使得引用计数器无法准确判断对象是否可达。

例子:观察循环引用问题 :这段代码演示了一个循环引用的情况,并通过强制触发JVM垃圾回收来观察其行为。

  1. /**
  2. * JVM参数: -XX:+PrintGC
  3. * @author 38134
  4. */
  5. public class Test {
  6. public Object instance = null;
  7. private static int _1MB = 1024 * 1024;
  8. private byte[] bigSize = new byte[2 * _1MB];
  9. public static void testGC() {
  10. Test test1 = new Test();
  11. Test test2 = new Test();
  12. test1.instance = test2;
  13. test2.instance = test1;
  14. test1 = null;
  15. test2 = null;
  16. // 强制 JVM 进行垃圾回收
  17. System.gc();
  18. }
  19. public static void main(String[] args) {
  20. testGC();
  21. }
  22. }

在这个类中,Test 类包含一个 instance 成员变量,同时创建了两个 Test 对象 test1 和 test2,并让它们相互引用。这就构成了循环引用。

在 testGC 方法中,将 test1 和 test2 设为 null,然后强制执行 System.gc() 触发垃圾回收。通过观察GC日志输出,我们可以得到垃圾回收前后的堆内存使用情况。

从提供的GC日志中可以看到,"6092K->856K(125952K)"表示在垃圾回收之前,堆内存使用了6092K,回收后剩余856K,总共的堆内存大小为125952K。这表明虚拟机进行了垃圾回收,并成功回收了一部分内存,包括循环引用的对象。

这也说明JVM并不使用引用计数法来判断对象是否存活,而是采用其他更复杂的算法,如可达性分析,来处理循环引用等情况。

b. 可达性分析算法

GC圈子中有两种主流的方案,如果面试官要你介绍GC,你可以说引用计数。但是引用计数主要用于python、PHP,如果问的是Java中的GC,那么介绍引用计数就不合适了。

在上面我们讲了,Java并不采用引用计数法来判断对象是否已"死",而采用"可达性分析"来判断对象是否存活(同样采用此法的还有C#、Lisp-最早的一门采用动态内存分配的语言)。

它的核心思想是通过一系列称为"GC Roots"的对象作为起始点,从这些节点开始向下搜索,搜索走过的路径称之为"引用链"。当一个对象到GC Roots没有任何引用链相连时(从GC Roots到这个对象不可达),证明此对象是不可用的,即可以被回收。

可达性分析的出发点有很多,不只是局部变量,还有一些别的:

GC Roots的对象包括:

  1. 虚拟机栈(栈帧中的本地变量表)中引用的对象: 属于方法执行的线程私有部分,保存了局部变量等信息,可以通过这些局部变量引用的对象作为GC Roots。

  2. 方法区中类静态属性引用的对象: 静态属性属于类的状态,可以作为GC Roots。

  3. 方法区中常量引用的对象: 常量池中的常量也可能被引用,因为它们在方法区中。

  4. 本地方法栈中 JNI(Native方法)引用的对象: JNI是Java Native Interface的缩写,允许Java代码调用和被调用者用其他语言编写的代码。在JNI中引用的对象也被视为GC Roots。

可达性分析的例子: 假设有对象Object1到Object7,它们之间存在一定的引用关系。通过可达性分析,如果从GC Roots(如虚拟机栈、方法区)无法通过引用链访问到Object5、Object6、Object7,那么这三个对象就被判定为不可达,即可被回收。

这种算法有效地判断了对象的可达性,保证了不再被引用的对象能够被垃圾回收机制及时释放。

简单来说,可达性分析本质上就是“时间换空间”。有一个线程周期性扫描我们代码中的所有对象,从一些特定对象出发,尽可能的进行访问的遍历,把所有能够访问到的对象都标记为“可达”,反之,经过扫描之后未被标记的对象就是“垃圾”了。这里的遍历大概率是N叉树,主要就是看你访问的某个对象里面有多少个引用类型的成员,针对每个引用类型的成员都需要进一步的进行遍历。而且这里的可达性分析都是周期性进行的,会定期确认当前的某个对象是否变成“垃圾”,因为里面的对象是会随着代码的执行发生改变的。

因此,可达性分析实际上还是比较消耗时间的。

以下图为例:

综上,总结一下:

  1. 引用计数算法:

    • 描述: 为对象增加一个引用计数器,每当有一个地方引用它时,计数器+1;当引用失效时,计数器-1;任何时刻计数器为0的对象即为不可用,可以被回收。
    • 优点: 实现简单,判定效率较高。
    • 缺点: 无法解决对象循环引用的问题。
  2. 可达性分析算法:

    • 描述: 通过一系列称为 "GC Roots" 的对象作为起始点,从这些节点开始向下搜索,形成引用链。当一个对象到 GC Roots 没有任何引用链相连时,证明此对象不可达,可以被回收。
    • 优点: 解决了对象循环引用的问题。
    • 缺点: 需要耗费一定的计算资源,对于大规模对象的系统,可能引起性能问题。

引用在Java中有多种类型,每种类型的引用对对象的生命周期和垃圾回收行为有不同的影响。在JDK1.2之后,引入了强引用、软引用、弱引用和虚引用这四种引用类型,它们的强度依次递减。

  1. 强引用 (Strong Reference):

    • 强引用是最普遍的引用类型,类似于 Object obj = new Object() 这样的引用。
    • 只要强引用存在,垃圾回收器永远不会回收被引用的对象实例。
    • 强引用保证对象的可达性。
  2. 软引用 (Soft Reference):

    • 用来描述一些还有用但不是必须的对象。
    • 在系统将要发生内存溢出之前,会将软引用关联的对象列入回收范围,进行第二次回收。
    • 如果这次回收仍然没有足够的内存,才会抛出内存溢出异常。
    • JDK1.2之后,提供了 SoftReference 类来实现软引用。
  3. 弱引用 (Weak Reference):

    • 用来描述非必需对象,强度比软引用更弱。
    • 被弱引用关联的对象只能生存到下一次垃圾回收发生之前。
    • 当垃圾回收器开始工作时,无论当前内存是否足够,都会回收只被弱引用关联的对象。
    • JDK1.2之后,提供了 WeakReference 类来实现弱引用。
  4. 虚引用 (Phantom Reference):

    • 也被称为幽灵引用或者幻影引用,是最弱的一种引用关系。
    • 对象是否有虚引用的存在不会对其生存时间构成影响,也无法通过虚引用取得一个对象实例。
    • 设置虚引用的唯一目的是在对象被收集器回收时收到一个系统通知。
    • JDK1.2之后,提供了 PhantomReference 类来实现虚引用。

这些引用类型的引入,使得开发者能够更灵活地控制对象的生命周期,特别是在一些内存敏感的应用中,合理使用不同类型的引用可以优化内存的使用和垃圾回收的效率。 

② 垃圾回收算法

通过上面的学习我们可以将死亡对象标记出来了,标记出来之后我们就可以进行垃圾回收操作了。

在正式学习垃圾收集器之前,我们先看下垃圾回收机器使用的几种算法(这些算法是垃圾收集器的指导思想)

a.标记-清除算法——比较简单粗暴

"标记-清除"算法是最基础的垃圾收集算法,它分为两个阶段:标记和清除。

首先标记出所有需要回收的对象,然后在标记完成后统一回收所有被标记的对象。

然而,这种算法存在一些不足之处:

  1. 效率问题: 标记和清除这两个过程的效率都不高。标记阶段需要遍历所有的对象来确定哪些是需要回收的,而清除阶段则需要回收被标记的对象,这两个过程都会占用一定的计算资源。

  2. 空间问题: 标记-清除算法在回收后可能会产生大量不连续的内存碎片。由于对象被不规则地分布在内存中,可能导致以后在程序运行中需要分配较大对象时,无法找到足够连续内存,从而不得不提前触发另一次垃圾收集。

这些问题影响了"标记-清除"算法的实际运用,因此后续的垃圾收集算法都是基于这种思路进行改进,以提高效率和解决空间问题。

b.复制算法

"复制"算法是为了解决"标记-清除"算法的效率问题而设计的。该算法将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。具体执行流程如下:

  1. 初始时刻: 将可用内存划分为两块,假设为From区和To区。

  2. 对象分配: 在程序运行过程中,对象首先被分配到From区。

  3. 垃圾回收: 当From区的内存空间即将用尽时,触发垃圾回收。此时,算法会扫描From区,将所有还存活的对象复制到To区,同时对From区进行整理,将已使用的内存空间清空。

  4. 切换区域: 将From区和To区的角色互换,使得To区变为新的From区,From区变为空闲的To区。

这样,每次垃圾回收都是对整个半区进行的,避免了标记-清除算法中的效率问题。而且,由于每次只使用一半的内存,不需要考虑内存碎片问题,大大简化了内存分配的复杂性。

此算法实现简单,运 行高效。算法的执行流程如下图 : 

当然,这个方案的缺点也很明显:

1、内存要浪费一半,利用率不高;

2、如果有效的对象过多,拷贝的开销就会很大。

在商用虚拟机中,包括HotSpot,通常采用复制算法来回收新生代。新生代中绝大多数对象都是“朝生夕死”的,因此可以使用一种更适合这种情况的垃圾回收算法。

c. 标记-整理算法

复制收集算法在对象存活率较高时会进行比较多的复制操作,效率会变低。因此在老年代一般不能使用复制算法。 针对老年代的特点,提出了一种称之为"标记-整理算法"。

标记-整理算法是为了解决复制算法在老年代中可能面临的效率问题。该算法的标记过程与标记-清理算法一致,但后续步骤有所不同。在标记-整理算法中,不是直接清理掉可回收对象,而是将所有存活的对象向一端移动,然后清理掉端边界以外的内存。

具体步骤如下:

  1. 标记阶段(与标记-清理算法相同): 标记出所有需要回收的对象。

  2. 整理阶段: 将所有存活的对象都向一端移动。这一步的目的是为了减少内存碎片的产生。移动后,所有存活的对象都被紧凑地排列在一起。

  3. 清理阶段: 清理掉端边界以外的内存。因为所有存活的对象都被移动到了一端,清理时只需要清理掉不再使用的内存,而不用考虑中间的空隙。

标记-整理算法相对于复制算法在老年代的优势在于,它避免了大量的复制操作,减少了移动对象的次数,降低了垃圾回收的时间开销。然而,标记-整理算法仍然需要停止应用程序的执行(Stop-the-World)来进行垃圾回收操作。

流程图如下: 

类似于顺序表删除元素的搬运操作……

但是搬运的开销仍然很大。

事实上,由于每种方式都不尽如人意,JVM真正采取的释放思路是上述基础思路的结合体,让不同的方案能够扬长避短。

d. 分代算法

分代算法和上面讲的 3 种算法不同,分代算法是一种根据对象存活周期的不同将内存划分为不同区域,并采用不同的垃圾回收策略的算法。

这就好比中国的一国两制方针一样,对于不同的情况和地域设置更符合当地 的规则,从而实现更好的管理,这就时分代算法的设计思想。

在当前的 JVM 垃圾收集中,常用的是“分代收集算法(Generational Collection)"算法,这个算法并没有新思想,只是根据对象存活周期的不同将内存划分为几块。”。这个算法并没有新思想,只是根据对象存活周期的特点将堆内存划分为新生代和老年代。

具体来说:

  1. 新生代(Young Generation): 新生代中的对象存活周期较短,一般有大量的对象在短时间内变为垃圾。因此,采用复制算法,将新生代分为三个区域:Eden区和两个Survivor区(通常称为From区和To区)。对象首先在Eden区分配,经过一次Minor GC(年轻代垃圾回收)后,仍然存活的对象会被移到Survivor区,经过多次迭代后存活的对象会被晋升到老年代。

  2. 老年代(Old Generation): 老年代中的对象存活周期较长,因此采用标记-清理或标记-整理算法。在老年代中进行Major GC(老年代垃圾回收),清理不再使用的对象。由于老年代中存活对象较多,采用复制算法的代价较高,因此选择标记-清理或标记-整理。

分代算法的设计思想是根据不同对象的存活特点,采用适当的垃圾回收算法,以提高垃圾回收的效率。这样的设计在实际应用中能够更好地适应不同对象的生命周期,提高整体的垃圾回收性能。

哪些对象会进入新生代?哪些对象会进入老年代?

新生代(Young Generation):

  1. 新创建的对象: 大部分对象在程序刚刚创建时会进入新生代的Eden区。
  2. 经过一次Minor GC后仍存活的对象: 如果一个对象在新生代经历了一次Minor GC后仍然存活,它将被移动到Survivor区域。
  3. Survivor区经过多次迭代的存活对象: 如果一个对象在Survivor区域经过多次GC迭代后仍然存活,最终会被晋升到老年代。

老年代(Old Generation):

  1. 经过多次Minor GC后仍然存活的对象: 对象在新生代经历了多次Minor GC后仍然存活,会晋升到老年代。
  2. 大对象: 由于新生代使用复制算法,复制大对象的代价较高。因此,大对象(尺寸超过某个阈值)可能直接分配到老年代。
  3. 经过一次Major GC后仍然存活的对象: 在老年代进行Major GC(Full GC)后仍然存活的对象会继续留在老年代。

需要注意的是,具体的阈值和规则可能会因为不同的JVM实现而有所不同。例如,对于大对象的阈值、Minor GC的触发条件等可能会有调优选项。

新生代内存通常被划分为一块较大的Eden空间和两块较小的Survivor空间(通常称为From区和To区)。默认情况下,HotSpot采用8:1:1的比例,即Eden:Survivor From: Survivor To = 8:1:1。每次使用Eden和其中一块Survivor进行对象分配,当回收时,将Eden和Survivor中的存活对象复制到另一块Survivor空间,最后清理掉Eden和刚才用过的Survivor空间。

具体的HotSpot复制算法流程如下:

当程序执行时,新创建的对象首先会被分配到新生代的Eden空间。Eden空间是新生代内存的主要工作区域,它较大,用于存储刚刚被创建的对象。

  1. 第一次Minor GC(触发条件:Eden区满):

    • 当Eden区满时,触发第一次Minor GC。
    • GC会将还存活的对象从Eden拷贝到Survivor From区。这个过程称为复制阶段。
    • 对象在Survivor From区域的年龄设为1,并且年龄计数器+1。
  2. 第二次Minor GC(触发条件:Eden和Survivor From区都满):

    • 当Eden区再次满时,触发第二次Minor GC。
    • GC会扫描Eden区和Survivor From区,对这两个区域进行垃圾回收,将存活的对象直接复制到Survivor To区。这个过程同样属于复制阶段。
    • 年龄为1的对象在Survivor To区的年龄会增加,变为2。如果年龄达到一定阈值(由MaxTenuringThreshold参数控制,默认为15),对象将被晋升到老年代。
  3. 后续的Minor GC:

    • 在后续的Minor GC中,对象的存活和晋升的过程会不断重复。
    • 存活的对象在Eden和Survivor From/To之间复制,年龄逐渐增加。
    • 当对象的年龄达到一定阈值时,会被晋升到老年代。
  4. 晋升到老年代:

    • 每次Minor GC,Survivor From和Survivor To会交换,成为下一次GC的新的From和To。
    • 对象在Survivor区的年龄达到一定阈值(MaxTenuringThreshold),将会晋升到老年代。
    • 这样,新生代的存活对象在Survivor区中循环复制,年龄逐渐增加,达到一定年龄后进入老年代。

这整个过程的核心思想是通过复制算法,将新生代中的存活对象拷贝到Survivor区域,并通过不断的复制和年龄控制,保证新生代的Eden和Survivor能够高效地回收不再使用的对象,同时降低了内存碎片的产生。最终,那些存活时间较长的对象会被晋升到老年代。

这样的设计有效地利用了新生代对象的“朝生夕死”特性,提高了内存利用率和回收效率。

面试题 : 请问了解Minor GC和Full GC么,这两种GC有什么不一样吗

Minor GC(新生代GC):

  1. 频率高: Minor GC 主要发生在新生代,因为新生代的对象存活周期较短。大多数对象在被创建后很快变得不可达。
  2. 速度快: 由于只涉及新生代的Eden区和Survivor区,Minor GC 的速度通常较快。
  3. 采用复制算法: 新生代一般采用复制算法,即将存活的对象复制到Survivor区域或老年代,清理掉不可达的对象。

Full GC(老年代GC或Major GC):

  1. 发生在老年代: Full GC 主要发生在老年代,即对整个堆(包括新生代和老年代)进行垃圾回收。
  2. 频率低: Full GC 的频率通常较低,因为老年代的对象具有较长的生命周期,不太容易变得不可达。
  3. 速度相对慢: Full GC 涉及整个堆空间,因此它的速度相对较慢,而且可能涉及到更多的垃圾回收算法(如标记-清理或标记-整理)。
  4. 涉及多个代: Full GC 通常会触发多次的 Minor GC,因为在清理老年代时,可能需要清理掉新生代的对象。

总体来说,Minor GC 和 Full GC 都是垃圾回收的过程,主要区别在于它们涉及的内存区域和频率。Minor GC 针对新生代,频繁发生且速度较快;而 Full GC 针对整个堆,频率相对较低且速度相对较慢。

③ 垃圾收集器

如果说上面我们讲的收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。

垃圾收集器的作用:垃圾收集器是为了保证程序能够正常、持久运行的一种技术,它是将程序中不用的死亡对象也就是垃圾对象进行清除,从而保证了新对象能够正常申请到内存空间。

具体而言,垃圾收集器实现以下功能:

  1. 识别垃圾对象: 垃圾收集器通过某种算法识别程序中哪些对象不再被引用和访问,即垃圾对象。

  2. 释放内存空间: 一旦垃圾收集器确定了哪些对象是垃圾,它就会释放这些对象占用的内存空间,使这些空间可用于新的对象分配。

  3. 防止内存泄漏: 通过及时回收不再使用的对象,垃圾收集器可以防止内存泄漏,确保程序不会因为长时间运行而耗尽可用内存。

  4. 提高程序健壮性: 自动内存管理可以降低程序员犯错的可能性,提高程序的健壮性。程序员无需担心手动释放内存,减轻了开发的负担。

以下这些收集器是 HotSpot 虚拟机随着不同版本推出的重要的垃圾收集器:​​​​​​​

上图展示了7种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明他们之间可以搭配使用。所处的区域,表示它是属于新生代收集器还是老年代收集器。

在讲具体的收集器之前我们先来明确 三个概念:

  1. 并行(Parallel): 多条垃圾收集线程并行工作,即同时执行。在并行垃圾收集期间,用户线程可能处于等待状态。

  2. 并发(Concurrent): 用户线程与垃圾收集线程同时执行,但并不一定是真正的并行。它表示用户程序可以继续运行,而垃圾收集程序在另一个 CPU 上执行。这有助于提高程序的响应性。

  3. 吞吐量(Throughput): 吞吐量是指 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值。它反映了系统在执行用户代码时的效率。吞吐量高表示系统更多的时间用于执行用户代码,而非垃圾回收。

例如,如果虚拟机总共运行了100分钟,其中垃圾收集花费了1分钟,那么吞吐量就是 99%。吞吐量是评价垃圾收集器性能的一个重要指标。

在实际应用中,根据应用的特点和性能需求,选择不同的垃圾收集器以达到更好的性能表现。Java 虚拟机提供了多种垃圾收集器,每个收集器都有其适用的场景和优缺点。

为什么会有这么多垃圾收集器? 

有这么多垃圾收集器的存在主要是因为不同的应用场景和需求对垃圾收集的性能指标有不同的要求。垃圾收集器的设计和选择取决于以下一些因素:

  1. 应用场景: 不同的应用场景对垃圾收集器的要求不同。一些应用可能更注重吞吐量(Throughput),而另一些可能更注重最小的停顿时间(Pause Time)。

  2. 硬件环境: 不同的硬件环境可能对垃圾收集器的表现产生影响。一些收集器可能在多核处理器上表现更好,而另一些可能更适用于特定的内存结构。

  3. 内存分布和对象生命周期: 不同的应用程序有不同的内存使用模式。一些应用可能会产生大量的临时对象,而另一些应用可能会有长寿命的对象。垃圾收集器的选择通常取决于应用程序的内存分布和对象的生命周期。

  4. 性能指标: 不同的垃圾收集器对性能指标的优化有不同的重点。有些垃圾收集器更注重吞吐量,而另一些更注重最小化停顿时间。

以下是一些常见的垃圾收集器以及它们的特点:

  • Serial(串行)收集器: 适用于单核处理器的客户端应用,通过单线程进行垃圾收集,停顿时间相对较长。

  • ParNew(并行新生代)收集器: 是 Serial 收集器的多线程版本,适用于多核处理器的客户端应用,也可用于服务器端。

  • Parallel Scavenge(并行吞吐量优先)收集器: 适用于注重吞吐量的场景,通过并行多线程进行垃圾收集。

  • Serial Old(串行老年代)收集器: 是 Serial 的老年代版本,用于老年代的垃圾收集。

  • CMS(Concurrent Mark-Sweep)收集器: 适用于追求最小停顿时间的场景,通过并发方式进行垃圾标记和清理。

  • G1(Garbage-First)收集器: 适用于大内存和注重低延迟的应用场景,通过分区方式进行垃圾收集。

  • ZGC(Garbage-First)收集器:适用于大内存和注重低延迟的应用场景,通过分区方式进行垃圾收集。

每个收集器都有其优势和局限性,部分垃圾收集器其实已经停用,但是由于我们之前提到的那本书里都介绍了,所以这里我们也就简单提一下。你如果比较感兴趣,可以自己去了解一下后面三个垃圾收集器,它们是最主流的。

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/盐析白兔/article/detail/762816
推荐阅读
相关标签
  

闽ICP备14008679号