跳到主要内容
约 8 分钟4 张卡

Java 字节码

Java 字节码是 JVM 执行的指令表示,由编译器连同类型信息一起写入 Class 文件。本文先用一个乘法方法说明源码怎样编译,再介绍 Class 结构、指令和方法描述符。最后说明字节码验证、版本兼容与运行时执行的关系。

1. 面试核心​

先记住这几条,再读正文
  • 源码编译。 javac 完成语法与类型检查,并将泛型擦除、语法糖展开后的程序写入与处理器无关的 Class 文件。卡 · javac 编译
  • Class 文件。 Class 文件保存版本、常量池、字段、方法和属性,方法的字节码使用局部变量表与操作数栈计算。
  • 方法类型信息。 方法描述符记录擦除后的参数与返回类型,泛型信息可保存在 Signature 属性中。
  • 产物检查。 javap -v -c -p 可检查常量池、描述符和字节码。卡 · Class 文件的结构
  • 目标版本兼容。 javac --release 8 同时约束语言、Class 版本和可用平台 API。卡 · Class 版本与目标平台

2. javac 编译​

javac 把合法的 Java 源码转换为 JVM 可读取的 Class 文件。下面编译一个乘法方法,再用 javap 查看生成的指令:

public final class Price {
public static int total(int unitPrice, int quantity) {
return unitPrice * quantity;
}
}
javac Price.java
javap -c -p Price.class

编译成功后,会得到 Price.class。javac 在这个过程中解析语法、检查类型,并处理泛型擦除、装箱调用和语法糖展开。编译器还会折叠部分常量表达式,也就是提前算出编译时已能确定的结果。

同一个源文件可以产生多个 Class 文件。比如,成员类、匿名类和编译器生成的辅助类型都能产生额外文件,所以文件数量与源码文件数量并非一一对应。

Class 中保存的是 JVM 指令与类型信息,因此产物与具体处理器无关。CPU 最终执行的是机器码,JVM 负责解释这些指令,或把热点方法编译为当前平台的机器码。

3. Class 文件的结构​

Class 文件既保存指令,也保存 JVM 理解这些指令所需的类型信息。查看文件时,可以按以下Class 文件结构阅读:

  • 文件以魔数 0xCAFEBABE 开始,用于识别 Class 格式。后面的 minor、major 分别记录次版本和主版本。
  • 常量池保存字符串等常量,以及类、字段和方法的符号引用。符号引用用名称与描述符说明要访问的目标。
  • 类型信息记录访问标志、当前类、父类和接口,再列出字段与方法。
  • 属性补充代码、调试和验证所需的信息。方法的 Code 属性包含字节码与异常表,还用 max_stack、max_locals 记录操作数栈和局部变量表容量。

Code 属性还能包含行号表和栈映射帧等信息。行号表把指令位置对应到源码行,栈映射帧描述特定位置的局部变量和操作数栈类型,供验证使用。abstract 和 native 方法没有 Code 属性,因为 Class 文件中没有它们的 Java 字节码实现。

符号引用会在解析时关联到运行时目标。下面的命令可以查看这些结构:

javap -v Price.class

该命令可查看版本、常量池和属性。-p 进一步显示所有可见级别的成员。

4. 操作数栈与局部变量表​

JVM 指令通过局部变量表读取参数,用操作数栈保存计算过程中的值。Price.total 的核心字节码如下:

iload_0
iload_1
imul
ireturn

iload_0 和 iload_1 从局部变量表读取两个参数,依次压入操作数栈。接着,imul 弹出这两个整数,相乘后把乘积压回栈顶。ireturn 再弹出乘积并返回,因此整个方法得到的就是单价乘数量。

每次方法调用都有自己的栈帧,局部变量表和操作数栈都属于这个栈帧。

栈帧槽位与共享范围见 JVM 运行时数据区域。

5. 方法描述符​

方法描述符记录参数类型与返回类型,供 JVM 匹配方法。下面的方法签名片段接收 long 参数,返回 List<String>:

List<String> find(long userId)

擦除后的描述符为:

(J)Ljava/util/List;

描述符的括号内是参数,括号后是返回类型。这里的 J 表示 long,L...; 表示引用类型,所以描述符保留的是 long 到 List 的类型关系。

泛型擦除后,方法调用按这个描述符匹配。String 这样的泛型信息可保存在 Signature 属性中,供反射等工具读取。桥接方法、装箱和增强 for 也经过编译转换,具体生成什么结构,应通过 Class 文件确认。

5.1 方法调用指令​

知道方法的参数与返回类型后,还要区分怎样找到要执行的方法。不同调用指令负责不同的查找和分派方式:

指令主要用途
invokestatic调用静态方法
invokevirtual按接收者类型进行虚方法调用
invokeinterface通过接口类型调用方法
invokespecial构造器、父类调用等特殊调用
invokedynamic通过 bootstrap method 链接动态调用点

表中的接收者就是调用实例方法的对象,虚方法分派会根据它的实际类型选择实现。bootstrap method 称为引导方法,负责为 invokedynamic 建立调用点,也就是确定这条动态调用指令怎样执行。

从 JDK 9 开始,javac 可用 invokedynamic 实现字符串拼接,Lambda 也可使用这条指令。具体语法生成何种指令,要以目标 JDK 的编译产物为准。JIT 将方法内联后,会把方法体并入调用处,最终机器码中也就可以省去独立调用。

6. 字节码验证​

Class 文件来自外部输入,JVM 需要确认其中的指令能安全地执行。读取和链接时,JVM 会检查文件格式、类型约束、指令操作数与控制流。非法操作数或不兼容类型会导致验证失败,避免非法字节码破坏运行时状态。

除了 javac,Kotlin、Scala 等语言的编译器也能生成 Class 文件。字节码工具还可以在编译后修改类,所以 JVM 要自己验证收到的产物。

验证过程见 类加载。

6.1 Class 版本与目标平台​

Class 与处理器无关,但运行它的 JVM 仍需支持对应版本。旧 JVM 遇到过新的 Class 版本,会抛出 UnsupportedClassVersionError。比如,Java 8 的 major 为 52,Java 17 为 61。

为旧平台编译时,可以用 --release 同时约束语言、Class 版本和平台 API:

javac --release 8 Price.java
javap -v Price.class

在支持该目标的 JDK 上,输出 major version 为 52。--release 8 也限制源码语法和 Java 8 平台 API。

只用 -source 8 -target 8,平台 API 仍可来自编译 JDK。误用新增 API 后,旧环境运行会出现链接错误。第三方依赖也要满足目标版本要求。

7. 字节码执行​

Class 文件交给 JVM 后,还需要创建运行时类型,再执行其中的方法。类加载器先找到字节并定义类型,JVM 完成验证、准备等链接工作,再按使用条件触发初始化。解析可以延迟到实际需要符号引用时进行,详见 类加载。

HotSpot 可以先解释字节码,收集方法调用频率、实际对象类型等运行信息,再将热点方法编译为机器码。这些运行信息称为运行时画像。执行层级与循环执行中的编译切换见 分层编译。

如果优化依赖的假设失效,已编译代码还会去优化,回到能够正确执行的较低层级。原理见 方法内联与去优化。

所以,Java 源码先编译成字节码,运行时又能结合解释执行与即时编译。两种执行方式共同支持同一份程序。

8. 常见问题​

源码、Class 文件和最终机器码属于不同阶段。区分这三个阶段,就能理解下面的编译与性能差异。

8.1 javac 与 JIT​

javac 在程序运行前生成可交给 JVM 的 Class 文件,提供可移植的编译产物。JIT 则在程序运行时利用调用与类型画像,生成当前平台的机器码,用这些实际运行信息优化热点代码。

8.2 反编译结果与源码​

8.3 字节码与实际性能​

9 · 面试题卡片​

9.1 Java 程序从源码到运行经历了哪些过程​

出现公司:去哪儿、字节跳动

考察重点:区分源码编译、类加载与运行时执行,说明字节码与机器码的关系,以及解释器和 JIT 怎样配合。

相关内容:javac 编译、字节码执行。

参考回答

Java 程序先由 javac 编译为 Class 文件,再由 JVM 加载、链接并执行。

javac 完成语法和类型检查,生成与处理器无关的字节码。JVM 根据 Class 字节创建运行时类型,完成验证与准备,再按使用条件触发初始化,解析可以延迟。HotSpot 解释字节码并收集运行时画像,再把热点代码编译为当前平台的机器码。

优化假设失效时,已编译代码还会去优化。整个过程把源语言检查、运行时类型创建和热点优化分开,CPU 最终执行的是机器码。

9.2 怎样确认一段 Java 语法最终生成了什么​

出现公司:京东数科

考察重点:用一致的编译参数和 javap 检查泛型擦除、桥接方法与调用指令,并区分编译产物和运行时优化。

相关内容:Class 文件的结构、方法描述符。

参考回答

用相同 JDK 和编译参数生成 Class 文件,再运行 javap -v -c -p 查看实际产物。

输出包含版本、常量池、描述符、属性和指令,可以据此确认泛型擦除或语法糖的编译结果。若要继续确认运行时加载了哪个类、方法是否内联,还需结合类加载日志、JIT 日志或 JFR 记录。字节码只描述编译产物,后续优化要通过运行时证据判断。

9.3 为什么编译给 Java 8 使用时,应选 --release 8?​

出现公司:暂无已核验的公开来源(本站自测)

考察重点:解释 Class 版本与平台 API 是两项兼容要求,说明 --release 怎样同时约束它们。

相关内容:Class 版本与目标平台。

参考回答

--release 8 同时限定 Java 8 语法、Class 版本和 Java 8 平台 API。

单独设置 -source 8 -target 8 时,编译器仍可读取当前 JDK 的新 API。这样生成的 Class 虽然能被 Java 8 读取,调用旧平台缺少的方法时却会链接失败。--release 8 能在编译时发现这类平台 API 误用,第三方依赖的目标版本仍需单独检查。

9.4 invokevirtual、invokespecial 与 invokedynamic 各负责什么?​

出现公司:暂无已核验的公开来源(本站自测)

考察重点:说明三条指令各怎样确定调用目标,并解释引导方法在动态链接中的作用。

相关内容:方法调用指令。

参考回答

invokevirtual 按对象的实际类型分派虚方法,invokespecial 处理构造器和父类等特殊调用。

invokedynamic 则让引导方法建立调用点。

前两者按各自的规则选择方法,后者把调用点的链接交给引导方法,因此可用于 Lambda 和字符串拼接。调用点确定后,JIT 仍可继续内联和优化,最终机器码中未必保留独立调用。

10. 参考资料​