
本教程基于 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翻译成.classJVM 只认.class。三、数据类型JVM 操作的两大类JVM 只操作两类类型类别对应的值例子基本类型primitive typesprimitive valuesint、long、float、boolean、returnAddress引用类型reference typesreference values类实例、数组、接口实例、null关键设计点几乎所有类型检查都在运行前完成通常由javac编译器做。JVM 自己不需要在运行时给基本类型值贴类型标签。指令集靠「专用指令」区分操作数类型。比如加法就有四个不同指令iaddint、laddlong、faddfloat、dadddouble。JVM显式支持对象对象要么是动态分配的类实例要么是数组。指向对象的引用类型叫reference且具有多态性。3.1 基本类型细分整数类型Integral Types类型位数取值范围默认值byte8[-128, 127]0short16[-32768, 32767]0int32[-2147483648, 2147483647]0long64[-9223372036854775808, 9223372036854775807]0char16[0, 65535]UTF-16 的 BMP 代码点无符号\u0000注意char是无符号的这点常被人忽略。浮点类型Floating-Point Typesfloat对应 IEEE 754 binary32默认正零double对应 IEEE 754 binary64默认正零Java SE 15 起采用 IEEE 754 2019 版之前是 1985 版包含正零/负零、正无穷/负无穷、NaNNot 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操作0false非 0true但boolean数组被直接支持用newarray创建用baload/bastore访问在 Oracle 实现里boolean数组按byte数组编码每元素 8 位1true、0false当映射成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线程共享存类的结构信息运行时常量池、字段/方法数据、方法的字节码等逻辑上属于堆的一部分但实现可以选择「不回收它」内存不够时抛OutOfMemoryError4.5 运行时常量池Run-Time Constant Pool每个类/接口都有一个是class文件里constant_pool的运行时表示内容包含编译期已知的常量、以及运行期才解析的符号引用比如一个方法的名字从方法区分得内存构建时不够就抛OutOfMemoryError4.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/returnAddresslong和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 Policiesround to nearest就近舍入除「转整数」和「取余」外选最近的可表示值平局时取末位为 0 的那个round toward zero向零舍入 / 截断用于d2i/f2i等转换以及drem/frem值集Value Sets小史Java 1.2–16 允许使用 extended-exponent 值集Java SE 17 起强制 strict evaluationACC_STRICT标志不再影响结果八、特殊方法Special MethodsJVM 里有几类「名字特殊」的方法需要单独记8.1 实例初始化方法init在类非接口中名为init且返回void的方法只能由invokespecial调用且作用于「尚未初始化完成的实例」对应你在 Java 里写的每个构造器8.2 类初始化方法clinit名为clinit且返回voidclass 文件版本 ≥ 51 时必须带ACC_STATIC标志且无参数JVM 在类初始化阶段自动调用对应 Java 里的static { }块和静态变量赋值8.3 签名多态方法Signature Polymorphic Methods声明在java.lang.invoke.MethodHandle或VarHandle中只有一个Object[]参数带ACC_VARARGSACC_NATIVE由invokevirtual做特殊处理这是MethodHandle.invoke能「看起来像普通调用」的底层魔法九、异常Exceptions异常是Throwable的子类实例三种被抛出的原因athrow指令显式抛出同步异常JVM 指令检测到如数组越界ArrayIndexOutOfBoundsException异步异常JVM 内部错误等异常处理器按class文件里的表顺序匹配没命中就「突然完成」并沿调用链上抛线程终止前交给 uncaught exception handlerJVM 的异常是「精确的precise」抛出点之前的效果可见之后的不可见十、指令集摘要JVM 指令集的「长相」一条指令 1 字节操作码opcode 若干操作数操作数**大端big-endian**存放除lookupswitch/tableswitch外所有指令单字节对齐解释器循环就是取操作码 → 取操作数 → 执行类型如何编码进指令多数指令把类型信息直接编进操作码i intl longf floatd doublea reference引用byte/short/char/boolean在加载时统一转成int符号扩展或零扩展所以它们在 JVM 里其实都是当int算的指令家族一览类别代表指令说明Load/Storeiload/istore/wide局部变量 ↔ 操作数栈索引过大时用wide算术iadd/imul/ishl/icmp整数除零抛ArithmeticException浮点不抛类型转换i2l宽化/i2b窄化宽化不丢精度窄化可能丢向零舍入对象创建new/newarray/getfield/arraylength操作数栈管理pop/dup/swap复制、交换栈顶控制转移ifeq/goto/tableswitchint比较指令最丰富方法调用invokevirtual/invokeinterface/invokespecial/invokestatic/invokedynamic返回用ireturn等抛异常athrow显式抛出同步ACC_SYNCHRONIZED方法级/monitorentermonitorexit块级管程锁结构化规则十一、公共设计私有实现这一节是整章的「哲学总结」公共视图class文件格式 指令集 运行时语义私有实现JIT 把字节码翻译成本地机器码、各种 GC 算法、内存布局……只要对外行为和语义不变实现者想怎么优化都行调试器、性能分析器等工具有时需要看「引擎盖下的东西」Oracle 也在推动通用接口如 JVM TI来标准化这种访问理解这一点你就能看懂为什么「同一份.class在不同 JVM 上跑得一样快/慢不一样」——快慢在实现行为在规范。练习练习 1基础char类型的取值范围是多少它和short在「有无符号」上有何本质区别练习 2理解堆与栈下面这段代码的user对象本身、以及user这个引用变量分别存在哪个运行时数据区publicvoiddemo(){UserusernewUser();// 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 1int→long转换i2l。对应clinit类初始化方法。不能被 Java 代码直接调用——它由 JVM 在类初始化阶段自动调用。精确异常意味着「异常抛出点之前的所有副作用都生效、之后的都不生效」这样调试时栈轨迹能准确告诉你是哪一行出的错若不精确你看到的异常位置会和实际出错点错位极难排查。小结这一章给你搭起了 JVM 的「骨架」输入契约class文件与平台无关数据类型基本类型含boolean的半支持、returnAddress与引用类型null是默认值运行时布局pc 寄存器、JVM 栈、堆、方法区、常量池、本地方法栈方法执行靠栈帧局部变量 操作数栈 动态链接完成有正常/突然两种结束方式其它要点对象表示、浮点约定、init/clinit/签名多态方法、精确异常、1 字节操作码指令集