Skip to content
Charles Shao
Go back

JVM 深入(一):内存架构与类加载 —— 从字节码到对象的一生

–views

你按下运行键,控制台跳出 Hello, World。这一刻究竟发生了什么?我们都知道编译器会把 .java 变成 .class 字节码文件,但这串冷冰冰的字节码是如何最终转化为屏幕上的输出的?

答案是:在你的操作系统之上,正运行着一个专属的 JVM(Java Virtual Machine)进程替你执行它。我们在排查线上 Full GC 停顿或者服务刚启动时的 CPU 毛刺时,归根结底都是在和这个进程打交道。因此,理解 JVM,本质上就是搞清楚这个字节码执行引擎内部是如何运转的、运行时数据如何分配、以及什么时候会遇到瓶颈或故障。

本文是 JVM 系列理论三篇的首篇。在探讨复杂的 GC 算法或 JIT 调优之前,我们必须先打牢底层地基,梳理清楚贯穿本篇的核心主线:一份字节码如何在分区后的运行时环境里,最终变成一个堆上对象。只有弄懂了内存在底层是如何按职责分区的、方法调用时栈帧怎么流转,以及一行 new 背后发生了什么,后续的垃圾回收与性能剖析才有讨论的基石。

本文是 JVM 系列的第 1 篇(共 6 篇)。 全系列:

  1. 入门篇 · 内存架构与类加载(本篇)
  2. 核心篇 · 垃圾回收全解
  3. 进阶篇 · JIT、内存模型与调优
  4. JDK 版本差异精讲(8 → 25)
  5. Grafana JVM 监控分析
  6. Arthas 诊断

一句话定位:JVM 作为一个运行在操作系统上的普通进程,通过类加载器将统一的字节码加载到方法区,利用基于操作数栈的执行引擎运行指令,并在按职责划分的运行时数据区中管理对象的生命周期。

TL;DR

Table of contents

Open Table of contents

一、为什么需要 JVM:跨平台的字节码执行引擎

1.1 统一字节码与本地翻译

过去,如果我们想让一段程序在 Windows、Linux 和 macOS 上都能跑,通常需要针对不同操作系统的底层指令集分别编译。而 Java 走了一条更具工程化思维的路:源代码只编译一次,生成统一的中间格式,再交由各个平台专属的底层进程去对接本地系统。

这个在底层兜底的进程就是 JVM。你的 Java 代码经编译器编译为标准的 .class 字节码后,各平台的 JVM 就会接手后续工作,将这套跨平台的指令集逐条翻译成当前宿主机的机器指令:

平台JVM 做的事
Windows各平台 JVM 把统一字节码翻译成本地机器指令
Linux各平台 JVM 把统一字节码翻译成本地机器指令
macOS各平台 JVM 把统一字节码翻译成本地机器指令

跨平台翻译示意图:一份 Hello.java 源码经 javac 编译成统一的 Hello.class 字节码,再分别交给 Windows / Linux / macOS 三个平台上的 JVM,各自翻译成对应平台的机器指令 一份源码编译成统一字节码,各平台的 JVM 各自把它翻译成本地机器指令。

一句话本质

Java 那句口号 「Write Once, Run Anywhere」 的真相 —— 不是 Java 代码本身拥有了跨平台的神奇能力,而是每个平台都有一个专门的 JVM 进程在替它掩盖底层的操作系统差异。

1.2 JVM 到底是什么

剥开所有花哨的修饰,JVM 本质上就是一个运行在操作系统上的普通进程。它和你在服务器上跑的 Nginx 或 MySQL 进程没有绝对的区别,其核心工作就是读取 .class 文件,并按文件中的指令集逐条执行。

正是为了高效地执行这些指令,JVM 向操作系统申请了一大块内存并自行接管。在这个高度封装的执行环境里,它内置了负责运算的字节码执行引擎,并维护着一套严密的类元数据体系。你的应用代码,本质上就是在这样一个隔离的运行时闭环中流转。顺着这条主线,接下来我们就能顺理成章地拆解这个环境内部究竟是如何划分职责的。

二、运行在操作系统上的 JVM 进程:运行时数据区

既然 JVM 是一个独立的运行时环境,它拿到操作系统批给的物理内存后,必然需要进行内部的规划。为了兼顾执行引擎的运算效率与多线程的并发安全,JVM 规范将这块内存严格按职责划分为若干个运行时数据区。

为什么要分区?

一线排障时我们常说「栈管运行,堆管存储」。不同数据在 JVM 中的生命周期有着天壤之别:那些被频繁创建和回收的业务对象需要集中的管理策略,而单条线程的方法调用状态则必须严格私有隔离。运行时数据区按职责划分(线程共享 vs 线程私有),正是为了让后续的垃圾回收和指令流转能够各自高效运作,互不掣肘。

JVM 运行时数据区分区图:分为线程共享区(堆 Heap 存放 new 出来的对象、方法区 / 元空间存放类定义 · 静态变量 · 常量池)与线程私有区(虚拟机栈存放栈帧与局部变量、本地方法栈服务 native 方法、程序计数器记录当前执行位置) 运行时数据区分成线程共享(堆、方法区)与线程私有(虚拟机栈、本地方法栈、程序计数器)两大类。

2.1 一行代码经过的所有区域

我们来看一段简单的代码,跟随它走一遍内存的流转:

public void greet() {
    String name = "Alice";
    User user = new User(name);
    System.out.println(user);
}

当这几行代码在生产环境中被调用时,JVM 的内部区域是这样配合协作的:

  1. greet 方法本身的元信息(比如它的字节码指令、参数表结构等)—— 早就被加载并存放在了方法区里;
  2. 随着请求线程执行到这个方法,JVM 就会为其分配一个专用的栈帧(Stack Frame)—— 并压入当前线程的虚拟机栈;
  3. 局部变量 name 与 user 的引用——被妥善存储在刚刚压入的那个栈帧的局部变量表内;
  4. 顺着引用找过去,new 出来的真正 User 对象实例——其实是被分配在了堆的年轻代中;
  5. 至于当前这套复杂的逻辑到底执行到了哪一条底层指令——则被实时记录在当前线程的程序计数器里。

一行代码经过的内存区域:greet() 方法的字节码与参数表存在方法区,方法调用的栈帧与局部变量 name / user 引用存在虚拟机栈,new User(name) 创建的真实对象存在堆里(栈帧里的引用指向堆中的对象),当前执行的字节码行号存在程序计数器 一次方法调用会同时用到方法区、虚拟机栈、堆与程序计数器;栈帧里的引用指向堆里的真实对象。

机制虽然繁杂,但只要把上面这五步串起来看,代码在底层的映射逻辑就变得非常清晰。

2.2 堆(Heap):对象的归宿

核心结论

所有通过 new 创建出来的对象实例,统统分配在堆里。

无论是业务代码里的 new User()、集合容器 new ArrayList<>() 还是一个普通的 new int[100] 数组,只要是动态实例化的对象,它们最终的归宿都在堆内存中。

为什么要费力气把对象统一圈在一个大池子里管理?因为在任何一个高并发应用中,对象都是数量最庞大、生命周期差异也最为极端的群体。专门划分出堆这块最大的共享区域,正是为了让后续的分代垃圾回收(GC)机制能够施展拳脚。但需要强调的是,正因为堆是所有线程共享的,多线程同时在堆上竞争内存时极易引发瓶颈。为了扛住高并发分配,JVM 引入了 TLAB(线程本地分配缓冲区)等机制,让每个线程先在堆内圈一小块私有空间来打底。

对象在堆里的布局:对象头

我们在堆里存放的对象,绝不仅仅是几个业务字段的简单集合。为了维持运行时的状态管理,每个对象都附带了极其重要的元数据:

  • 对象头(Object Header):这部分是 JVM 的重点关注对象。它的一半是 Mark Word,实时记录着对象的哈希码、当前的锁状态以及用于 GC 的年龄计数器(下一篇里决定对象能不能晋升老年代,读的就是这里的比特位);另一半则是类型指针,指向方法区里的类元数据,用于在运行时证明「我是谁」。
  • 实例数据:你在代码中定义的各个字段的真实数值。
  • 对齐填充:纯粹为了迎合 64 位 CPU 的高效读取机制,将整个对象的大小强制补齐到 8 字节的整数倍。

明白了这个布局,你就会发现:哪怕只是一个没有任何字段的纯 new Object(),在开启压缩指针的 64 位线上机器里,也会雷打不动地占据 16 字节(对象头 + 对齐填充)。

眼见为实:我们可以借助 OpenJDK 官方的 jol(Java Object Layout)工具,直接把一个新建对象的真实内存布局给刨出来:

java.lang.Object object internals:
OFF  SZ   TYPE DESCRIPTION               VALUE
  0   8        (object header: mark)     0x0000000000000001 (non-biasable; age: 0)
  8   4        (object header: class)    0x00001000
 12   4        (object alignment gap)
Instance size: 16 bytes

一眼就能验证:Mark Word 占据 8 字节,类型指针占据 4 字节,再加上最后的对齐填充 4 字节,加起来正好 16 字节。顺便留意一下第一行末尾那个 age: 0 —— 它就是对象朝生夕灭过程中的计时器,决定着这个对象在后续 GC 洪流中的命运。

2.3 栈(Stack):方法调用的生命周期

比起堆里的对象,方法的执行轨迹则完全是另一套逻辑。每当你的代码发生一次方法调用,JVM 就会在当前线程的虚拟机栈中分配并压入一个栈帧(Stack Frame)。你可以把它当成这个方法在运行时独享的上下文容器,里面完整装载了执行所需的全部弹药:

一旦方法执行结束(无论是正常 return 还是抛出未捕获的异常),对应的栈帧就会被利落地弹出并销毁。正因为虚拟机栈是严格线程私有的,各个请求线程之间的栈空间泾渭分明,所以只要局部变量没有发生引用逃逸,它们在多线程环境下天然就是绝对安全的。

两个经典异常(附一处常见误解的澄清)

  • StackOverflowError:通常发生在糟糕的无限递归中。因为每一次方法调用都会压入新栈帧,当深度击穿了栈的容量上限,就会抛出此异常。
  • OutOfMemoryError:这里需要特别注意,HotSpot 虚拟机的线程栈大小是固定的(在线上通常由 -Xss 参数限制,并不会动态扩容)。单纯的方法调用过深只会爆栈,而真正因栈引发 OOM 的场景,往往是并发过高导致创建了海量线程,使得操作系统耗尽了物理内存而无法再分配新的线程栈,此时抛出的是致命的 unable to create new native thread。

(提示:部分早期教程提到「栈动态扩容失败导致 OOM」,那是 JVM 规范层面允许的理论实现,但主流的 HotSpot 并未采用这一机制,排障时切勿刻舟求剑。)

2.4 一眼看懂字节码:操作数栈是如何计算的

前面反复提到 JVM 是一台字节码执行引擎,那么这台引擎真正吃进去的指令到底长什么样?我们可以写一个最基础的加法方法:

int add() {
    int a = 1;
    int b = 2;
    return a + b;
}

通过 javac 编译后,用自带的 javap -c 顺手反编译一下,就能在控制台清晰地看到底层指令流:

int add();
  Code:
     0: iconst_1     // 把常量 1 压入操作数栈
     1: istore_1     // 弹出栈顶,存入局部变量表 slot 1(即变量 a)
     2: iconst_2     // 把常量 2 压入操作数栈
     3: istore_2     // 弹出栈顶,存入 slot 2(即变量 b)
     4: iload_1      // 把 a 的值读回操作数栈
     5: iload_2      // 把 b 的值读回操作数栈
     6: iadd         // 弹出栈顶两个操作数,相加后结果压回栈顶
     7: ireturn      // 返回操作数栈顶的 int 值

这里揭示了 JVM 架构设计中极为核心的一环:它是一台基于操作数栈的虚拟机。相比于 C/C++ 依赖物理 CPU 寄存器(如 eax、rbx)的设计,JVM 选择用操作数栈的压栈与弹栈来统一所有的底层运算操作。对照着上面的指令,我们来推演一下局部变量表与操作数栈是如何左右互搏的:

指令        局部变量表 [slot1, slot2]     操作数栈(自底向上)
iconst_1    [  - ,  - ]                  [ 1 ]
istore_1    [  1 ,  - ]                  [ ]
iconst_2    [  1 ,  - ]                  [ 2 ]
istore_2    [  1 ,  2 ]                  [ ]
iload_1     [  1 ,  2 ]                  [ 1 ]
iload_2     [  1 ,  2 ]                  [ 1 , 2 ]
iadd        [  1 ,  2 ]                  [ 3 ]          ← 弹出 1、2,压回 3
ireturn     [  1 ,  2 ]                  [ ]            → 把 3 返回给调用者

为什么要懂这一层

看懂这一段,绝不是为了炫技,而是为了打通系统优化的认知闭环:

  • 借助指令流转,原本抽象的局部变量表与操作数栈瞬间变成了切实可见的内存交互。
  • 后续所有进阶的技术细节均建立在字节码之上:比如第三篇要讲的 JIT 编译器,其优化动作的输入源正是这批字节码;日常提到「方法内联」性能极高,正是因为它粗暴地省去了这里分配栈帧和参数来回压栈的巨大开销;而多线程里用的 synchronized,底层也不过是映射为 monitorenter 和 monitorexit 字节码。只有看透了这层基础指令的起承转合,你才算真正理解 JVM 到底在优化什么。

2.5 方法区:类的元数据中心

了解了对象的存活地和方法的执行场,接下来还有一个关键问题:当 JVM 执行到具体的对象方法时,它是去哪里读取 User 这个类的字段定义、方法签名以及刚才我们看到的那串字节码指令的?这些描述类本身属性的元数据,统一被安放在了方法区中。

在排查堆内存泄漏时,请务必在脑海中把这两个维度切分开来:

永久代 → 元空间

如果你接手过古董级的 JDK 8 以前的项目,可能还对 OOM: PermGen space 心有余悸。早期 HotSpot 采用「永久代(PermGen)」来实现方法区,但这块内存在遇到大量使用动态代理生成类的场景(如 Spring/CGLib)时极易被打满。因此,从 JDK 8 开始,JVM 团队做出了破坏性变更,用直接占用本地机器内存的「元空间(Metaspace)」彻底替换了永久代。只要物理内存没爆,元空间默认就不会设限,这极大地缓解了类元数据溢出的顽疾。

方法区内还有一块常常出现在面试题里的重点区域——字符串常量池。它正是解释无数人栽过跟头的 == 比较题的底层原理:

String a = "hello";
String b = "hello";
String c = new String("hello");
System.out.println(a == b);  // true
System.out.println(a == c);  // false

为什么结果不同

  • 业务代码中直接写出的字面量 "hello" 会被 JVM 自动放入字符串常量池。为了节省内存,相同内容的字面量只会保留一份全局实例,因此 a 和 b 实际引用了同一个对象内存地址,故 a == b 为 true。
  • 而遇到 new String("hello") 时,new 指令会无视常量池,强行在堆中单独划出一块全新内存来分配实例。既然地址截然不同,a == c 必然是 false(这也提醒我们,排查业务 Bug 时,判断字符串内容相等必须严谨地使用 equals)。

补充一条冷知识:为了防止常量池把永久代撑爆并提高回收效率,早在 JDK 7 时期,字符串常量池就已经被剥离出来,挪进了堆内存之中。

顺带理解「堆」与「非堆」

刚才提到的方法区,在系统监控中属于典型的**非堆(Non-Heap)**内存。线上排障时面对看板上密密麻麻的指标,很多人会困惑为什么要对内存做这种二元切分。其实,一切都归结于数据生命周期的天差地别:

堆存放的是短命且频繁分配的业务对象;而非堆存放的是 JVM 自身运转所依赖的、生命周期长且极其稳定的底层结构。

这两种内存的流动速度根本不在一个量级。堆每天要承受几十上百 GB 对象的疯狂创建与回收,绝大多数业务对象往往朝生夕灭;而像类元数据这样的非堆结构,一旦加载进内存,通常会稳稳存活到整个服务停机,JIT 预热编译出的机器码更是越跑越熟练。如果把这些长驻数据混入堆中,不仅会无谓地增加 GC 扫描的负担,还会干扰停顿时间的预判。

非堆区域除了元空间,还包含了线上性能的核心组件:

非堆区域装什么
Metaspace(元空间)类的元信息:字段、方法、字节码 —— 就是这里讲的方法区
Code Cache(代码缓存)JIT 把热点代码编译出的本地机器码(见第三篇)
其他压缩类空间、符号表、字符串表等 JVM 内部结构

生命周期呈现的监控特征

正因为存活特性的鸿沟,在第五篇我们要实战解读的 Grafana 看板上,这两类内存的曲线长得完全不一样:堆里的对象不断被 new 出来再被 GC 收割,使用率曲线必定呈现出规律的锯齿状;而元数据与 Code Cache 等非堆内存,在应用启动经历一小段爬坡后,就会接近绝对水平,只有在发布新代码或触发极端的动态类生成时才会出现台阶式增长。(等到第三篇,我们会把堆与非堆各自会抛出什么 OOM 画成一张全景地图。)

2.6 程序计数器和本地方法栈

除了堆、栈、方法区这三大块,还有两个体量较小但不可或缺的区域:

2.7 一张表记住所有分区

区域谁住在这里谁能访问会不会 OOM
堆new 出来的对象实例所有线程会(Java heap space)
虚拟机栈栈帧、局部变量表、操作数栈当前线程栈太深是 StackOverflowError;线程太多才 OOM
方法区 / 元空间类元数据、静态变量、常量池所有线程会(Metaspace)
程序计数器当前执行的字节码指令位置当前线程不会
本地方法栈native 方法调用的状态当前线程会

三、对象的一生:new 指令背后的分配流程

理清了宏观的内存分区,现在我们把镜头拉近,聚焦到那句每天都在敲的 new User() 上。在虚拟机底层,它绝不是凭空变出了一块内存,而是经历了一套极度严密的流水线装配流程。

对象创建流程:遇到 new 指令后先检查类是否已加载(没加载则先走类加载的加验准解初),然后分配内存(指针碰撞 / 空闲列表,优先走 TLAB),接着给实例字段赋零值、设置对象头(Mark Word 与类型指针),最后执行构造器 init() 按代码赋真实值,对象才可用 一行 new 背后是「类加载检查 → 分配内存 → 赋零值 → 设对象头 → 执行构造器」五步。

当代码流转到 new 这一行指令时,底层经历了这不可逆转的五步:

  1. 类加载检查:JVM 遇到 new 指令,首先会去元数据区查一下:这个对应的类是不是已经被加载并解析妥当了?如果没查到,那就必须紧急刹车,先去走完底层的类加载流程(也就是第四章要讲的重头戏)。
  2. 分配内存:确定类没问题后,JVM 就在堆内存里给这个新生对象划出一块确定大小的领地。如果当前这代堆内存是规整连贯的(得益于带有压缩整理机制的 GC),JVM 会粗暴而高效地采用「指针碰撞」,直接把分配指针往后平移;要是内存早就被踩成了千疮百孔的空间碎片,那就只能硬着头皮去查「空闲列表」了。需要特别说明的是,线上多线程并发抢夺 Eden 区内存时极易产生锁竞争,为了扛住极高的分配吞吐量,绝大多数小对象都会直接在各自线程独占的 TLAB(线程本地分配缓冲区) 内悄悄分配掉。
  3. 赋零值:成功圈出地盘后,JVM 会自动介入,把这块内存里的所有实例字段统一清零(比如 int 强塞 0,引用字段设为 null)。这一步是纯粹的安全兜底机制,它保证了哪怕业务代码里忘记给字段赋值,程序跑起来也不会读到上一个对象遗留的脏数据。
  4. 设置对象头:接下来,JVM 熟练地为这个半成品贴上身份标签。它把对象的哈希码、GC 年龄、锁标记位塞进 Mark Word,再把指向方法区的指针塞进类型指针里。至此,从 JVM 内部的视角来看,这个对象已经是一个合格的管理实体了。
  5. 执行构造器:直到这一步,底层的数据骨架才算就绪。JVM 终于把控制权交还给业务,调用原本写在 Java 代码里的 <init> 实例构造方法,把你传入的真实参数依次赋给对应的字段。到这里,一个真正在业务上可用的对象才算彻底诞生。

对象访问定位:句柄 vs 直接指针

顺着刚才的代码想一下:虚拟机栈帧里的那个局部引用变量,究竟该怎么隔空定位到堆里的真实对象?主流 JVM 通常有两种流派:

  • 句柄访问:栈里的引用并不直接指向对象实体,而是指向堆里专门开辟的一个句柄池,句柄里再分别存上对象实例和类元数据的内存地址。这个方案的好处很明显:当第二篇里的 GC 频繁把对象在新生代里搬来搬去时,对象的物理地址虽然变了,但栈上的引用却安如泰山,只要更新一下句柄内部的指针即可。
  • 直接指针:栈引用非常生猛,直接指向堆里的对象实例,然后通过对象头里的类型指针再去找元数据。这种方式省去了一次间接寻址的开销,读写速度极速飙升,所以性能至上的 HotSpot 选择的正是直接指针设计。但凡事皆有代价,既然栈直接挂在对象身上,一旦 GC 移动了对象,就必须耗费算力同步把所有指向它的引用全部更新。明白了这个权衡,你也就触碰到了下一篇中 GC 停顿背后的核心痛点之一。

四、类是如何进入 JVM 的:类加载机制

回到上一章的第一步,当代码第一次执行到 new 指令时,如果需要的类根本不在内存里,JVM 就必须去硬盘的 classpath 或者网络协议里把对应的 .class 文件抠出来。这套化无为有的过程,就是大名鼎鼎的类加载机制。

4.1 类加载的五个核心步骤

JVM 不可能生吞活剥一个字节流文件,它要把干瘪的 .class 转换成方法区里能被直接调用的运行时数据结构,必须闯过严密的五个阶段。这五个阶段有一个朗朗上口的口诀:「加验准解初」。

类加载五个步骤流程:① 加载(读 .class 进内存)→ ② 验证(检查字节码合法性)→ ③ 准备(静态变量赋默认零值)→ ④ 解析(符号引用转直接引用)→ ⑤ 初始化(执行 clinit 赋真实值) 类加载五步:加载 → 验证 → 准备 → 解析 → 初始化。

这五步步步为营,各自承担了不可或缺的底层职责:

步骤这一步解决什么问题JVM 实际做的事
① 加载将类字节流载入内存把 .class 字节流读取进来,并在方法区中构建出代表这个类的运行时数据结构
② 验证确保载入的类不危害系统安全强迫症般地校验字节码的格式与语义合法性,防止被篡改的恶意代码搞崩整个虚拟机
③ 准备为静态变量分配内存空间在方法区为静态变量分配好内存,并强制赋上默认零值(int 给 0,引用给 null)
④ 解析把抽象符号翻译成具体地址在常量池里顺藤摸瓜,把「符号引用」替换为直接指向目标内存的「直接引用」
⑤ 初始化执行静态代码块并赋予开发者定义的初始值触发类构造器 <clinit>,为静态变量赋上你在 Java 代码中指定的真实值

「符号引用」与「直接引用」的本质区别

  • 符号引用:当类还没被完全装进内存时,它只能用一串规范的字面量全称来指代目标(例如常量池里写着 com/example/User.getName:()Ljava/lang/String;)。它只是一串字符串,没有任何实际执行价值。
  • 直接引用:等目标类真正被加载到内存并安顿好之后,JVM 就能推导出它确切的内存位置。此时把那一长串字符串直接替换成内存地址指针或偏移量,调用时就能一步到位。

所谓的「解析」阶段,干的就是把抽象字符串落地成真实物理地址的苦差事。

4.2 一个容易踩坑的初始化细节

对照上面的流程表,你会发现准备和初始化阶段都在对静态变量动手脚,但它们赋进去的值有着云泥之别。这也是很多开发在排查类加载和单例并发问题时最容易陷进去的泥潭:

public class Demo {
    static int a = 10;
}

面试与排障高频陷阱

  • 准备阶段:此时 JVM 只是在打地基,仅仅为变量分配内存并强制赋予默认零值,所以这时候 a = 0。
  • 初始化阶段:直到类装载到了最后一步,开始执行专门的类构造器 <clinit> 时,才会真正把业务代码里的意图执行下去,此时 a = 10。

深刻理解这两个阶段的强制物理切割,你在看包含复杂静态块和父子类继承的类加载顺序时,就不会再产生那种「薛定谔的值」的错觉了。

4.3 类加载器与双亲委派模型

在第一步的「加载」阶段中,去把 .class 捞进内存的苦力就是类加载器(ClassLoader)。为了确保核心类库的绝对权威,并隔离开不同层级的依赖,JVM 沿用了一套极为严密的三个层级加载体系:

加载器负责加载
启动类加载器(Bootstrap)java.lang.* 等核心类库
扩展类加载器(Extension)JDK 扩展库
应用类加载器(Application)你自己写的类(classpath 上的类)

术语提示(JDK 9+)

自打 JDK 9 引入模块系统(Jigsaw)进行大刀阔斧的重构后,「扩展类加载器」这个名字就被正式更名为了平台类加载器(Platform ClassLoader),用于专门加载 JDK 中那些非核心的平台模块。不过,虽然换了名字、微调了边界,但这套委派体系的层级关系却依然如故。为了对接多数人熟悉的知识库,本文仍然沿用「扩展类加载器」的称呼。

这三种层级森严的加载器,构筑出了一道大名鼎鼎的安全屏障:双亲委派模型(Parents Delegation Model)。当最底层的应用类加载器接到去加载 java.lang.String 的请求时,它不敢擅作主张立刻干活,而是优先把请求原封不动地向上委派给父级加载器。扩展加载器收到后继续往上丢,直到顶层的启动类加载器。只有当上层的大佬们反馈说自己辖区里找不到这个类时,底下的子加载器才会认命,自己去尝试加载。

双亲委派模型流程:应用类加载器向上委派给扩展类加载器,扩展类加载器再向上委派给启动类加载器;启动类加载器能加载 java.lang.String 就自己加载,无法加载则逐层下放回扩展、应用类加载器 双亲委派:请求先一路上交到顶层,顶层能加载就加载,不能才逐层下放。

这种看似低效推诿的委派机制,恰恰是 JVM 防御恶意篡改与类库冲突的核心心智。试想一下,如果哪个糊涂虫在项目的依赖包里偷偷塞进了一个同名的 java.lang.String 假李鬼:

双亲委派的两个核心目的

  1. 保证核心类不被篡改
  2. 避免相同的类被重复加载

打破双亲委派模型的工程实践

必须澄清的是,双亲委派只是 JVM 官方推荐的一种安全模型,绝不是什么不可逾越的物理禁区。在很多复杂的企业级工程实战中,我们恰恰需要主动去打破双亲委派:

  • JDBC / SPI 机制:DriverManager 贵为核心 API(归启动类加载器管),但它在连接数据库时,却必须去加载由业务系统提供、存放在应用 classpath 下的 MySQL 驱动实现类。由于父加载器无法向下索要资源,JVM 只能被迫引入「线程上下文类加载器(Thread Context ClassLoader)」,通过这种反向委派的方式,强行加载到了底层的 SPI 实现。
  • Tomcat 等 Web 容器:为了支持在一台 Tomcat 服务器上同时运行十几个依赖版本完全不同的 Spring Boot 应用,Tomcat 为每个 Web 应用都开辟了独立的专属类加载器。它们在加载类时往往优先加载自身应用目录下的类(直接违背了「先问父亲」的铁律),以此完美实现了各个应用间的类库隔离,避免了令人抓狂的 JAR 包冲突。
  • OSGi 与热部署:为了在不停机的情况下支持模块的热插拔,它干脆抛弃了树状的层级,采用了一种极其复杂的网状加载结构,可以说是把委派体系砸得粉碎再重塑。

至此,JVM 这个庞大的字节码执行引擎的基础架构与流转闭环已经完全清晰。我们打通了内存宏观上是如何按职责划分的、方法里的代码是如何依托操作数栈运算的、一个业务对象是如何经过严密的五步被 new 出来安顿在堆里的,以及类元数据又是怎样通过双亲委派机制被安全地加载进方法区的。

既然对象已经成群结队地在堆里安了家,而堆的物理空间又总有被打满的一刻,那这些对象最终必然要面临残酷的死亡判定与垃圾回收。**那么,JVM 究竟是如何在密密麻麻的对象引用图中精准锁定那些已经失效的垃圾?又是如何在不把线上 QPS 打崩的前提下,把 STW 的停顿时间一压再压的?**这正是我们通往性能调优的下一站——核心篇 · 垃圾回收全解 要深入探讨的核心命题。

延伸阅读


–views
Share this post on:

Previous Post
JVM 深入(二):垃圾回收机制全解 —— 从可达性分析到 ZGC
Next Post
JMeter 压测实战:组件模型、线程组、CLI 执行与 HTML 报告