目录
  1. 1. 一、JVM 整体架构
  2. 2. 二、类加载子系统
    1. 2.1. 2.1 类加载的三个阶段
    2. 2.2. 2.2 类加载器的层次结构
  3. 3. 三、运行时数据区域
    1. 3.1. 3.1 程序计数器(PC Register)
    2. 3.2. 3.2 Java 虚拟机栈(Stack)
    3. 3.3. 3.3 本地方法栈(Native Method Stack)
    4. 3.4. 3.4 堆(Heap)
    5. 3.5. 3.5 方法区(Method Area / Metaspace)
  4. 4. 四、对象的创建与内存布局
    1. 4.1. 4.1 对象创建的完整流程
    2. 4.2. 4.2 对象的内存布局
    3. 4.3. 4.3 对象的访问定位
  5. 5. 五、执行引擎
    1. 5.1. 5.1 解释执行
    2. 5.2. 5.2 JIT 编译
    3. 5.3. 5.3 ART 的执行模式
  6. 6. 六、编译与执行全链路
  7. 7. 七、JVM vs ART 对比
    1. 7.1. 关键差异详解
  8. 8. 八、源码路径汇总
  9. 9. 九、常见面试题
Java进阶之深入理解JVM

一、JVM 整体架构

JVM(Java Virtual Machine)是 Java 跨平台能力的基石。它定义了 Java 程序执行的标准环境,向上为 Java 语言提供统一的运行时语义,向下屏蔽了底层操作系统和硬件差异。

JVM 规范的架构可以分为以下几个子系统:

┌─────────────────────────────────────────────────┐
│ 类加载子系统 │
│ Bootstrap CL → Extension CL → Application CL │
│ 加载 → 链接(验证/准备/解析) → 初始化 │
├─────────────────────────────────────────────────┤
│ 运行时数据区域 │
│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │ 方法区│ │ 堆 │ │虚拟机栈│ │本地方法│ │ PC │ │
│ │Method │ │ Heap │ │ Stack │ │ 栈 │ │寄存器│ │
│ │ Area │ │ │ │ │ │NMT Stack│ │ │ │
│ └──────┘ └──────┘ └──────┘ └──────┘ └──────┘ │
├─────────────────────────────────────────────────┤
│ 执行引擎 │
│ 解释器 → JIT 编译器 → 垃圾回收器 │
├─────────────────────────────────────────────────┤
│ 本地方法接口 (JNI) │
│ 本地方法库 (libc, libm, ...) │
└─────────────────────────────────────────────────┘

二、类加载子系统

2.1 类加载的三个阶段

类的生命周期分为 7 个阶段:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)、卸载(Unloading)。其中加载、验证、准备、初始化、卸载这 5 个阶段的顺序是确定的,解析阶段可能在初始化之后(为了支持动态绑定)。

加载(Loading)

  • 通过全限定名获取类的二进制字节流
  • 将字节流转换为方法区的运行时数据结构
  • 在堆中生成 java.lang.Class 对象,作为方法区类数据的入口

验证(Verification)

  • 文件格式验证:魔数 0xCAFEBABE、版本号、常量池
  • 元数据验证:是否有父类、是否继承了 final 类
  • 字节码验证:类型安全、操作数栈与局部变量表匹配
  • 符号引用验证:能否解析到实际的类、方法、字段

源码中,HotSpot 的验证逻辑在 src/hotspot/share/classfile/verifier.cpp 中,整体验证入口是 Verifier::verify()

准备(Preparation)

  • 为静态变量分配内存并赋”零值”
  • 例如 public static int value = 123; 在此阶段 value = 0(不是 123!)
  • 123 是在初始化阶段的 <clinit> 中赋值的
  • 但被 final 修饰的 static 变量(编译期常量)会在此阶段直接赋值

解析(Resolution)

  • 将常量池中的符号引用替换为直接引用
  • 包括类解析、字段解析、方法解析、接口方法解析

初始化(Initialization)

  • 执行 <clinit> 方法(静态变量赋值 + static 代码块)
  • 父类先于子类初始化
  • <clinit> 方法是线程安全的(JVM 保证同一个类的 <clinit> 只被一个线程执行)

2.2 类加载器的层次结构

JVM 使用双亲委派模型(Parent Delegation Model):

Bootstrap ClassLoader (启动类加载器)
↑ 委派
Extension ClassLoader (扩展类加载器) — JDK 9+ 改为 Platform ClassLoader
↑ 委派
Application ClassLoader (应用/系统类加载器)

工作流程

// ClassLoader.loadClass() 的核心逻辑(简化)
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// 1. 检查是否已加载
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
// 2. 委派给父加载器
if (parent != null) {
c = parent.loadClass(name, false);
} else {
// 父加载器为 null 时,尝试 Bootstrap ClassLoader
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 父加载器找不到,自己尝试
}
if (c == null) {
// 3. 自己加载
c = findClass(name);
}
}
return c;
}
}

为什么需要双亲委派?

  • 安全性:防止核心类库被篡改。例如用户自定义的 java.lang.String 不会被加载(因为 Bootstrap ClassLoader 已经加载了 rt.jar 中的 String)。
  • 唯一性:保证同一个类不会被多个 ClassLoader 重复加载,避免类型转换异常。

Android 中的 ClassLoader

Android 不使用 JVM 的类加载器模型,而是用 PathClassLoaderDexClassLoader

// Android 类加载器层次
BootClassLoader (加载 framework 类)

PathClassLoader (加载已安装的 APK 中的 DEX)

DexClassLoader (加载外部的 DEX/JAR)

Android 类加载器的源码路径:

  • libcore/dalvik/src/main/java/dalvik/system/BaseDexClassLoader.java
  • libcore/dalvik/src/main/java/dalvik/system/PathClassLoader.java
  • libcore/dalvik/src/main/java/dalvik/system/DexClassLoader.java

三、运行时数据区域

3.1 程序计数器(PC Register)

PC 寄存器是一块很小的内存空间,是线程私有的。如果线程正在执行 Java 方法,PC 记录的是当前字节码指令的地址;如果执行 native 方法,PC 为空(undefined)。PC 是 JVM 中唯一不会抛出 OutOfMemoryError 的区域。

3.2 Java 虚拟机栈(Stack)

每个线程都有一个私有的虚拟机栈。每个方法执行时创建一个栈帧(Stack Frame),栈帧中存储:

  • 局部变量表(Local Variable Table):存储方法参数和方法内局部变量。基本类型直接存值,引用类型存储指向堆的指针。slot 是局部变量表的最小单位,long 和 double 占用 2 个 slot。
  • 操作数栈(Operand Stack):JVM 指令的”工作台”。字节码指令从局部变量表加载数据到操作数栈,执行计算后再将结果压回操作数栈。
  • 动态链接(Dynamic Linking):指向运行时常量池中该栈帧所属方法的引用。
  • 方法返回地址(Return Address):方法调用后需要返回到的指令位置。

栈的深度在编译期已确定。如果线程请求的栈深度超过 JVM 允许的最大深度,抛出 StackOverflowError;如果栈可以动态扩展但无法申请到足够内存,抛出 OutOfMemoryError

3.3 本地方法栈(Native Method Stack)

与虚拟机栈类似,但服务于 native 方法(JNI 调用)。在 HotSpot 中,虚拟机栈和本地方法栈是合并在一起的。

3.4 堆(Heap)

堆是 JVM 中最大的一块内存区域,是所有线程共享的。几乎所有对象实例和数组都在堆上分配。

JVM 堆的结构(HotSpot):

Young Generation (1/3 of heap)
├── Eden (8/10 of Young)
├── Survivor0 (1/10 of Young)
└── Survivor1 (1/10 of Young)
Old Generation (2/3 of heap)

对象晋升到老年代的条件:

  1. 年龄达到 MaxTenuringThreshold(默认 15)
  2. Survivor 区中相同年龄的对象大小总和超过 Survivor 空间的一半
  3. 大对象直接进入老年代(通过 -XX:PretenureSizeThreshold 指定)

3.5 方法区(Method Area / Metaspace)

方法区存储类信息、常量、静态变量、JIT 编译后的代码缓存。在 JDK 8 之前,方法区使用永久代(PermGen)实现,位于堆中。JDK 8+ 使用元空间(Metaspace),位于本地内存中,不再受堆大小限制。

在 Android ART 中,类似的概念是:

  • Class Table:存储类的元数据(方法的 ArtMethod、字段的 ArtField)
  • Intern Table:字符串常量池
  • DexCache:解析后的常量池缓存

相关源码:art/runtime/class_linker.cc — 类的加载和元数据管理。

四、对象的创建与内存布局

4.1 对象创建的完整流程

new Object() 到堆上出现一个对象实例,经历了以下步骤:

1. 类加载检查
└→ 常量池中定位到类的符号引用 → 检查类是否已加载/解析/初始化
→ 若未加载,先执行类加载

2. 分配内存
├── 指针碰撞(Bump the Pointer)—— 适用于内存规整的收集器(Serial, ParNew)
│ 用过的内存在一侧,空闲的在另一侧,中间有一个指针作为分界
│ 分配时只需将指针向空闲侧移动对象大小的距离
└── 空闲列表(Free List)—— 适用于 CMS 这种基于 Mark-Sweep 的收集器
虚拟机维护一个列表记录可用的内存块,分配时从列表中找到足够大的块

3. 并发安全处理
├── CAS + 失败重试:对指针修改使用 Compare-And-Swap
└── TLAB(Thread Local Allocation Buffer):
每个线程在 Eden 区预分配一小块内存(默认 Eden 的 1%)
线程在该区域内使用指针碰撞分配,无需加锁
只有在 TLAB 用完时才同步分配新的 TLAB

4. 内存空间初始化(赋予零值)
将分配的内存空间全部初始化为零值(不包括对象头)
这保证了 Java 字段的默认值在未显式初始化时就是零值

5. 设置对象头
类的元数据指针、对象的 HashCode(延迟计算)、GC 分代年龄
偏向锁/轻量级锁/重量级锁的标志位

6. 执行 <init> 方法
调用构造函数,完成字段的显式初始化

4.2 对象的内存布局

一个 Java 对象在堆内存中的布局分为三部分:

┌──────────────┐
│ 对象头 │ ← Mark Word (32/64 bit) + Klass Pointer (32/64 bit)
│ (Header) │ 包含: hashCode, GC年龄, 锁状态标志, 线程持有的锁, 偏向线程ID
├──────────────┤
│ 实例数据 │ ← 各字段按顺序排列(父类字段在前)
│ (Instance │ 相同宽度的字段分配在一起(满足对齐要求)
│ Data) │ 默认顺序: longs/doubles → ints/floats → shorts/chars → bytes/booleans → oops
├──────────────┤
│ 对齐填充 │ ← 对象大小必须是 8 字节的整数倍
│ (Padding) │
└──────────────┘

Mark Word 的位布局(64位 JVM,压缩指针开启时)

锁状态 62..34 33..3 2 1 0
无锁 unused hashCode (31位) 0 GC年龄(4位) 01
偏向锁 线程ID(54位) Epoch(2位) 0 GC年龄(4位) 01
轻量级锁 指向栈中锁记录的指针 00
重量级锁 指向重量级锁的指针 10
GC标记 11

在 HotSpot 源码中,对象布局相关逻辑在 src/hotspot/share/oops/oop.hppsrc/hotspot/share/oops/markWord.hpp

4.3 对象的访问定位

JVM 规范没有规定对象引用的具体实现方式。HotSpot 使用直接指针方式(引用直接存储对象的内存地址),这也是最主流的方式。另一种方式是句柄池:引用指向句柄池中的一个入口,该入口包含两个指针,分别指向堆中的对象实例和方法区中的类型数据。句柄池的优势是 GC 移动对象时只需修改句柄池中的实例指针,而引用本身不需要修改——但代价是每次访问都要多一次指针跳转。

五、执行引擎

5.1 解释执行

JVM 的初始执行模式是解释执行:逐条读取字节码指令,翻译为机器指令并执行。解释器的优势是启动快,不需要预热时间,适合短生命周期的程序。缺点是执行效率低——同一条字节码指令可能被解释成千上万次。

HotSpot 中有两套解释器:

  • C++ 解释器src/hotspot/share/interpreter/bytecodeInterpreter.cpp):纯 C++ 实现,可移植性好,但性能一般。
  • 模板解释器src/hotspot/cpu/x86/templateTable_x86.cpp):为每个字节码指令生成一段汇编模板,执行时直接将对应模板复制到指令缓冲区中。性能优于 C++ 解释器,是 HotSpot 的默认解释器。

5.2 JIT 编译

JIT(Just-In-Time)编译器在运行时将热点代码(Hot Spot——这就是 HotSpot 名字的由来)编译为本地机器码,后续调用直接执行机器码,大幅提升性能。

JIT 编译的触发条件

HotSpot 使用”热点探测”(Hot Spot Detection),两种探测方式:

  • 基于采样的热点探测:周期性检查栈顶,如果某方法经常出现在栈顶,则标记为热点。简单但不够精确。
  • 基于计数器的热点探测:为每个方法维护调用计数器(Invocation Counter)和回边计数器(Back Edge Counter,用于循环)。计数器超过阈值(Client 模式默认 1500 次,Server 模式默认 10000 次)时触发 JIT 编译。

C1 vs C2 编译器

  • C1(Client Compiler):编译速度快,优化较少。适合 GUI 应用(启动时间敏感)。
  • C2(Server Compiler):编译速度慢,但优化激进(内联、逃逸分析、循环展开、空值检查消除等)。适合长时间运行的服务端应用。

分层编译(Tiered Compilation):JDK 7+ 默认开启。C1 提供快速编译以加速启动,C2 在后台对持续热点的方法进行更深度的优化。

5.3 ART 的执行模式

ART 的执行模式更为多样化:

1. 解释执行 (Interpreter)
└→ art/runtime/interpreter/interpreter_common.cc
Interpreter 逐条执行 Dex 字节码

2. AOT 编译 (Ahead-of-Time)
└→ art/dex2oat/dex2oat.cc
安装时用 dex2oat 将 DEX 编译为 .oat (ELF 格式)
Android 7+ 混合使用 AOT + Profile Guided Compilation

3. JIT 编译 (Just-in-Time)
└→ art/runtime/jit/jit.cc
运行时将热点代码编译为本地机器码
Android 7+ 引入 JIT 编译器

4. 混合模式 (Hybrid)
└→ 解释 + JIT + AOT
Profile 记录热点方法 → JIT 编译 → 后台空闲时 dex2oat AOT编译

六、编译与执行全链路

.java 到最终执行,完整路径如下:

.java 源文件
↓ javac (前端编译)
.class 字节码(符合 JVM 规范)
↓ ClassLoader (加载 → 链接 → 初始化)
方法区中的类元数据 + 堆中的 Class 对象
↓ 字节码解释器 / JIT 编译器
对应平台的机器码(x86-64, ARM64, RISC-V...)
↓ CPU 执行

javac 编译的详细过程

1. Parse (词法分析 + 语法分析)
└→ com.sun.tools.javac.parser.JavacParser
生成 AST (Abstract Syntax Tree)

2. Enter (符号填充)
└→ com.sun.tools.javac.comp.Enter
将类、方法、字段的定义注册到符号表

3. Annotation Processing (注解处理)
└→ JSR-269 APT
如果有注解处理器,在此阶段执行

4. Attribute (语义分析)
└→ com.sun.tools.javac.comp.Attr
类型检查、常量折叠

5. Flow (数据流分析)
└→ com.sun.tools.javac.comp.Flow
变量赋值检查、异常检查、可达性分析

6. Desugar (解语法糖)
└→ com.sun.tools.javac.comp.TransTypes
泛型擦除、自动装箱拆箱、foreach 循环展开

7. Generate (字节码生成)
└→ com.sun.tools.javac.jvm.Gen
生成 ClassWriter → 输出 .class 文件

七、JVM vs ART 对比

维度 HotSpot JVM ART (Android Runtime)
可执行文件 .class (JVM 字节码) .dex (Dalvik 字节码)
指令集 基于操作数栈 基于寄存器(Dalvik 字节码是 register-based)
编译方式 解释 + C1/C2 JIT 解释 + JIT + AOT (dex2oat)
类加载器 Bootstrap → Extension → Application BootClassLoader → PathClassLoader
GC G1 (默认), Serial, Parallel, CMS, ZGC Concurrent Copying (Android 10+)
方法区 Metaspace (JDK 8+) Class Table + DexCache
常量池 每 class 一个常量池 共享常量池(多 class 共享 DEX 中的常量池)
进程模型 每个 JVM 实例一个进程 Zygote fork(共享预加载的类和资源)

关键差异详解

DEX vs .class

JVM 的每个 .class 文件有独立的常量池(Constant Pool),而 DEX 将多个 .class 的常量池合并为一个共享常量池。这显著减少了重复数据。例如,所有类中出现的 "java/lang/String" 字符串在 .class 文件中每个类都会存一份,但在 DEX 中只存一份。

JVM .class:
class1.class: [常量池: "java/lang/String", "toString", ...]
class2.class: [常量池: "java/lang/String", "hashCode", ...]
→ "java/lang/String" 重复存储

DEX:
classes.dex: [共享常量池: "java/lang/String", "toString", "hashCode", ...]
→ 多 class 共享,体积更小

寄存器 vs 操作数栈

Dalvik 字节码是基于寄存器的(register-based),这与 x86/ARM 的物理寄存器更接近。但注意:Dalvik 的寄存器是虚拟寄存器,数量可变(方法头指定 .registers N),而非物理寄存器。

JVM 字节码 (栈式):
iload_1 // 将局部变量 1 (int) 压栈
iload_2 // 将局部变量 2 (int) 压栈
iadd // 弹出栈顶两个 int,相加后压栈
istore_3 // 将栈顶 int 存入局部变量 3

Dalvik 字节码 (寄存器式):
add-int v3, v1, v2 // v3 = v1 + v2 (一条指令完成)

八、源码路径汇总

组件 AOSP 路径 说明
ART 启动 art/runtime/runtime.cc ART Runtime 的创建和初始化
类链接器 art/runtime/class_linker.cc 类加载、链接的核心实现
解释器 art/runtime/interpreter/interpreter_common.cc 字节码解释执行
JIT art/runtime/jit/jit.cc JIT 编译器管理
dex2oat art/dex2oat/dex2oat.cc AOT 编译器入口
堆管理 art/runtime/gc/heap.cc 堆分配与 GC 调度
Java 层 ClassLoader libcore/dalvik/src/main/java/dalvik/system/ Android 类加载器
JNI art/runtime/jni_internal.cc JNI 调用管理

九、常见面试题

Q1: 详细描述一个对象从 new 到可以使用的完整过程。

A: 当执行 new Object() 时:(1) JVM 检查该类是否已被加载,若未加载则经历完整的类加载流程(loadClass→findClass→defineClass);(2) 计算对象需要的空间大小(对象头 + 实例数据 + 对齐填充),在堆中分配内存——分配策略取决于 GC 算法:指针碰撞(Serial/ParNew)或空闲列表(CMS);(3) 并发安全性通过 TLAB(Thread Local Allocation Buffer)或 CAS+重试机制保证;(4) 将分配的内存空间清零(不包括对象头),这使得所有实例字段拥有默认值(int=0, boolean=false, 引用=null);(5) 设置对象头,包括 Mark Word(hashCode、GC年龄、锁状态)和 Klass Pointer(指向方法区中类元数据的指针);(6) 执行 <init> 方法(构造函数),完成字段的显式初始化。此时对象创建完毕,可以被使用。

Q2: 什么是双亲委派模型?为什么要这样设计?Android 的 ClassLoader 和 JVM 的有何不同?

A: 双亲委派模型要求除了顶层的 Bootstrap ClassLoader 外,所有 ClassLoader 在加载一个类时优先委派给父 ClassLoader。设计目的:(1) 安全性——防止核心类库被篡改,例如用户自定义的 java.lang.String 不会被加载,因为 Bootstrap ClassLoader 已经加载了 rt.jar 中的 String;(2) 唯一性——保证 Java 核心库的类型安全,避免同一个类被不同 ClassLoader 加载后引发 ClassCastException。Android 的 ClassLoader 差异:Android 使用 BootClassLoader(加载 framework 类)→ PathClassLoader(加载 APK 中的 DEX)→ DexClassLoader(加载外部 DEX/JAR),没有 Extension ClassLoader 这一层。Android 的 BaseDexClassLoader 通过 DexPathList 管理多个 DEX 文件(MultiDex 场景),不遵循严格的双亲委派。

Q3: 方法区/元空间会发生 GC 吗?什么情况下会触发?

A: 方法区/元空间也会发生 GC,只是频率远低于堆 GC。触发条件:(1) 该类所有的实例都已被回收(堆中不存在该类的任何实例);(2) 该类的 ClassLoader 已被回收;(3) 该类的 Class 对象没有被任何地方引用(反射中不再可用)。满足以上三个条件后,该类的元数据就可以在下次 Full GC 时被回收。在 JDK 7 及以前(PermGen),类卸载需要 Full GC;JDK 8+(Metaspace),类卸载可以在后台进行。频繁生成动态代理类(如 Spring AOP、Hibernate)可能导致 Metaspace 溢出(OutOfMemoryError: Metaspace),需要关注 -XX:MaxMetaspaceSize 的设置。

Q4: JVM 中操作数栈(operand stack)和局部变量表(local variable table)有什么区别?为什么 JVM 选择栈式架构而不是寄存器架构?

A: 操作数栈是一个 LIFO(后进先出)的数据结构,用于存储字节码指令的中间结果。局部变量表是一个数组,索引从 0 开始,存储方法的参数和局部变量。两者同属于栈帧的一部分,但用途不同:局部变量表是”存储”,操作数栈是”计算”。JVM 选择栈式架构的原因是平台无关性——栈不需要依赖物理 CPU 的寄存器数量和编号(x86-64 有 16 个寄存器,ARM64 有 31 个,RISC-V 有 32 个),这使得字节码可以无缝运行在任何平台上。代价是执行时需要更多的 load/store 指令(相比寄存器架构)。但 JIT 编译器可以在运行时将栈式字节码转换为高效的寄存器式机器码,消除这一劣势。

Q5: 什么是 TLAB?为什么需要它?它的大小是如何确定的?

A: TLAB(Thread Local Allocation Buffer)是 JVM 为每个线程在 Eden 区预分配的一小块内存缓冲区。线程在分配小对象时,优先在自己的 TLAB 中使用指针碰撞分配,无需与其他线程竞争全局锁。当 TLAB 用完时,线程申请一个新的 TLAB(此时需要同步)。TLAB 避免了多线程场景下频繁的 CAS 竞争,显著提升了对象分配效率。TLAB 的大小由 -XX:TLABSize 指定,默认为 Eden 区的 1%。TLAB 内部有浪费(waste)的概念——如果一个 TLAB 剩余空间不够分配下一个对象,这部分空间就被浪费。JVM 通过动态调整(TLAB Waste Target Percent)来平衡浪费率和 TLAB 申请频率。

Q6: JVM 如何保证 <clinit> 方法在多线程环境下的安全执行?

A: JVM 规范要求 <clinit> 方法在多线程环境下只能被一个线程执行,并且执行完成后其他线程才能继续。具体实现:每个类在 JVM 内部有一个 initialization_state 状态,包括 “uninitialized”、”being_initialized”、”fully_initialized”、”initialization_error”。第一个线程发现类处于 “uninitialized” 状态时,通过 CAS 将其改为 “being_initialized”,然后执行 <clinit>。其他线程检查到 “being_initialized” 状态时,会阻塞等待(通过 Object.wait() 或类似机制),直到第一个线程执行完成并将状态改为 “fully_initialized” 或 “initialization_error”。这就是为什么类的静态初始化块是线程安全的——JVM 在底层帮你锁住了。但也需要注意:如果 <clinit> 中有死锁(例如 A 的 <clinit> 等 B,B 的 <clinit> 等 A),整个应用会挂死。


参考文档:

  • The Java Virtual Machine Specification, Java SE 11 Edition
  • AOSP: art/runtime/ — ART 运行时实现
  • Oracle HotSpot Source: src/hotspot/share/
  • “深入理解Java虚拟机” 周志明 著
打赏
  • 微信
  • 支付宝

评论