前言

JVM是Java进阶之路中非常重要的一步,因此写下本文,用一篇文章对JVM知识点做一个总结。

JVM知识体系比较多,本文将采用“想到什么说什么“的思维编写,个人感觉这样更容易引发学习思考,面对有难度的知识点,可以由浅入深,一点点的了解。

最后,再大概了解完JVM的所有知识点后,再做一个核心知识点总结,用于整理归纳。

JVM是什么

首先,在听到一个新单词时,不禁会产生疑问,它是什么?所以JVM是什么呢?

JVM全名 Java Virtual Machine ,中文名 Java虚拟机。顾名思义,它是一个虚拟化的计算机。

Q:JVM作为虚拟化机器,能做什么呢?

它能执行Java字节码,将Java字节码翻译成机器代码,供操作系统执行,实现Java“一次编译,到处运行“的特性。

Q:出现了新单词 - Java字节码,什么是Java字节码呢?

Java字节码(Java Bytecode)是Java编程语言编译后生成的一种中间表示形式。它是与平台无关的二进制代码,JVM能够直接理解和执行它。反过来说,Java字节码需要JVM进行解释,才能够被机器直接执行。

Q:Java字节码是怎么产生的呢?

它是由Java源代码(.java文件)经过Java编译器(javac)编译后生成的,生成的文件后缀名为 .class 。

需要注意的是,Java字节码对于JVM来说都应该被叫做 JVM字节码 ,因为.class文件是为了便于JVM识别和执行的。只是因为JVM最初是为运行Java程序而设计的,因此Java语言是第一个也是最广泛使用的编译生成JVM字节码的语言。由于Java的普及和影响力,JVM字节码通常被称为“Java字节码”。 虽然JVM最初是为Java设计的,但它已经发展成为一个多语言平台。许多其他编程语言(如Scala、Kotlin、Groovy、Clojure等)也可以编译成JVM字节码并在JVM上运行。因此,用“JVM字节码”这个术语更能反映现代JVM生态系统的多样性和广泛应用。

Q:既然知道是通过JVM执行的Java字节码,那JVM的执行机制又是怎样的?

这个问题有点偏面试的问法了,换成下面白话文的形式:

现在有如下一段代码,它是怎么运行起来的,JVM在执行Java字节码的过程中做了哪些操作?

1
2
3
4
5
public class Hello {
public static void main(String[] args) {
System.out.println("Hello");
}
}

开发人员只看源代码的情况下,瞟一眼就知道系统执行后会输出“Hello”语句。

那么操作系统是如何知道要输出“Hello”语句的,就涉及到Java代码的执行过程了,执行过程一般分为编译期和运行时。

首先Java源代码(.java文件)经过编译器(javac)生成为Java字节码(.class文件),在 IDEA 上双击Hello.class文件,可以看到如下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by decompiler)
//

package com.itwray.study.advance.jvm;

public class Hello {
public Hello() {
}

public static void main(String[] args) {
System.out.println("Hello");
}
}

通过注释可以看出,IDEA 默认使用了 FernFlower 反编译工具将字节码文件反编译为我们看得懂的 Java 源代码。

那么,真实的.class文件是什么样子的呢,建议使用 Sublime Text 工具直接打开.class文件,打开后的文件内容如下:

image-20240619165037574

从右下角的 Binary 可以看出,Sublime Text 识别到.class文件为二进制文件,只不过在展示时将其转换为了十六进制。

有了字节码文件后,就可以启动JVM运行字节码文件了(启动方式的本质是使用java命令工具)。

JVM在运行时的执行过程如下:

  1. 类加载:通过类加载器加载Hello类。
  2. 字节码验证:验证Hello.class文件的合法性。
  3. 内存分配:在堆上为Hello类的对象分配内存。
  4. 解释执行:JVM解释执行Hello的main方法中的字节码指令。
  5. 即时编译:如果main方法是热点代码,JIT编译器将其编译为本地机器码,提升执行效率。
  6. 输出结果:调用本地方法(java.io.FileOutputStream#writeBytes),通过JNI与操作系统交互,输出“Hello”到控制台。

看完JVM的执行机制,突然发现了很多新词汇,在一个一个分析之前,需要先了解了解JVM的组成部分。

JVM的组成

Q:JVM的组成部分有哪些?

JVM大致可以分为类加载器、运行时数据区、执行引擎三个部分,下面是它的组成图。

20240624172938

其实在执行引擎之后还有两个组成部分,分别是本地方法接口(JNI)和本地方法库(Native Method Libraries)。可以从上图看出,JVM分析字节码文件的执行过程大致就是按照它的组成部分从上往下执行的。

类加载器是JVM运行Java程序的第一关,主要负责加载类文件(.class文件),如果类文件加载失败,就不会进入到运行时数据区和执行引擎了。类加载器将类文件加载到内存中后,会经过加载、连接、初始化三个主要阶段。

运行时数据区负责存储类的元数据、对象实例、方法调用信息和线程执行状态等。方法区存储类信息和静态数据,堆存储对象实例,Java虚拟机栈和本地方法栈分别管理方法调用和本地方法调用的状态信息,程序计数器记录当前执行的字节码指令地址。这些区域协同工作,确保Java程序能够高效、正确地执行。

执行引擎负责执行Java字节码,将Java字节码指令转换为机器指令,并执行这些指令。它的主要职责包括解释执行、即时编译(JIT)、垃圾回收、以及各种优化技术。

总结:在JVM中,类加载器负责加载类文件并生成Class对象,而为Class对象分配内存空间和初始化是由运行时数据区中的方法区完成的。执行引擎负责解释执行字节码或将其编译为本地机器码并执行。这些组件协同工作,确保类文件被正确加载、分配内存、初始化并执行。

类加载机制

Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程被称作虚拟机的类加载机制。

类加载时机

一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)七个阶段,其中验证、准备、解析三个部分统称为连接(Linking)。

image-20240710161054762

类加载过程

加载

加载阶段负责将类文件从不同来源(如本地文件系统、网络等)加载到内存中,并生成类的Class对象。这个阶段包括以下步骤:

  • 查找并加载类的二进制数据:类加载器首先在类路径(classpath)中查找类文件。如果找不到,会继续使用其他方式(如网络下载或自定义加载器)查找类文件。
  • 生成类的Class对象:将加载的类文件的二进制数据解析为JVM内部数据结构,并创建对应的Class对象。

Q:查找类文件的依据是什么?或者说JVM使用什么数据查找的类文件?

类加载器根据类的全限定名称来查找类文件。全限定名称包括包名和类名,例如:com.example.MyClass

类的全限定名称通常映射到文件路径。例如:类com.example.MyClass映射到文件路径com/example/MyClass.class

Q:加载类文件的来源一般有哪些?

  • Classpath:
    • 本地文件系统:通常类文件会放在本地文件系统的特定目录中,这些目录通过classpath设置。
    • JAR文件:类文件可以打包在JAR文件中,通过classpath包含这些JAR文件。
  • 网络:类加载器可以从网络上加载类文件,特别是自定义的类加载器可以从指定的URL或远程服务器上加载类文件。
  • 内存:类文件可以直接从内存中加载。例如,一些框架会生成类文件的字节码并直接加载到JVM中。
  • 其他存储:类文件可以存储在数据库中,某些类加载器可以从数据库中读取和加载类文件。

Q:类加载器有哪些?

以Java 8为例,类加载器一般分为4种:(前三个ClassLoader为内置类加载器)

  1. 引导类加载器(Bootstrap ClassLoader):加载核心Java类库(通常位于<JAVA_HOME>/lib目录下),如java.lang.*包中的类。
  2. 扩展类加载器(Extension ClassLoader):加载扩展库(通常位于<JAVA_HOME>/lib/ext目录下)的类。
  3. 应用程序类加载器(AppClassLoader):加载应用程序类路径(classpath)中的类,这是最常用的类加载器。
  4. 自定义类加载器:用户可以定义自己的类加载器,以便从特定位置或以特定方式加载类。

Q:怎么确定类文件应该由哪个类加载器加载呢?

ClassLoader采用双亲委派模型搜索类和资源,ClassLoader 实例会在试图亲自查找类或资源之前,将搜索类或资源的任务委托给其父类加载器。委派机制如下:

image-20240627173246953

并且,双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。

Java内置的三个类加载器就是按照 BootstrapClassLoader -> ExtClassLoader -> AppClassLoader 的层级设计的,BootstrapClassLoader作为顶层ClassLoader,是没有父类加载器的。

如果在代码中获取ExtClassLoader的parent ClassLoader,也会输出为空,因为BootstrapClassLoader是由C++实现的,这个 C++ 实现的类加载器在 Java 中是没有与之对应的类的,所以拿到的结果是 null。

提示:双亲委派模型并非是Java强制的约束,只是一种官方推荐的方式,在自定义类加载器中,可以重写C lassLoadloadClass方法改为不采用双亲委派模型的方式。不过为了避免类重复加载以及Java核心API的安全,一般不建议重写loadClass方法,而是重写findClass方法实现自定义类的加载机制。

Q:类加载器是怎么解析的类文件的二进制数据呢?

这个问题涉及到类文件的详细结构,后面单独出章节分析。目前只需要记住类文件的组成结构有如下部分:魔数(Magic Number)、版本号(Version Number)、常量池(Constant Pool)、访问标志(Access Flags)、类索引、父类索引和接口索引集合、字段表(Fields)、方法表(Methods)、属性表(Attributes)。

并且,这个组成结构的顺序是固定的,下面是一个类文件的字节码结构组成部分:(u后面的数字表示占用的字节数)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ClassFile {
u4 magic; // 魔数
u2 minor_version; // 次版本号
u2 major_version; // 主版本号
u2 constant_pool_count; // 常量池计数
cp_info constant_pool[constant_pool_count-1]; // 常量池
u2 access_flags; // 访问标志
u2 this_class; // 类索引
u2 super_class; // 父类索引
u2 interfaces_count; // 接口计数
u2 interfaces[interfaces_count]; // 接口索引集合
u2 fields_count; // 字段计数
field_info fields[fields_count]; // 字段表
u2 methods_count; // 方法计数
method_info methods[methods_count]; // 方法表
u2 attributes_count; // 属性计数
attribute_info attributes[attributes_count]; // 属性表
}

Q:创建的Class对象被存储到哪了?

在类加载阶段,生成的Class对象被存放在JVM的方法区中。方法区是JVM运行时数据区的一部分,用于存储类的元数据、常量、静态变量和即时编译器编译后的代码等。

Class在运行时被实例化的对象则是存放在中,通过这个对象实例可以访问到类的元数据(即Object#getClass()方法)。

连接

类连接主要包括三个子步骤:验证(Verification)、准备(Preparation)和解析(Resolution)。这些步骤确保类文件格式正确,分配必要的内存,并将符号引用转换为直接引用。

验证

验证阶段中JVM执行了一系列详细的验证规则,以确保类文件的格式和内容符合JVM规范,从而保证运行时的安全性和稳定性。验证规则主要由四部分组成:

  1. 文件格式验证:确保类文件的格式正确。
    • 魔数:类文件的前四个字节是否为0xCAFEBABE
    • 版本号:主次版本号是否在当前JVM的处理范围之内。例如,使用JDK8 javac编译的字节码文件,是不能在JAVA7 java命令下运行的。
    • 常量池:检查常量池中的每个常量项是否符合类型和格式的要求。例如,CONSTANT_Class_info项的格式是否正确。
    • 常量池索引:确保常量池中的索引是有效的,不超出常量池的边界。
  2. 元数据验证:确保类文件的元数据(类的结构信息)符合JVM规范。
    • 类声明:检查类的访问标志(如public, final, abstract等)是否合法,确保不能同时使用互斥的标志。
    • 父类和接口:检查类的父类是否存在并且可访问,确保类实现的接口合法。
    • 字段和方法:检查字段和方法的声明是否合法,包括访问修饰符、类型、名称等。
    • 方法签名:确保方法的签名合法,包括参数和返回值类型是否合法。
  3. 字节码验证:确保类文件中的字节码正确。
    • 数据流分析
      • 检查局部变量和操作数栈的使用是否合法,确保操作数栈的深度不超过最大限制。
      • 确保所有变量在使用前已经初始化。
      • 确保方法调用的参数类型和数量正确。
    • 控制流分析
      • 确保所有的跳转指令跳转到有效的字节码位置,不会跳转到中间的指令或无效的位置。
      • 确保异常处理块(try-catch-finally)的范围合法,不会跨越方法边界。
      • 确保方法中的所有路径都正确处理了异常,确保异常处理器的类型和捕获类型匹配。
  4. 符号引用验证:确保常量池中的符号引用能够被解析为合法的直接引用。
    • 类引用:检查常量池中的类引用是否合法,确保引用的类存在并且可访问。
    • 字段引用:检查常量池中的字段引用是否合法,确保引用的字段在相应的类或接口中存在并且可访问。
    • 方法引用:检查常量池中的方法引用是否合法,确保引用的方法在相应的类或接口中存在并且可访问。
准备

准备阶段主要是为类的所有静态变量分配内存,并将其初始化为默认值

准备阶段与初始化阶段的区别

  • 准备阶段:分配静态变量的内存并将其初始化为默认值。这个过程只涉及默认值的设置,不执行任何用户代码(如静态初始化块和静态变量的显式赋值)。
  • 初始化阶段:执行类的静态初始化块和静态变量的显式赋值。这个过程是在准备阶段之后进行的,确保所有静态变量已经分配好内存并设置了默认值。

注意:

  • 这些内存都是在方法区进行分配的,如果是JDK8及以后,方法区的实现是元空间。
  • 类变量此时的初始化是指默认值初始化,而不是用户定义的赋值。默认值如下:
    • 整数类型(如intshortbytelong):默认值为0
    • 浮点类型(如floatdouble):默认值为0.0
    • 字符类型(char):默认值为\u0000(null字符)。
    • 布尔类型(boolean):默认值为false
    • 引用类型(如对象引用):默认值为null。(引用类型包括String、Integer、枚举等)
  • 如果类变量被 final 关键字修饰,那就需要根据变量类型决定其何时被初始化。如果是基本类型和字符串常量,则在准备阶段就会被初始化赋值;如果是引用类型(包括Integer、枚举、自定义类等),则还是在初始化阶段被赋值,准备阶段仍为null。
解析

解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程。解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符 7 类符号引用进行。

符号引用符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在《Java虚拟机规范》的Class文件格式中。

直接引用直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局直接相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在虚拟机的内存中存在。

在解析阶段,因为需要将符号引用替换为直接引用,所以在此阶段可能会抛出各种异常,例如:

  • ClassNotFoundException:无法找到所需的类或接口。
  • NoSuchFieldError:无法找到所需的字段。
  • NoSuchMethodError:无法找到所需的方法。
  • IllegalAccessError:类或接口、字段、方法解析时,发现不具备访问权限。
  • IncompatibleClassChangeError:解析过程中发现类的结构与预期不符。

Q:用一句话总结连接过程三个阶段主要做了什么。

验证阶段确保类文件的字节码是否符合JVM规范,准备阶段就是为类变量分配内存并初始化为默认值的过程,解析阶段是JVM将常量池中的符号引用替换为直接引用的过程。

Q:这三个阶段是按照顺序执行的吗?

从类加载过程来说,是顺序执行的。但对《Java虚拟机规范》而言,加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,类型的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定特性(也称为动态绑定或晚期绑定)。顺序按部就班地开始表示这些阶段通常都是互相交叉地混合进行的,会在一个阶段执行的过程中调用、激活另一个阶段。

验证阶段保证了字节码的合法和安全,如果验证失败,整个类加载过程就会被中断,所以验证阶段是第一步。

准备阶段为静态变量分配内存并设置默认值,解析阶段是将常量池的符号引用替换为直接引用。从语义上来看,两个阶段在部分情况下可以并行执行,但是在JVM的实际实现中,为了保证类加载过程的逻辑清晰和实现简单,所以这两个阶段在实际情况下还是按照顺序执行的。

而至于为什么要把准备阶段放在解析阶段之前,主要是因为在解析过程中可能需要访问或验证静态变量的类型和内存布局。如果准备阶段未完成,这些信息可能不完整或不可用。所以解析阶段依赖于准备阶段。

初始化

初始化阶段是类加载过程的最后一个步骤,它的主要任务是执行类的初始化逻辑,即执行类构造器<clinit>()方法的过程

<clinit>()并不是程序中编写的构造方法(实例的构造方法在JVM视角中称为<init>()方法),它是javac编译器自动收集类中的所有类变量的赋值动作和静态语句块(static {}块)中语句 合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的。

静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问(不能直接使用变量名访问,但可以使用<类名.变量名>的方式访问)。

1
2
3
4
5
6
7
8
9
public class Test {
static {
i = 0; // 给变量复制可以正常编译通过
System.out.print(i); // 这句编译器会提示“非法向前引用”
System.out.println(InitializeDemo.i); // 这句编译可以通过,并且可执行输出为0
}

static int i = 1;
}

<clinit>方法与类的构造方法不同,它不需要显式地调用父类<clinit>()方法,Java虚拟机会保证在子类的<clinit>()方法执行前,父类的<clinit>()方法已经执行完毕。因此在Java虚拟机中第一个被执行的<clinit>()方法的类型肯定是java.lang.Object。

由于父类的<clinit>()方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作。例如如下示例中,输出的结果为2。

<clinit>()方法对于类或接口不是必需的,如果一个类中没有静态语句块和类变量赋值操作,那么javac编译器可以不为这个类生成<clinit>()方法。

接口虽然不能定义静态语句块,但可以有变量赋值操作,它属于类变量赋值操作。但接口与类不同,在初始化阶段,子接口不会先执行父接口的<clinit>()方法,只有当父接口定义的变量被使用时,父接口才会被初始化。此外,接口的实现类在初始化时也会执行接口的<clinit>()方法。

每个类的<clinit>()方法在类加载过程中只会执行一次,它通过对<clinit>()方法加锁实现,确保多线程环境中只会有其中一个线程去执行<clinit>()方法,其他线程都需要阻塞等待,直到活动线程的<clinit>()方法执行完毕。如果在类初始化过程中出现异常,该异常都会被封装成ExceptionInInitializerError异常抛出。

类的初始化阶段是惰性的,即在首次使用该类时才会触发。触发情况有如下几种:

  • 当创建类的新实例时(使用new关键字)。
  • 当访问类的静态字段或调用静态方法时。
  • 当反射机制调用类的方法时(例如,Class.forName)。
  • 当初始化一个类的子类时,父类会先被初始化。
  • 当虚拟机启动时,用户指定的主类会首先被初始化。

因为初始化是惰性的,也间接说明了类在经过加载、连接阶段后,并不一定会马上执行初始化阶段。常见情况有如下几种:

  1. 通过反射查询类信息,但不实际使用

    使用反射机制查询类的信息,例如获取类的元数据(字段、方法等),这会触发类的加载和解析,但不会触发初始化。

    1
    2
    3
    4
    5
    6
    public class Test {
    public static void main(String[] args) throws ClassNotFoundException {
    Class<?> clazz = Class.forName("Example", false, Test.class.getClassLoader());
    // 仅查询类信息,不触发初始化
    }
    }
  2. 仅仅解析类而未实际访问

    某些情况下,JVM在运行过程中可能会预解析类以提高性能,但如果这些类没有被实际使用,则不会进入初始化阶段。

    1
    2
    3
    4
    5
    6
    public class Main {
    public static void main(String[] args) {
    Class<?> clazz = Example.class; // 仅解析类,不触发初始化
    System.out.println("Main method");
    }
    }
  3. 引用常量

    使用类的常量字段(final static 修饰的基本类型或字符串)时,只会触发类的加载和解析,不会触发初始化。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    public class Example {
    public static final int CONST = 42;
    static {
    System.out.println("Example class static block");
    }
    }

    public class Test {
    public static void main(String[] args) {
    int value = Example.CONST; // 不会触发初始化
    System.out.println(value);
    }
    }

类加载器

Java虚拟机设计团队有意把类加载阶段中的“通过一个类的全限定名来获取描述该类的二进制字节流”这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需的类。实现这个动作的代码被称为“类加载器”(Class Loader)。

类与类加载器

对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。

换一句话说就是:比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个Java虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。

这里所指的“相等”,包括代表类的Class对象的equals()方法、isAssignableFrom()方法、isInstance()方法的返回结果,也包括了使用instanceof关键字做对象所属关系判定等各种情况。

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
/**
* 类加载器与instanceof关键字演示
*/
public class ClassLoaderTest {
public static void main(String[] args) throws Exception {
ClassLoader myLoader = new ClassLoader() {
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
try {
String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class";
InputStream is = getClass().getResourceAsStream(fileName);
if (is == null) {
return super.loadClass(name);
}
byte[] b = new byte[is.available()];
is.read(b);
return defineClass(name, b, 0, b.length);
} catch (IOException e) {
throw new ClassNotFoundException(name);
}
}
};
Object obj = myLoader.loadClass("com.itwray.study.advance.jvm.ClassLoaderTest").newInstance();
// 输出结果:class com.itwray.study.advance.jvm.ClassLoaderTest
System.out.println(obj.getClass());
// 输出结果:false
System.out.println(obj instanceof com.itwray.study.advance.jvm.ClassLoaderTest);
}
}

双亲委派模型

JVM中内置了三个ClassLoader

  • BootstrapClassLoader(启动类加载器):最顶层的加载类,由 C++实现,通常表示为 null,并且没有父级,主要用来加载 JDK 内部的核心类库( %JAVA_HOME%/lib目录下的 rt.jarresources.jarcharsets.jar等 jar 包和类)以及被 -Xbootclasspath参数指定的路径下的所有类。
  • ExtensionClassLoader(扩展类加载器):主要负责加载 %JAVA_HOME%/lib/ext 目录下的 jar 包和类以及被 java.ext.dirs 系统变量所指定的路径下的所有类。
  • AppClassLoader(应用程序类加载器):面向开发者的加载器,负责加载当前应用 classpath 下的所有 jar 包和类。如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

image-20240627173246953

上图展示的各种类加载器之间的层次关系被称为类加载器的“双亲委派模型(Parents DelegationModel)”。双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。不过这里类加载器之间的父子关系一般不是以继承(Inheritance)的关系来实现的,而是通常使用组合(Composition)关系来复用父加载器的代码。

双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。

双亲委派模型的好处是当一个类处于包含多个类加载器的JVM环境下时,可以保证加载出来的都是同一个类。即一个全限定名的类,在一个JVM下加载多次得到的Class类对象是相等的。

例如,手写一个rt.jar类库下的java.lang.String类,当通过Class.forName()方法加载时,并没有执行手写的String类的static块代码,说明没有加载这个类,而是加载的rt.jar类库下的String类。

1
2
3
4
5
6
7
8
package java.lang;

public class String {

static {
System.out.println("custom String static method");
}
}
1
2
3
4
5
6
7
public class StringDemo {

public static void main(String[] args) throws ClassNotFoundException {
Class<?> stringClass = Class.forName("java.lang.String");
System.out.println(stringClass.getName());
}
}

双亲委派模型的实现位于java.lang.ClassLoader的loadClass()方法中。

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
// 源码来自jdk8
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 如果父类加载器抛出ClassNotFoundException,说明父类加载器无法完成加载请求
}

if (c == null) {
// 在父类加载器无法加载时,再调用本身的findClass方法来进行类加载
long t1 = System.nanoTime();
c = findClass(name);

// this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}

模块化下的类加载器

JDK 9 之后为了适应模块化的发展,类加载器做了如下变化:

  • 仍维持三层类加载器和双亲委派的架构,但扩展类加载器被平台类加载器所取代;
  • 当平台及应用程序类加载器收到类加载请求时,要首先判断该类是否能够归属到某一个系统模块中,如果可以找到这样的归属关系,就要优先委派给负责那个模块的加载器完成加载;
  • 启动类加载器、平台类加载器、应用程序类加载器全部继承自 java.internal.loader.BuiltinClassLoader ,BuiltinClassLoader 中实现了新的模块化架构下类如何从模块中加载的逻辑,以及模块中资源可访问性的处理。

image-20240710173108681

总结

JVM类加载机制是Java程序运行的基础,它通过加载、连接(验证、准备、解析)和初始化阶段将类文件动态加载到内存中,并通过双亲委派模型确保了类加载的安全性和一致性。

运行时数据区

运行时数据区隶属于Java 内存区域的一部分,主要讲述Java虚拟机对于内存区域的划分,这些区域有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而一直存在,有些区域则是依赖用户线程的启动和结束而建立和销毁。

image-20240712152559344

其中方法区和堆属于所有线程共享的数据区,而虚拟机栈、本地方法栈、程序计数器是线程隔离的数据区,也就是说隔离的数据区保证每个线程下都有对应的虚拟机栈、本地方法栈和程序计数器。

程序计数器

程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。在Java虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

由于Java虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。

如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是本地(Native)方法,这个计数器值则应为空(Undefined)。

此内存区域是唯一一个在《Java虚拟机规范》中没有规定任何OutOfMemoryError情况的区域。

Java虚拟机栈

Java虚拟机栈(Java Virtual Machine Stack)又称为JVM栈,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的线程内存模型:每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。

每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

本地方法栈

本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。

与虚拟机栈一样,本地方法栈也会在栈深度溢出或者栈扩展失败时分别抛出StackOverflowError和OutOfMemoryError异常。

Java堆

Java堆(Java Heap)是虚拟机所管理的内存中最大的一块,它是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,Java世界里“几乎”所有的对象实例都在这里分配内存。

Java堆是垃圾收集器管理的内存区域,它可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为是连续的。

Java堆既可以被实现成固定大小的,也可以是可扩展的,不过当前主流的Java虚拟机都是按照可扩展来实现的(通过参数-Xmx和-Xms设定)。

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

方法区

方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。

JDK 8 以后的方法区实现已经不再是永久代(Permanent Generation)了,而是使用元空间(Metaspace)来实现。

方法区也是可以存在垃圾收集的行为的,不过这个区域的回收效果一般微乎其微。因此,如果方法区无法满足新的内存分配需求时,同样会抛出 OutOfMemoryError 异常。

运行时常量池(Runtime Constant Pool)也是方法区的一部分,用于存放常量池表(Constant Pool Table),常量池表中存放了编译期生成的各种符号字面量和符号引用。

字节码执行引擎

JVM字节码执行引擎是Java虚拟机的核心组件之一,它负责执行已加载到内存中的Java字节码,并将其转换为具体的机器指令以执行程序。执行引擎的主要任务包括解释执行字节码、JIT编译、垃圾回收和线程调度等。

解释执行字节码

JVM字节码是一种与平台无关的中间表示形式。解释执行是将字节码逐条转换为相应的机器指令并执行。

字节码解释器:

  • JVM内置的字节码解释器逐条读取字节码指令并执行相应的操作。
  • 解释执行通常较慢,因为每条指令都需要解析和解释。

JIT(Just-In-Time)编译

为了提高执行效率,JVM使用即时编译技术,将热点代码(被频繁执行的代码)编译为本地机器码,直接在硬件上运行。

  • 即时编译器(JIT Compiler):
    • C1编译器:注重编译速度,用于编译简单和不太频繁的代码。
    • C2编译器:注重优化性能,用于编译频繁执行的热点代码。
  • 热点探测:JVM通过计数器统计方法的调用次数或循环次数,以识别热点代码。
  • 编译优化:包括内联、循环展开、逃逸分析等,进一步提高执行效率。

垃圾回收(Garbage Collection)

JVM自动管理内存分配和回收,执行引擎中的垃圾回收器负责清理不再使用的对象,释放内存。

  • 垃圾回收算法:
    • 标记-清除算法:标记所有可达对象,清除未标记对象。
    • 复制算法:将存活对象复制到新空间,清空旧空间。
    • 标记-压缩算法:标记所有可达对象,将存活对象压缩到一端,清除其他空间。
  • 垃圾回收器:
    • Serial GC:单线程垃圾回收器,适用于小型应用。
    • Parallel GC:多线程垃圾回收器,适用于多核处理器。
    • CMS GC:并发标记-清除垃圾回收器,减少停顿时间。
    • G1 GC:分代垃圾回收器,平衡停顿时间和吞吐量。

线程管理

JVM执行引擎负责管理Java线程的生命周期,包括线程的创建、调度和销毁。

  • 线程调度:
    • JVM使用操作系统的线程调度机制来管理Java线程。
    • 线程的状态包括新建、就绪、运行、阻塞、等待和终止。
  • 同步和并发:
    • JVM提供了关键字synchronizedvolatile,以及java.util.concurrent包,支持多线程编程和并发控制。

方法调用和返回

执行引擎负责处理Java方法的调用和返回,包括静态方法、实例方法、构造方法等。

  • 方法调用:
    • 静态绑定:在编译时确定调用的方法(如静态方法和私有方法)。
    • 动态绑定:在运行时根据对象的实际类型确定调用的方法(如实例方法)。
  • 方法返回:处理方法的返回值和返回指令,管理方法调用栈帧的创建和销毁。

异常处理

执行引擎处理Java程序中的异常,包括捕获和抛出异常。

  • 异常捕获:使用try-catch块捕获异常。
  • 异常抛出:使用throw语句抛出异常。
  • 异常处理机制:遍历调用栈,查找匹配的异常处理器。

本地方法调用

JVM执行引擎通过本地方法接口(JNI)调用本地代码,实现与平台相关的功能。

  • JNI(Java Native Interface):允许Java程序调用本地C/C++代码。
  • 本地方法库:加载和执行本地方法库(如.dll.so文件)。

总结

JVM字节码执行引擎主要包括以下功能:

  1. 解释执行字节码:逐条解释和执行字节码指令。
  2. JIT编译:将热点代码编译为本地机器码,提高执行效率。
  3. 垃圾回收:自动管理内存,回收不再使用的对象。
  4. 线程管理:管理Java线程的生命周期和调度。
  5. 方法调用和返回:处理方法的调用、执行和返回。
  6. 异常处理:捕获和处理Java异常。
  7. 本地方法调用:通过JNI调用本地代码。

参考