本教程基于 Oracle 官方《The Java Virtual Machine Specification》(Java SE 26)第 2 章《The Structure of the Java Virtual Machine》整理编写,面向想真正理解 JVM 内部如何工作的 Java 开发者。读完后,你将能用自己的话讲清楚:一段 Java 代码编译后变成了什么、JVM 在运行时把数据放在哪里、方法是怎么被调用和返回的。
你将学到
- JVM 是什么「抽象机器」,它和具体实现的关系
class文件格式的定位(它为什么与平台无关)- JVM 的两大类数据类型:基本类型 vs 引用类型
- 运行时数据区:程序计数器、虚拟机栈、堆、方法区、常量池、本地方法栈
- **栈帧(Frame)**的内部结构:局部变量表、操作数栈、动态链接
- 对象、浮点、特殊方法(
<init>/<clinit>)、异常与指令集的要点
前置知识
- 写过 Java,知道「编译 → 运行」的基本流程
- 了解栈(stack)和堆(heap)这两个基本数据结构概念
- 不需要读过后面的 JVM 规范章节;本篇就是第 2 章的通俗版
一、先建立心智模型:JVM 是一台「抽象机器」
规范开篇就说得很直白:JVM 描述的是一台抽象机器,而不是某一份具体实现。
这意味着:
- 任何「正确」的 JVM 实现,只要能读取
class文件格式、并正确执行其中规定的操作,就合规。 - 至于运行时数据区在内存里怎么摆、用哪种垃圾回收算法、指令内部怎么优化(比如 JIT 编译成机器码)——全由实现者自己决定。
- 所有 Unicode 引用基于The Unicode Standard, Version 17.0.0。
一句话理解:规范规定了「输入输出契约」,没规定「屋子内部怎么装修」。这也是为什么 HotSpot、OpenJ9 跑起来行为一致,但底层天差地别。
二、class文件格式:与平台无关的二进制
被 JVM 执行的编译后代码,用一种与硬件和操作系统无关的二进制格式表示,通常(但不一定)存成文件,这就是class文件格式。
它精确定义了类或接口的表示,连字节序(big-endian,大端)这种在普通平台相关目标文件里常被忽略的细节都写死了。完整的class文件结构在第 4 章详述,本篇先记住它的定位即可:
class文件是 JVM 的「公共设计接口」之一——你写的.java被javac翻译成.class,JVM 只认.class。
三、数据类型:JVM 操作的两大类
JVM 只操作两类类型:
| 类别 | 对应的值 | 例子 |
|---|---|---|
| 基本类型(primitive types) | primitive values | int、long、float、boolean、returnAddress |
| 引用类型(reference types) | reference values | 类实例、数组、接口实例、null |
关键设计点:
- 几乎所有类型检查都在运行前完成(通常由
javac编译器做)。JVM 自己不需要在运行时给基本类型值贴类型标签。 - 指令集靠「专用指令」区分操作数类型。比如加法就有四个不同指令:
iadd(int)、ladd(long)、fadd(float)、dadd(double)。 - JVM显式支持对象:对象要么是动态分配的类实例,要么是数组。指向对象的引用类型叫
reference,且具有多态性。
3.1 基本类型细分
整数类型(Integral Types)
| 类型 | 位数 | 取值范围 | 默认值 |
|---|---|---|---|
byte | 8 | [-128, 127] | 0 |
short | 16 | [-32768, 32767] | 0 |
int | 32 | [-2147483648, 2147483647] | 0 |
long | 64 | [-9223372036854775808, 9223372036854775807] | 0 |
char | 16 | [0, 65535](UTF-16 的 BMP 代码点,无符号) | '\u0000' |
注意
char是无符号的,这点常被人忽略。
浮点类型(Floating-Point Types)
float:对应 IEEE 754 binary32;默认正零double:对应 IEEE 754 binary64;默认正零- Java SE 15 起采用 IEEE 754 2019 版(之前是 1985 版)
- 包含正零/负零、正无穷/负无穷、
NaN(Not a Number,用于表示 0/0 这类无效操作)
浮点参数要点:
float:尾数 24 位、指数 8 位;double:尾数 53 位、指数 11 位NaN是无序的:任何与NaN的比较都返回 false- 正零 == 负零,但除法结果不同:
1.0/0.0 = 正无穷,1.0/-0.0 = 负无穷
returnAddress类型
- 由
jsr、ret、jsr_w指令使用,值是「指向某条 JVM 指令操作码」的指针 - 不直接对应任何 Java 语言类型,运行期间不可被程序修改
- (现代 Java 代码基本不会直接碰到它,了解即可)
boolean类型——JVM 的「半吊子」支持
JVM定义了boolean,但只给了有限支持:
- 没有专门的
boolean指令;boolean表达式会被编译器编译成int操作(0=false,非 0=true) - 但
boolean数组被直接支持:用newarray创建,用baload/bastore访问 - 在 Oracle 实现里,
boolean数组按byte数组编码(每元素 8 位),1=true、0=false;当映射成int时,编译器必须用同样的编码
这也是为什么
boolean只占 1 字节(数组里),但作为局部变量/字段时常被 JVM 当int处理——规范把灵活度留给了实现。
3.2 引用类型
三类引用类型:
- class types:指向动态创建的类实例
- array types:由 component type 递归构成,最终得到 element type(必须是基本类型、类或接口)
- interface types:指向实现了该接口的类实例或数组
特殊值null:
- 是默认的引用值,没有运行时类型
- 可以转换成任意引用类型
- 规范不规定
null在内存里怎么编码(各家实现不同)
四、运行时数据区(Run-Time Data Areas)—— 本篇核心
JVM 在运行程序时,把数据放在若干「区」里。有的区随 JVM 启动而创建、退出时销毁;有的是每线程私有的。
┌─────────────────────────────────────────┐ 每个线程私有 → │ pc 寄存器 │ JVM 栈(帧) │ 本地方法栈 │ ├─────────────────────────────────────────┤ 所有线程共享 → │ 堆 (Heap) │ 方法区(含常量池) │ └─────────────────────────────────────────┘4.1pc寄存器(程序计数器)
- 每个线程都有自己的程序计数器
- 如果当前方法不是 native:存「下一条要执行的指令」的地址
- 如果是 native 方法:值未定义
- 宽度要足够容纳
returnAddress或原生指针
为什么每个线程都有?因为多线程是轮流占用 CPU 的,线程切回来时得知道自己执行到哪了。
4.2 Java 虚拟机栈(JVM Stacks)
- 每线程私有,用来存「栈帧(frames)」
- 类似 C 语言的栈:保存局部变量和中间结果
- 大小可以固定,也可以动态扩展
- 溢出时抛:
StackOverflowError(栈太深,比如无限递归)或OutOfMemoryError(想扩展却没内存)
4.3 堆(Heap)
- 所有线程共享,是类实例和数组分配内存的地方
- JVM 启动时创建,由垃圾回收器(GC)自动管理
- 可固定大小也可扩展
- 内存不够时抛
OutOfMemoryError
几乎所有你
new出来的对象,都住在这里。
4.4 方法区(Method Area)
- 线程共享,存类的结构信息:运行时常量池、字段/方法数据、方法的字节码等
- 逻辑上属于堆的一部分,但实现可以选择「不回收它」
- 内存不够时抛
OutOfMemoryError
4.5 运行时常量池(Run-Time Constant Pool)
- 每个类/接口都有一个,是
class文件里constant_pool的运行时表示 - 内容包含:编译期已知的常量、以及运行期才解析的符号引用(比如一个方法的名字)
- 从方法区分得内存;构建时不够就抛
OutOfMemoryError
4.6 本地方法栈(Native Method Stacks)
- 给 native 方法(比如用 C 写的)用的栈,每个线程可选地分配
- 异常规则和 JVM 栈一样:
StackOverflowError/OutOfMemoryError
五、栈帧(Frames):方法调用的载体
每次方法被调用,JVM 就新建一个 frame(栈帧)。它装着:
- 方法的局部数据
- 部分计算结果
- 动态链接信息
- 返回值 / 异常分发的去处
frame 从当前线程的 JVM 栈里分配,包含:
- 局部变量数组(Local Variables)
- 操作数栈(Operand Stack)
- 指向「当前类运行时常量池」的引用
注意:只有「当前帧(current frame)」是活跃的,线程之间不共享帧。
5.1 局部变量表(Local Variables)
- 大小在编译期就确定了
- 一个「变量槽(slot)」能存
int/float/reference/returnAddress long和double因为 64 位,占两个槽- 实例方法调用时,变量 0 固定是
this,参数从 1 开始;而类方法(static)参数从 0 开始
5.2 操作数栈(Operand Stack)
- 一个LIFO(后进先出)栈,最大深度编译期定好
- 用途:加载常量、做运算、给方法传参
long/double在操作数栈里占两个单元- 类型必须匹配:不能把两个
int当成一个long来用
直觉理解:字节码指令大多从操作数栈「弹出」操作数、算完再「压回」结果。它就像手工计算器里的暂存栈。
5.3 动态链接(Dynamic Linking)
- 帧会引用「当前方法所在类型的运行时常量池」
- 把符号引用(比如「某个叫 foo 的方法」)在运行时转换成具体的方法引用、字段偏移量
- 这就是 Java 能支持「晚期绑定(late binding)」的基础
5.4 方法调用如何「完成」
- 正常完成(Normal):方法没抛异常,执行
return系列指令,把返回值交给调用者,并恢复调用者帧的状态。 - 突然完成(Abrupt):方法里抛了异常又没自己处理,则不返回值;当前帧被弹出,异常沿调用链往上抛。
六、对象的表示
规范不强制对象在内存里的内部结构——又一处「留给实现」。
在 Oracle 的某些实现里:
- 类实例的引用,是一个「指向 handle 的指针」
- 这个 handle 里包含:方法表指针、
Class对象指针、以及指向堆中真实数据的指针
这解释了为什么 Java 里「对象引用」传递,传递的是引用本身,而非对象拷贝。
七、浮点运算注意事项
JVM 采用 IEEE 754 的一个子集(Java SE 15+ 用 2019 版)。与严格 IEEE 754 的主要差异:
drem/frem(取余)基于向零舍入,而非「最近」dneg/fneg(取负)不要求反转 NaN 的符号位- 不抛IEEE 异常(如溢出)
- 没有signaling NaN
- 不支持extended 格式(如 80 位扩展精度)
舍入策略(Rounding Policies):
- round to nearest(就近舍入):除「转整数」和「取余」外,选最近的可表示值(平局时取末位为 0 的那个)
- round toward zero(向零舍入 / 截断):用于
d2i/f2i等转换,以及drem/frem
值集(Value Sets)小史:
- Java 1.2–16 允许使用 extended-exponent 值集
- Java SE 17 起强制 strict evaluation,
ACC_STRICT标志不再影响结果
八、特殊方法(Special Methods)
JVM 里有几类「名字特殊」的方法,需要单独记:
8.1 实例初始化方法<init>
- 在类(非接口)中,名为
<init>且返回void的方法 - 只能由
invokespecial调用,且作用于「尚未初始化完成的实例」 - 对应你在 Java 里写的每个构造器
8.2 类初始化方法<clinit>
- 名为
<clinit>且返回void - class 文件版本 ≥ 51 时,必须带
ACC_STATIC标志且无参数 - JVM 在类初始化阶段自动调用(对应 Java 里的
static { }块和静态变量赋值)
8.3 签名多态方法(Signature Polymorphic Methods)
- 声明在
java.lang.invoke.MethodHandle或VarHandle中 - 只有一个
Object[]参数,带ACC_VARARGS+ACC_NATIVE - 由
invokevirtual做特殊处理(这是MethodHandle.invoke能「看起来像普通调用」的底层魔法)
九、异常(Exceptions)
- 异常是
Throwable的子类实例 - 三种被抛出的原因:
athrow指令显式抛出- 同步异常:JVM 指令检测到(如数组越界
ArrayIndexOutOfBoundsException) - 异步异常:JVM 内部错误等
- 异常处理器按
class文件里的表顺序匹配;没命中就「突然完成」并沿调用链上抛;线程终止前交给 uncaught exception handler - JVM 的异常是「精确的(precise)」:抛出点之前的效果可见,之后的不可见
十、指令集摘要
JVM 指令集的「长相」:
- 一条指令 =1 字节操作码(opcode)+ 若干操作数
- 操作数**大端(big-endian)**存放
- 除
lookupswitch/tableswitch外,所有指令单字节对齐 - 解释器循环就是:
取操作码 → 取操作数 → 执行
类型如何编码进指令
多数指令把类型信息直接编进操作码:
i= int,l= long,f= float,d= double,a= reference(引用)byte/short/char/boolean在加载时统一转成int(符号扩展或零扩展),所以它们在 JVM 里其实都是当int算的
指令家族一览
| 类别 | 代表指令 | 说明 |
|---|---|---|
| Load/Store | iload/istore/wide | 局部变量 ↔ 操作数栈,索引过大时用wide |
| 算术 | iadd/imul/ishl/icmp | 整数除零抛ArithmeticException;浮点不抛 |
| 类型转换 | i2l(宽化)/i2b(窄化) | 宽化不丢精度;窄化可能丢,向零舍入 |
| 对象创建 | new/newarray/getfield/arraylength | |
| 操作数栈管理 | pop/dup/swap | 复制、交换栈顶 |
| 控制转移 | ifeq/goto/tableswitch | int比较指令最丰富 |
| 方法调用 | invokevirtual/invokeinterface/invokespecial/invokestatic/invokedynamic | 返回用ireturn等 |
| 抛异常 | athrow | 显式抛出 |
| 同步 | ACC_SYNCHRONIZED(方法级)/monitorenter+monitorexit(块级) | 管程锁,结构化规则 |
十一、公共设计,私有实现
这一节是整章的「哲学总结」:
- 公共视图:
class文件格式 + 指令集 + 运行时语义 - 私有实现:JIT 把字节码翻译成本地机器码、各种 GC 算法、内存布局……只要对外行为和语义不变,实现者想怎么优化都行
- 调试器、性能分析器等工具有时需要看「引擎盖下的东西」,Oracle 也在推动通用接口(如 JVM TI)来标准化这种访问
理解这一点,你就能看懂为什么「同一份
.class,在不同 JVM 上跑得一样快/慢不一样」——快慢在实现,行为在规范。
练习
练习 1(基础):char类型的取值范围是多少?它和short在「有无符号」上有何本质区别?
练习 2(理解堆与栈):下面这段代码的user对象本身、以及user这个引用变量,分别存在哪个运行时数据区?
publicvoiddemo(){Useruser=newUser();// User 是一个普通类}练习 3(指令集):要把一个int局部变量加载到操作数栈,应该用哪条指令?如果要把int转成long,又是什么指令?
练习 4(方法调用):Java 里的static { ... }静态初始化块,在 JVM 层面对应哪个特殊方法?它能被你写的 Java 代码直接调用吗?
练习 5(开放思考):为什么规范说「JVM 异常是精确的」很重要?如果异常不精确,对调试会有什么影响?
char范围 [0, 65535],是无符号16 位;short是有符号16 位,范围 [-32768, 32767]。同样 16 位,解释方式不同。new User()创建的对象实例在堆;user这个引用变量是demo()方法的局部变量,在当前线程的 JVM 栈帧的局部变量表里。- 加载
int局部变量:iload(带索引,如iload 1);int→long转换:i2l。 - 对应
<clinit>(类初始化方法)。不能被 Java 代码直接调用——它由 JVM 在类初始化阶段自动调用。 - 精确异常意味着「异常抛出点之前的所有副作用都生效、之后的都不生效」,这样调试时栈轨迹能准确告诉你是哪一行出的错;若不精确,你看到的异常位置会和实际出错点错位,极难排查。
小结
这一章给你搭起了 JVM 的「骨架」:
- 输入契约:
class文件(与平台无关) - 数据类型:基本类型(含
boolean的半支持、returnAddress)与引用类型(null是默认值) - 运行时布局:pc 寄存器、JVM 栈、堆、方法区、常量池、本地方法栈
- 方法执行:靠栈帧(局部变量 + 操作数栈 + 动态链接)完成,有正常/突然两种结束方式
- 其它要点:对象表示、浮点约定、
<init>/<clinit>/签名多态方法、精确异常、1 字节操作码指令集