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 反编译结果与源码
反编译器根据 Class 文件重建近似源码。此时泛型擦除、桥接方法生成、装箱调用插入和语法糖转换已经发生。因此,重建结果会保留编译后的结构,原来的变量名与书写方式未必能够恢复。
8.3 字节码与实际性能
相同字节码可以产生不同的机器码和运行表现。JDK 版本、JVM 实现、运行参数、实际输入和硬件都会影响优化。它们也会改变解释执行、内联和垃圾回收的开销,所以性能要在目标环境测量。
9 · 面试题卡片
9.1 Java 程序从源码到运行经历了哪些过程
出现公司:去哪儿、字节跳动
考察重点:区分源码编译、类加载与运行时执行,说明字节码与机器码的关系,以及解释器和 JIT 怎样配合。
参考回答
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 仍可继续内联和优化,最终机器码中未必保留独立调用。