ARTICLE · 1262038
JVM规范学习总结:Java源码经过javac编译后产物
一、核心结论 Java 源码 Class 文件 JVM 执行引擎 CPU 执行 二、`javac` 编译过程:从 `.java` 到 `.class` 1. OpenJDK 源码:`Gen.java` 如何生成字节码 2. OpenJDK 源码:`ClassWriter.java` 如何写出 Class 文件 三、JVMS 规定的 Class 文件具体包含什么? 重点理解六类信息 四、通过一个实际例子验证编译产物 1. 编写 Java 源码 2. 编译得到 Class 文件 3. 查看字节码 五、一个 `.java` 文件一定只会生成一个 `.class` 文件吗? 六、系统工程师应如何理解编译产物与运行时的边界? 七、学习总结与实践清单 1. JVMS 规定的是格式与行为,不是 `javac` 的内部实现 2. 从 `javac` 源码验证字节码生成过程 学习总结 3. 从 `ClassWriter.java` 理解二进制文件写出 4. 最终要掌握的几个工程结论 5. 建议的验证实验
前言
本文旨在记录近期研读Java源码的学习心得与疑难问题。由于个人理解水平有限,文中内容难免存在疏漏,恳请读者不吝指正。
Java 源码经过 javac 编译后生成什么?
一、核心结论
Java 源码经过 javac 编译后,主要生成符合 JVM 规范的 .class 文件。.class 文件中包含 JVM 字节码,以及类、字段、方法、常量池和各种属性等结构化信息。它通常不是 CPU 可以直接执行的本地机器码。
根据 Java SE 26 的《Java 虚拟机规范》(JVMS)第 4 章,Class 文件有规定的二进制格式,包含魔数、版本号、常量池、类信息、字段、方法和属性等内容。javac 的官方文档也明确指出,其主要任务是将 Java 源文件编译为能够在 JVM 上运行的 Class 文件。
Java 源码
.java:人类编写的高级语言
javac:词法与语法分析、语义检查、代码 生成
Class 文件
.class:JVM 字节码 + 类结构和元数据
类加载、链接、初始化与执行
JVM 执行引擎
解释执行;也可能通过 JIT 编译为本地机器码
CPU 执行
执行目标处理器架构的机器指令
从系统工程师角度,需要先建立这三个关键认知:
编译产物: javac主要生成.class文件,而不是直接生成.exe、CPU 汇编文件或本地机器码。规范与实现: JVMS 定义 Class 文件格式、字节码指令和 JVM 必须满足的行为约束; javac是编译器实现,HotSpot 等是 JVM 实现。执行与优化: Class 文件是 JVM 执行的标准输入之一。运行过程中,JVM 可以解释字节码,也可以通过即时编译(JIT)把热点代码编译为本地机器码。
二、javac 编译过程:从 .java 到 .class
从编译器实现角度,可以把 javac 的主要工作概括为以下几个阶段。
这是便于学习的阶段划分,不代表所有阶段在每个 JDK 版本的内部实现中都严格以独立步骤顺序执行。官方 javac 文档说明了其源码编译和 Class 文件输出职责;OpenJDK 源码则可以进一步验证字节码生成和文件写出的实现。
1. OpenJDK 源码:Gen.java 如何生成字节码
OpenJDK 源码位置:
src/jdk.compiler/share/classes/com/sun/tools/javac/jvm/Gen.java
该文件开头说明了它的主要职责:
/** * This pass maps flat Java (i.e. without inner classes) to bytecodes. * * // 学习注释: * // Gen 是 javac 中负责字节码生成的重要组件。 * // 它把经过前序处理的 Java 程序表示转换成 JVM 字节码。 */public class Gen extends JCTree.Visitor {// 后续负责生成方法指令、处理表达式及语句等。}下面是该文件中方法生成逻辑的摘录,省略了与原理无关的上下文,并在关键位置添加学习注释:
// OpenJDK: com.sun.tools.javac.jvm.Genvoid genMethod( JCMethodDecl tree, Env<GenContext> env,boolean fatcode) {// 初始化当前方法的代码生成结构。// 该结构会跟踪字节码、操作数栈状态及相关信息。int startpcCrt = initCode(tree, env, fatcode);// 遍历方法体中的语句和表达式,// 把它们转换为相应的 JVM 字节码指令。 genStat(tree.body, env);// 省略:检查操作数栈状态、代码位置、// 调试信息及其他代码生成约束。if (code.isAlive()) {// void 方法若能够正常执行到末尾,// 则需要生成 return 指令完成方法返回。// 实际源码中还有其他返回类型对应的处理逻辑。 }}以上为便于学习的缩略示意,关键调用与逻辑对应 OpenJDK 的 Gen.java。
学习总结: Gen 的工作是生成 JVM 指令,而不是生成特定 CPU 的机器指令。例如,整数加法可以生成 iadd,对象方法调用可以生成 invokevirtual 或 invokespecial 等指令。这些指令由 JVM 规范定义,并不直接等同于 x86-64 或 AArch64 指令。
2. OpenJDK 源码:ClassWriter.java 如何写出 Class 文件
源码位置:
src/jdk.compiler/share/classes/com/sun/tools/javac/jvm/ClassWriter.java
该文件的注释说明,它负责把以 ClassSymbol 为根的内部符号结构映射成 Class 文件。相关代码如下:
// OpenJDK: com.sun.tools.javac.jvm.ClassWriterpublic JavaFileObject writeClass(ClassSymbol c)throws IOException, PoolOverflow, StringOverflow {// 根据类名和输出配置确定目标 CLASS 文件。// 这里的 CLASS_OUTPUT 对应编译产物的输出位置。JavaFileObject outFile = fileManager.getJavaFileForOutput( outLocn, name, JavaFileObject.Kind.CLASS, c.sourcefile );// 打开输出流,开始序列化 Class 文件。OutputStream out = outFile.openOutputStream();try {// 真正执行 Class 文件的写出。 writeClassFile(out, c); } finally {// 省略原源码中的资源关闭和异常清理逻辑。 }return outFile;}在 writeClassFile 内部,OpenJDK 还会统计并写出字段和方法:
// 以下代码摘录自 ClassWriter.writeClassFile()databuf.appendChar(fieldsCount);// // 学习注释:将字段信息序列化为 field_info 结构。writeFields(c.members());databuf.appendChar(methodsCount);// // 学习注释:将方法信息序列化为 method_info 结构。writeMethods(c.members());// 省略:类访问标志、父类、接口及各种属性的处理。// 将已经组织好的 Class 文件二进制数据写入输出流。// poolbuf、poolWriter、databuf 是 javac 的内部写出结构,// 不应把它们简单理解成三个独立的 Class 文件。out.write(poolbuf.elems, 0, poolbuf.length);poolWriter.writePool(out);out.write(databuf.elems, 0, databuf.length);这段代码的关键在于:字节码只是 Class 文件的一部分,ClassWriter 还必须写出常量池、类层次结构、字段、方法以及属性等信息。
三、JVMS 规定的 Class 文件具体包含什么?
学习依据:Java SE 26《Java 虚拟机规范》第 4 章,Class File Format。
规范中的 ClassFile 结构可以用下面这种带注释的形式理解:
ClassFile { u4 magic; // 魔数:固定为 0xCAFEBABE u2 minor_version; // 次版本号 u2 major_version; // 主版本号,决定 Class 格式版本 u2 constant_pool_count; // 常量池表项数量 + 1 cp_info constant_pool[]; // 常量池:字符串、类名、方法引用等 u2 access_flags; // 类或接口的访问标志 u2 this_class; // 当前类在常量池中的索引 u2 super_class; // 父类在常量池中的索引 u2 interfaces_count; // 实现的接口数量 u2 interfaces[]; // 接口索引表 u2 fields_count; // 字段数量 field_info fields[]; // 字段定义及其属性 u2 methods_count; // 方法数量 method_info methods[]; // 方法定义、方法描述符及方法属性 u2 attributes_count; // 类级属性数量 attribute_info attributes[]; // 类级元数据和其他属性}这是一份学习用的结构摘录:数组长度在规范中由对应的计数字段决定,上述写法简化了具体数组声明。
重点理解六类信息
1. 文件标识和版本
魔数为 0xCAFEBABE。主、次版本号用于标识 Class 文件格式版本,帮助 JVM 判断文件是否符合其支持范围。
2. 常量池
存储类名、字段名、方法名、类型描述符、字符串常量和符号引用等。字节码可以通过常量池索引引用类、字段或方法,而不是在每条指令中重复存放完整名称。
3. 类、父类和接口信息
描述当前类型的名称、访问标志、父类和接口。这些信息参与类加载、类型检查、链接和方法解析。
4. 字段信息
包括字段名称、类型描述符、访问标志及相关属性。它描述类声明的字段,但不等同于某个对象在堆中的实际内存布局。
5. 方法信息和字节码
包括方法名、描述符、访问标志和属性。具有方法体的普通 Java 方法,其 JVM 指令通常存放在 Code 属性中;抽象方法和本地方法则没有 Code 属性。
6. 属性与元数据
例如 SourceFile、LineNumberTable、StackMapTable、Signature 和注解相关属性。它们可以为验证、调试、泛型信息和注解处理等提供支持。
这里有一个容易忽略的区别:LocalVariableTable 等调试属性并非默认在所有编译配置下都完整存在。javac 的 -g、-g:lines、-g:vars、-g:none 等选项会影响相应调试信息的生成。
四、通过一个实际例子验证编译产物
1. 编写 Java 源码
创建 Calc.java:
public class Calc {// static 表示静态方法。// 参数 a、b 在方法执行时进入局部变量槽位 0、1。public static int add(int a, int b) {// javac 会把整数加法转换成 JVM 字节码指令。// 源代码中的 return 不是一条独立的机器指令。return a + b; }}2. 编译得到 Class 文件
在终端运行:
# -g:生成调试信息# -d out:将编译产生的 Class 文件放进 out 目录javac -g -d out Calc.java预期得到:
out/└── Calc.class这里的 Calc.class 是二进制文件,而不是另一份 Java 源码,也不是一份可直接交给 CPU 执行的本地可执行文件。
3. 查看字节码
执行:
javap -c -v -p -classpath out Calc其中,javap 是 JDK 提供的 Class 文件反汇编工具;-c 展示字节码,-v 展示更详细的 Class 文件结构,-p 展示包括私有成员在内的成员。
对 add 方法,典型输出如下:
public static int add(int, int); Code: 0: iload_0 1: iload_1 2: iadd 3: ireturn逐条理解:
iload_0 | int 值压入操作数栈 |
iload_1 | int 值压入操作数栈 |
iadd | |
ireturn |
这就是 Java 源码经过 javac 转换后的直接证据:a + b 在这个简单例子中变成了 iload_0、iload_1、iadd、ireturn 等 JVM 指令。
需要注意,编译器还会为这个类生成默认构造方法。因此,查看完整的 javap -c 输出时,通常还能看到 <init> 构造方法及调用 java.lang.Object 构造方法的指令。具体常量池编号、附加属性等取决于编译器版本和编译选项。
五、一个 .java 文件一定只会生成一个 .class 文件吗?
不一定。 通常情况下,源码中每个被编译的类、接口、枚举或记录类型都有相应的 Class 文件,但具体数量取决于源码和编译过程。
Calc | Calc.class |
.class 文件 | |
.class 文件 | |
module-info.java | module-info.class |
还有一个值得学习的特殊情况:现代 javac 通常使用 invokedynamic 和相关引导机制来实现 Lambda 表达式。Lambda 表达式一般不会像匿名内部类那样,在编译阶段就为每一个表达式单独生成一个 .class 文件。 运行时的具体实现方式属于 JVM 实现层面的事情。
此外,默认的 javac 编译主要产出 Class 文件;注解处理器可以另外生成源码或资源,-h 选项也可以要求编译器生成供本地方法使用的头文件。这些都是额外输出,不应与普通的 Class 文件产物混为一谈。
六、系统工程师应如何理解编译产物与运行时的边界?
学习 JVMS 时,建议始终区分三个层次。
第一层:语言和编译器。 Java 源码中的变量、表达式、泛型、Lambda、异常和同步语法,需要经过编译器的分析和转换。其中部分语法结构会被降级为更基础的表示。
第二层:Class 文件和 JVM 规范。 .class 定义了 JVM 能理解的类型结构、方法描述符、字节码和属性。JVM 通过加载、链接、验证和解析等环节处理这些内容。Class 文件符合格式要求,并不意味着所有代码一定可以成功运行:类依赖、访问约束、验证规则及运行时条件仍然可能导致错误。
第三层:JVM 实现与硬件。 JVM 规范定义的是可观察行为和执行约束,而不是强制所有 JVM 采用相同的内部数据结构或机器码生成策略。在 HotSpot 等实现中,方法可能由解释器执行,也可能由 JIT 编译器转化为本地机器码;最终具体执行哪段机器指令,还受到处理器架构、JVM 实现和运行时优化等因素影响。
因此,看到某条 iadd 字节码时,不应直接推断 CPU 一定执行某一条固定的加法指令。应当继续追踪 JVM 如何解释这条指令,或者 JIT 如何将相关代码编译为目标平台指令。
七、学习总结与实践清单
建议围绕一个最简单的 Java 类,实际完成下面四项验证:
使用 javac -g -d out Calc.java,确认源码对应的 Class 文件。使用 javap -c -v -p -classpath out Calc,观察魔数对应的文件格式、常量池、方法描述符、Code属性和指令。阅读 OpenJDK 的 Gen.java,理解 Java 语句如何转化为 JVM 字节码。阅读 OpenJDK 的 ClassWriter.java,理解字节码及类信息如何被序列化为.class二进制文件。
相关规范和源码:
JVMS 第 4 章:Class 文件格式 Java SE 26 javac命令文档OpenJDK Gen.javaOpenJDK ClassWriter.java
最终学习结论: javac 将 Java 源码编译为符合 JVM 规范的 Class 文件。Class 文件不是单纯的字节码集合,而是包含类型定义、常量池、方法指令及各类属性的标准二进制结构。理解这个边界,是进一步学习类加载、字节码验证、解释执行、JIT 编译、运行时数据区以及 CPU 指令执行机制的基础。
八、从系统工程师视角理解 Class 文件
1. JVMS 规定的是格式与行为,不是 javac 的内部实现
学习时需要明确:JVMS(Java Virtual Machine Specification)并不规定 javac 必须通过哪些内部编译阶段,也不规定它必须采用什么算法生成字节码。 JVMS 主要定义 JVM 应当能够识别和执行的 Class 文件格式、指令集及运行行为。javac 则是将 Java 程序编译为 Class 文件的编译器实现。
从工程角度看,可以把整个过程拆分为:
Calc.java | | javac:编译 Java 源码 vCalc.class | | JVM:加载、链接、初始化 vJVM 执行引擎 | +--> 解释执行字节码 | +--> JIT 编译为本地机器码(取决于 JVM 实现及运行状态) | v CPU 执行这里最重要的边界是:
javac负责生成标准的 Class 文件。JVMS 规定 Class 文件和字节码的格式、约束及执行语义。 JVM 的具体实现负责加载和执行 Class 文件;某些实现还会进行 JIT 编译。 本地机器码由 JVM 的执行实现产生或由其他工具提供,并不是普通 javac编译过程的默认产物。
2. 从 javac 源码验证字节码生成过程
OpenJDK 源码位置:
Gen.java:javac 字节码生成器
该文件声明了 Gen 类,源码注释明确说明它将处理后的 Java 程序转换为字节码。 下面是 Gen.java 中 genMethod 方法的关键逻辑摘录,并添加学习注释:
// OpenJDK: com.sun.tools.javac.jvm.Genvoid genMethod(JCMethodDecl tree, Env<GenContext> env, boolean fatcode) {// tree 表示当前方法的语法树节点。// env 保存 javac 代码生成阶段需要的上下文。// 省略:参数数量检查,以及构造方法等特殊情况的处理。if (tree.body != null) {// 初始化当前方法的代码生成结构。int startpcCrt = initCode(tree, env, fatcode);// 核心步骤:// 遍历方法体,把语句和表达式转换为 JVM 字节码。 genStat(tree.body, env);// 编译器维护操作数栈状态。// 这里检查生成代码后的栈状态是否符合预期。if (code.state.stacksize != 0) {throw new AssertionError(); }// 如果方法可以正常执行到结尾,// 并且返回类型允许正常落到方法末尾,// 编译器会生成相应的 return 指令。if (code.isAlive()) {// 省略:源码位置、返回类型及特殊控制流处理。// void 方法正常结束时使用 return。 code.emitop0(return_); }// 省略:作用域、代码长度及其他约束检查。 }}这里省略了参数检查、错误处理和特殊控制流等细节;它是对 OpenJDK 真实方法中关键逻辑的摘录,不是完整的可独立编译代码。完整实现请查看上述源码。
学习总结
这段源码可以验证三个结论:
javac会针对方法体生成 JVM 字节码。生成过程不仅涉及指令,还需要维护操作数栈状态、局部变量、跳转和方法结束等信息。 方法体字节码只是 Class 文件的一部分,后续还需要由编译器输出组件写出类、字段、方法及属性等完整结构。
3. 从 ClassWriter.java 理解二进制文件写出
OpenJDK 源码位置:
ClassWriter.java:Class 文件写出组件
这个文件负责把编译器内部的类型和符号信息组织为 Class 文件格式。其二进制写出逻辑包含对多字节数据的处理。
例如,源码中的 putChar 方法可以这样理解:
// OpenJDK: com.sun.tools.javac.jvm.ClassWritervoid putChar(ByteBuffer buf, int op, int x) {// 将整数 x 的高 8 位写入第一个字节。 buf.elems[op] = (byte) ((x >> 8) & 0xFF);// 将整数 x 的低 8 位写入第二个字节。 buf.elems[op + 1] = (byte) (x & 0xFF);}这是源码逻辑摘录,注释用于解释其行为。它说明编译器正在组织二进制文件中的多字节字段,而不是简单地把 Java 源码文本保存成 .class。
JVMS 规定 Class 文件采用连续的字节流表示,多字节数据以大端序存储。因此,Class 文件的版本号、计数器等字段需要按照规定的二进制格式进行编码。
4. 最终要掌握的几个工程结论
第一,Class 文件不是单纯的字节码。 它还包含版本、常量池、类层次结构、字段、方法和各种属性。
第二,Class 文件并不等于本地机器码。 JVM 字节码是虚拟机指令集中的指令;本地机器码则对应具体的处理器架构和执行环境。
第三,javac 的内部源码与 JVMS 规范是不同层次。 阅读 Gen.java 是理解 OpenJDK 如何生成字节码,阅读 ClassWriter.java 是理解它如何写出二进制文件,阅读 JVMS 第 4 章则是理解这些二进制内容必须满足的标准。
第四,一个 Java 源文件不一定只生成一个 Class 文件。 同一文件可以声明多个类型,成员内部类、局部类等也可能产生单独的 Class 文件。此外,注解处理器或其他编译选项还可能产生额外文件。
5. 建议的验证实验
创建 Calc.java:
public class Calc {public static int add(int a, int b) {// 学习点:Java 的整数加法会被编译成 JVM 指令。return a + b; }}执行:
# 编译并输出 Class 文件javac -g -d out Calc.java# 查看字节码和详细 Class 文件结构javap -c -v -p -classpath out Calc在 add 方法中,通常能够观察到类似以下字节码:
0: iload_01: iload_12: iadd3: ireturnjavap -c 用于反汇编方法字节码,-v 用于显示更多 Class 文件信息。
这项实验可以直接验证:源码中的 a + b 经 javac 编译后,会被表示为 JVM 字节码指令,而不是直接变成某一条固定的 CPU 加法指令。
最终总结: 从系统工程师角度学习 JVMS,首先要建立“Java 源码 → Class 文件 → JVM 执行 → 本地机器指令(可能经过 JIT 编译)”的完整认知。之后再沿着 Class 文件结构、常量池、字节码、验证机制、类加载与运行时执行路径逐层阅读规范和 OpenJDK 源码,才能真正理解 Java 程序从编译到运行的底层过程。















































































