一、JVM 整体架构
JVM(Java Virtual Machine)是 Java 跨平台能力的基石。它定义了 Java 程序执行的标准环境,向上为 Java 语言提供统一的运行时语义,向下屏蔽了底层操作系统和硬件差异。
JVM 规范的架构可以分为以下几个子系统:
┌─────────────────────────────────────────────────┐ |
二、类加载子系统
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 (启动类加载器) |
工作流程:
// ClassLoader.loadClass() 的核心逻辑(简化) |
为什么需要双亲委派?
- 安全性:防止核心类库被篡改。例如用户自定义的
java.lang.String不会被加载(因为 Bootstrap ClassLoader 已经加载了 rt.jar 中的 String)。 - 唯一性:保证同一个类不会被多个 ClassLoader 重复加载,避免类型转换异常。
Android 中的 ClassLoader
Android 不使用 JVM 的类加载器模型,而是用 PathClassLoader 和 DexClassLoader:
// Android 类加载器层次 |
Android 类加载器的源码路径:
libcore/dalvik/src/main/java/dalvik/system/BaseDexClassLoader.javalibcore/dalvik/src/main/java/dalvik/system/PathClassLoader.javalibcore/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) |
对象晋升到老年代的条件:
- 年龄达到
MaxTenuringThreshold(默认 15) - Survivor 区中相同年龄的对象大小总和超过 Survivor 空间的一半
- 大对象直接进入老年代(通过
-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. 类加载检查 |
4.2 对象的内存布局
一个 Java 对象在堆内存中的布局分为三部分:
┌──────────────┐ |
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.hpp 和 src/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) |
六、编译与执行全链路
从 .java 到最终执行,完整路径如下:
.java 源文件 |
javac 编译的详细过程:
1. Parse (词法分析 + 语法分析) |
七、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: |
寄存器 vs 操作数栈
Dalvik 字节码是基于寄存器的(register-based),这与 x86/ARM 的物理寄存器更接近。但注意:Dalvik 的寄存器是虚拟寄存器,数量可变(方法头指定 .registers N),而非物理寄存器。
JVM 字节码 (栈式): |
八、源码路径汇总
| 组件 | 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虚拟机” 周志明 著




