3.1 JVM 工作原理

1. JVM 工作原理

工作原理:是将编译的.class代码(字节码)翻译成底层操作系统可以运行的机器指令并且进行调用执行,这也是Java程序能够"一次编写,到处运行"的原因,因为它会根据特定的操作系统生成对应的操作指令。

Java程序运行流程:

  1. 编译器会首先把Java代码文件(.java)转化成字节码文件(.class);
  2. 然后通过类加载器将字节码文件加载到内存中,但字节码并不是可以直接交给操作系统执行的机器码,而是一套 JVM 的指令集;
  3. 这个时候需要使用特定的命令解析器也就是我们俗称的"执行引擎(Execution Engine)"将字节码翻译成可以被底层操作系统执行的指令再去执行。

这样就实现了整个 Java 程序的运行,这也是 JVM 的整体执行流程。

所以,JVM 主要是由三个核心部分协同工作:类加载子系统、运行时数据区、执行引擎。

2. 类加载子系统

(1)类加载生命周期

类加载负责把 .class 文件变成 JVM 内部的类信息,完整过程包含加载(Loading)、链接(Linking:验证、准备、解析)和初始化(Initialization)的全过程。生命周期通常分为:

  • 加载:通过类的全限定名找到字节码,读入 JVM,并在方法区生成对应的 Class 对象;

  • 验证:检查字节码格式、类型安全、控制流是否合法,比如:防止恶意代码破坏 JVM。验证阶段大致上会完成四个阶段的检验动作:文件格式验证、元数据验证、字节码验证、符号引用验证。

  • 准备:为静态变量分配内存并设置默认值,比如:int 默认为0。这里面仅包含static变量,而不包含实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。

  • 解析:将常量池内的符号引用替换为直接引用;

  • 初始化:执行静态代码块和静态变量赋值,也就是执行类构造器 <clinit>() 方法。JVM 就正式开始执行类中编写的 Java 业务代码,先初始化父类,后初始化子类,不要在静态块中抛出异常,否则会导致类初始化失败。

以一个具体的例子串起整个类加载过程,例如:执行People p = new People();,因为要用到 People 类,触发加载:

  • 加载:通过全限定名找到 People.class,通过流将字节码读入内存,创建 People 的 Class 对象。
  • 验证:检查 People 字节码规范与安全性。
  • 准备:为静态变量(例如:static String people)分配内存,并赋默认值 null。
  • 解析:把成员 name 和 address 前的符号引用替换成 String 类的直接引用。 当我们加载 People 类时,它的成员可能使用其他类,例如 String name;,这里的 String 是一个引用类型,而在最初加载 People 时,String 类可能还没被加载或不知道具体位置,于是先用一个"符号"占位,这就叫符号引用。等到解析阶段,JVM 会真正去查找 String 类的 Class 对象在内存中的地址,然后把占位符号替换成实际的地址(直接引用)。这样一来,后续访问就能直接定位了。
  • 初始化:为静态变量显式赋值,并执行静态代码块。例如:static String people = "小明"; 在准备阶段 people 被初始化为 null,到了初始化阶段,people 才被赋值为 "小明",覆盖默认值。
(2)类加载器

在加载阶段是由类加载器完成,把 .class 字节码读进来,并交给 JVM 定义成一个类。类加载器可以说是Java语言的一项创新,也是Java语言流行的重要原因之一。常见类加载器包括:

  • 启动类加载器(Bootstrap ClassLoader):加载核心类库;它在 JVM 启动时自动运行,是最顶层的加载器。

  • 平台/扩展类加载器(Platform ClassLoader):加载平台模块或扩展库;比如:加载 JDK 内部的一些特殊模块(如 java.sql、java.xml 等)。

  • 系统类/应用类加载器(System / Application ClassLoader):加载 classpath 下的业务类;我们自己编写的类通常都由它加载。

  • 自定义类加载器:开发者继承 ClassLoader 实现。用户自定义的类加载器,可加载指定路径的class文件。

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

双亲委派模型的工作过程是:

  1. 如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中;
  2. 只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。
  3. 如果仍然找不到,抛出ClassNotFound异常。
  4. 先加载JDK中的类,再加载用户的类。

使用双亲委派模型来组织类加载器之间的关系,有一个显而易见的好处就是Java类随着它的类加载器一起具备了一种带有优先级的层次关系。例如:类javalang.Object它存放在可rt.jar之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都是同一个类。相反,如果没有使用双亲委派模型,由各个类加载器自行去加载的话,系统中将会出现多个不同的Object类,应用程序将会变得一片混乱。

(3)代码演示类加载器

代码目录结构:

bash 复制代码
myclassloader/
└── src/
    └── com/test/myclassloader/
        └── ClassLoaderDemoTest.java 

获取各级类加载器:

csharp 复制代码
package com.test.myclassloader;

/**
 * 层级关系(从下到上):
 * 系统/应用类加载器 (AppClassLoader) -> 平台/扩展类加载器 (PlatformClassLoader) -> 启动类加载器 (BootstrapClassLoader)
 */
public class ClassLoaderDemoTest {

    public static void main(String[] args) {
        // 1. 获取系统/应用类加载器(Application ClassLoader)
        // 负责加载用户类路径(ClassPath)下的类,比如我们自己写的类
        ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();

        // 2. 获取系统类加载器的父加载器 ------ 平台/扩展类加载器(Platform ClassLoader)
        // 负责加载 Java SE 平台中除了 java.base 之外的模块(JDK 9 之前叫扩展类加载器 ExtClassLoader)
        ClassLoader platformClassLoader = systemClassLoader.getParent();

        // 3. 获取平台类加载器的父加载器 ------ 启动类加载器(Bootstrap ClassLoader)
        // 由 C++ 实现,内嵌在 JVM 中,负责加载核心类库(如 java.lang.*)
        ClassLoader bootstrapClassLoader = platformClassLoader.getParent();
        
        
        System.out.println("系统/应用类加载器 (AppClassLoader) : " + systemClassLoader);
        System.out.println("平台/扩展类加载器 (PlatformClassLoader): " + platformClassLoader);
        System.out.println("启动类加载器 (BootstrapClassLoader): " + bootstrapClassLoader);

    
        // 我们可以通过尝试获取核心类(如 String)的加载器来侧面验证。
        System.out.println("--------------------------------------------------");
        try {
            // String 是核心类,由启动类加载器加载
            // 输出 null,证明加载它的是启动类加载器
            Class<?> stringClass = Class.forName("java.lang.String");
            System.out.println("java.lang.String 的类加载器: " + stringClass.getClassLoader());
            
            // 我们自己写的类,由系统类加载器加载
            // 输出 AppClassLoader,证明加载它的是系统/应用类加载器
            Class<?> myClass = Class.forName("com.test.myclassloader.ClassLoaderDemoTest");
            System.out.println("ClassLoaderDemoTest 的类加载器: " + myClass.getClassLoader());
            
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
    }
}

运行结果:

kotlin 复制代码
系统/应用类加载器 (AppClassLoader) : jdk.internal.loader.ClassLoaders$AppClassLoader@531d72ca
平台/扩展类加载器 (PlatformClassLoader): jdk.internal.loader.ClassLoaders$PlatformClassLoader@7a4f0f29
启动类加载器 (BootstrapClassLoader): null
--------------------------------------------------
java.lang.String 的类加载器: null
ClassLoaderDemoTest 的类加载器: jdk.internal.loader.ClassLoaders$AppClassLoader@531d72ca

验证结论:为什么启动类加载器打印出来是 null?因为 BootstrapClassLoader 在 Java 层面没有对应的对象实例,因此输出 null。

3. 运行时数据区

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据域。这些区域都有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而存在,有些区域则依赖用户线程的启动和结束而建立和销毁。

(1)方法区

方法区(Method Area)用于存储已被虚拟机加载的类信息、常量、静态变量、编译后的class文件 (即类的信息,包含类的属性、成员变量、构造函数等)、代码缓存等。

运行时常量池(Runtime Constant Pool)是方法区的一部分。class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期间生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时量池中存放。

对于运行时常量池,Java虚拟机规范没有做任何细节的要求,不同的提供商实现的虚拟机可以按照自己的需要来实现这个内存区域。不过,一般来说,除了保存class文件中描述的符号引用外,还会把翻译出来的直接引用也存储在运行时常量池中。

JDK 8 之后把永生代换成的元空间,把字符串常量池从方法区移到了Java堆上。

对于习惯在HotSpot虚拟机上部署程序的开发者来说,很多人都更愿意把方法区称为"永久代"(Permanent Generation),本质上两者并不等价,仅仅是因为HotSpot虚拟机的设计团队选择把GC分代收集扩展至方法区,或者说使用永久代来实现方法区而已。这样HotSpot的垃圾收集器可以像管理Java堆一样管理这部分内存,能够省去专门为方法区编写内存管理代码的工作。

对于其他虚拟机(如BEAJRockit、IBM J9 等)来说是不存在永久代的概念的。Java 8之后,移除了永久代,取而代之的是元空间,这就是方法区的另外一种实现,更加灵活了。

根据Java虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出 java.lang.OutOfMemoryError: PremGen Space 异常。PremGen Space就是方法区的意思。运行时常量池和永久代是方法区的一部分,自然要受到方法区内存的限制,当无法再申请到内存时,也会抛出 OutOfMemoryError 的异常。

对于元空间metaspace的空间不足,也会抛出 java.lang.OutOfMemoryError: Metaspace 异常。

(2)堆

对于大多数应用来说,Java堆(Java Heap)是Java虚拟机所管理的内存中最大的一块。堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例 ,几乎所有的对象实例都在这里分配内存。这一点在Java虚拟机规范中的描述是:所有的对象实例以及数组都要在堆上分配。

Java堆也是垃圾收集器管理的主要区域,因此很多时候也被称做"GC堆"(Garbage Collected Heap)。从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以Java堆中还可以细分为:新生代和老年代 ;再细致一点的有Eden空间、From Survivor空间、To Survivor空间等。从内存分配的角度来看,线程共享的Java堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。不过无论如何划分,都与存放内容无关,无论哪个区域,存储的都仍然是对象实例,进一步划分的目的是为了更好地回收内存,或者更快地分配内存。

新生代------> Eden空间:对象刚被创建时,绝大多数都会优先分配到这里(大对象除外)。

新生代------> Survivor 空间:分为 From 和 To 两块大小相等的区域。

  • 第一次 GC(Minor GC):当 Eden 区满时,触发新生代 GC。垃圾回收器会扫描 Eden 和 From Survivor 区,把仍然存活的对象复制到 To Survivor 区(同时将对象年龄加 1)。
  • 清空与角色互换:复制完成后,Eden 和刚才的 From Survivor 区被完全清空。此时,原来的 To Survivor 变成了下一次 GC 时的 From Survivor,而原来的 From 区变成了 To 区。(这两块区域是动态互为 From 和 To 的)。

老年代 :用于存放经过多次垃圾回收后依然存活的对象,以及大对象。对象在 Survivor 区中每熬过一次 Minor GC,年龄就增加 1 岁。当年龄达到阈值(默认是 15 岁,可通过-XX:MaxTenuringThreshold设置),该对象就会被移动到老年代。

根据Java虚拟机规范的规定,Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,就像我们的磁盘空间一样。在实现时,既可以实现成固定大小的,也可以是可扩展的,不过当前主流的虚拟机都是按照可扩展来实现的(通过-Xmx和-Xms控制)。

堆大小的值可通过-Xms和-Xmx来设置(设置最小值和最大值),当堆超过最大值时就会抛出 java.lang.OutOfMemoryError:Java heap space 异常。

(3)虚拟机栈

Java虚拟机栈(Java Virtual Machine Stacks)生命周期与线程相同。线程执行Java方法时都会创建一个栈,方法调用完成时自动清理。

虚拟机栈描述的是Java方法执行的内存模型:每个方法被执行时就会同步创建一个栈帧(Stack Framee),用于存储局部变量 、内容参数 、返回地址 、操作数栈 、动态链接 、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

通常说的 "Java栈" 所指的就是现在讲的虚拟机栈,或者说是虚拟机栈中局部变量表部分。局部变量表存放了编译期间的各种基本数据类型 (boolean、byte、char、short、intfoat、long、double)、对象引用 (reference 类型,它不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)和 retumAddress 类型(指向了一条字节码指令的地址)。

局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。栈内存不涉及垃圾回收。

在Java虚拟机规范中,对这个区域规定了两种异常状况:

  • 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 java.lang.StackOverflowError 异常;
  • 如果虚拟机栈可以动态扩展(当前大部分的Java虚拟机都可动态扩展,只不过Java虚拟机规范中也允许固定长度的虚拟机栈),如果扩展时无法申请到足够的内存,就会抛出 java.lang.OutOfMemoryError: unable to create native thread 异常。
(4)本地方法栈

本地方法栈(Native Method Stack)与虚拟机栈所发挥的作用是非常相似的,区别在于本地方法栈则为虚拟机使用到的 Native 方法服务。

在虚拟机规范中对本地方法栈中方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。甚至有的虚拟机(譬如SunHotSpot虚拟机)直接就把本地方法栈和虚拟机栈合二为一。

与虚拟机栈一样,本地方法栈区域也会抛出 StackOverflowError 和 OutOfMemoryError 异常。

(5)程序计数器

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

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

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

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

4. 执行引擎

执行引擎是Java虚拟机最核心的组成部分之一。虚拟机的执行引擎是由自己实现的,因此可以自行制定指令集与执行引擎的结构体系,并且能够执行那些不被硬件直接支持的指令集格式。

在不同的虚拟机实现里面,执行引擎在执行Java代码的时候可能会有解释执行 (通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择,也可能两者兼备,甚至还可能会包含几个不同级别的编译器执行引擎。所有的Java虚拟机的执行引擎逻辑都是一致的:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果。

执行引擎主要由两部分组成:

  • 解释器(Interpreter):逐条读取并执行字节码,方法第一次被调用通常由解释器执行。它的优势是启动快,无需等待编译,但执行效率相对较低。
  • 即时编译器(JIT Compiler):将频繁执行的 "热点代码" 编译为本地机器码。当某段代码执行频率超过阈值后,JVM 会认为它是 "热点代码",交给 JIT 编译器进行编译。编译后的代码执行效率远高于解释执行,但编译本身需要时间和资源。

在 HotSpot 虚拟机中,它并不是单纯解释执行,而是以 "解释器 + 即时编译器(JIT) " 的混合模式运行字节码,并通过分层编译策略在启动速度与峰值性能之间取得平衡。

HotSpot采用分层编译策略,将解释执行与多个编译层级结合(JDK 8 以后默认开启的分层编译,把执行过程分成多个层级),以同时获得快速启动和高峰值性能。如下:

层级 执行方式 说明
Tier 0 解释执行 初始状态,启动快
Tier 1 C1 无 profile 简单优化,执行速度较快
Tier 2 C1 轻量 profile 采集基础执行次数信息
Tier 3 C1 完整 profile 采集详细运行时数据
Tier 4 C2 深度优化 生成高质量本地代码
  • 第1-3层:由C1编译器(客户端编译器)处理。C1编译速度快、优化保守,适合快速将代码从解释执行提升到编译执行,并收集更详细的性能剖析数据。
  • 第4层:由C2编译器(服务端编译器)处理。C2会进行激进、耗时的优化,生成高度优化的机器码,追求极致性能。

执行流程如下:

JIT 编译后的本地代码会保存在 Code Cache 中,后续调用时直接执行编译结果,不再重复解释。

参考文献:

  1. 周志明,《深入理解Java虚拟机》,机械工业出版社。
相关推荐
Java内核笔记1 小时前
同一份知识库,4 种切分 × 3 种召回,纯关键词比纯向量高 13 个点
java·人工智能·spring boot
海岳云舟1 小时前
springboot单元测试技术
java·后端·spring
众链网络2 小时前
年卡跨淡旺季的有效期模型:不可用日期、次数与顺延规则
java·前端·数据库
文丑颜不良啊2 小时前
LeetCode926~950题目解析
java·leetcode
今天的砖头有点烫手啊2 小时前
Java 对象内存布局:压缩指针、对齐填充与 JOL 实测
java·jvm
devilnumber2 小时前
Lambda 表达式(Java)优缺点清晰分析
java·开发语言·python
时空节拍AI数字人2 小时前
博物馆数字人讲解系统设计:知识库 schema、展项联动协议与时序控制
java·服务器·前端·人工智能·ai·语音识别
GG.WinGel2 小时前
三元组中心问题
java·数据结构·算法·青少年编程·蓝桥杯
vailee2 小时前
42 万字符的工具输出压进 6,945 token:Agent 长会话的上下文治理实践
java·大数据·人工智能·prompt工程·rag·上下文管理