Java源码经过javac编译后产物
- 前言
- [Java 源码经过 `javac` 编译后生成什么?](#Java 源码经过
javac编译后生成什么?) -
- 一、核心结论
-
- [Java 源码](#Java 源码)
- [Class 文件](#Class 文件)
- [JVM 执行引擎](#JVM 执行引擎)
- [CPU 执行](#CPU 执行)
- [二、`javac` 编译过程:从 `.java` 到 `.class`](#二、
javac编译过程:从.java到.class) -
- [1. OpenJDK 源码:`Gen.java` 如何生成字节码](#1. OpenJDK 源码:
Gen.java如何生成字节码) - [2. OpenJDK 源码:`ClassWriter.java` 如何写出 Class 文件](#2. OpenJDK 源码:
ClassWriter.java如何写出 Class 文件)
- [1. OpenJDK 源码:`Gen.java` 如何生成字节码](#1. OpenJDK 源码:
- [三、JVMS 规定的 Class 文件具体包含什么?](#三、JVMS 规定的 Class 文件具体包含什么?)
- 四、通过一个实际例子验证编译产物
-
- [1. 编写 Java 源码](#1. 编写 Java 源码)
- [2. 编译得到 Class 文件](#2. 编译得到 Class 文件)
- [3. 查看字节码](#3. 查看字节码)
- [五、一个 `.java` 文件一定只会生成一个 `.class` 文件吗?](#五、一个
.java文件一定只会生成一个.class文件吗?) - 六、系统工程师应如何理解编译产物与运行时的边界?
- 七、学习总结与实践清单
- [八、从系统工程师视角理解 Class 文件](#八、从系统工程师视角理解 Class 文件)
-
- [1. JVMS 规定的是格式与行为,不是 `javac` 的内部实现](#1. JVMS 规定的是格式与行为,不是
javac的内部实现) - [2. 从 `javac` 源码验证字节码生成过程](#2. 从
javac源码验证字节码生成过程) - [3. 从 `ClassWriter.java` 理解二进制文件写出](#3. 从
ClassWriter.java理解二进制文件写出) - [4. 最终要掌握的几个工程结论](#4. 最终要掌握的几个工程结论)
- [5. 建议的验证实验](#5. 建议的验证实验)
- [1. JVMS 规定的是格式与行为,不是 `javac` 的内部实现](#1. JVMS 规定的是格式与行为,不是
前言
本文旨在记录近期研读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 的主要工作概括为以下几个阶段。
| 阶段 | 主要工作 | 系统工程师需要关注的内容 |
|---|---|---|
| 1. 词法与语法分析 | 将源码转换成语法树 | 语法是否合法 |
| 2. 符号处理 | 建立类、字段、方法等符号信息 | 类型之间的依赖关系 |
| 3. 注解处理 | 按需执行注解处理器 | 是否生成额外源码或资源 |
| 4. 语义分析 | 类型检查、方法解析、控制流检查等 | 编译期错误如何产生 |
| 5. 代码降级 | 将部分高级语言结构转换成较基础的表示 | 语法糖如何影响字节码 |
| 6. 字节码生成 | 生成 JVM 指令、处理方法和栈帧相关信息 | 指令、操作数栈、局部变量 |
| 7. Class 文件写出 | 序列化常量池、字段、方法和属性 | 最终二进制格式 |
这是便于学习的阶段划分,不代表所有阶段在每个 JDK 版本的内部实现中都严格以独立步骤顺序执行。官方 javac 文档说明了其源码编译和 Class 文件输出职责;OpenJDK 源码则可以进一步验证字节码生成和文件写出的实现。
1. OpenJDK 源码:Gen.java 如何生成字节码
OpenJDK 源码位置:
src/jdk.compiler/share/classes/com/sun/tools/javac/jvm/Gen.java
该文件开头说明了它的主要职责:
java
/**
* This pass maps flat Java (i.e. without inner classes) to bytecodes.
*
* // 学习注释:
* // Gen 是 javac 中负责字节码生成的重要组件。
* // 它把经过前序处理的 Java 程序表示转换成 JVM 字节码。
*/
public class Gen extends JCTree.Visitor {
// 后续负责生成方法指令、处理表达式及语句等。
}
下面是该文件中方法生成逻辑的摘录,省略了与原理无关的上下文,并在关键位置添加学习注释:
java
// OpenJDK: com.sun.tools.javac.jvm.Gen
void 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 文件。相关代码如下:
java
// OpenJDK: com.sun.tools.javac.jvm.ClassWriter
public 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 还会统计并写出字段和方法:
java
// 以下代码摘录自 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[]; // 类级元数据和其他属性
}
这是一份学习用的结构摘录:数组长度在规范中由对应的计数字段决定,上述写法简化了具体数组声明。
重点理解六类信息
- 文件标识和版本
魔数为 0xCAFEBABE。主、次版本号用于标识 Class 文件格式版本,帮助 JVM 判断文件是否符合其支持范围。
- 常量池
存储类名、字段名、方法名、类型描述符、字符串常量和符号引用等。字节码可以通过常量池索引引用类、字段或方法,而不是在每条指令中重复存放完整名称。
- 类、父类和接口信息
描述当前类型的名称、访问标志、父类和接口。这些信息参与类加载、类型检查、链接和方法解析。
- 字段信息
包括字段名称、类型描述符、访问标志及相关属性。它描述类声明的字段,但不等同于某个对象在堆中的实际内存布局。
- 方法信息和字节码
包括方法名、描述符、访问标志和属性。具有方法体的普通 Java 方法,其 JVM 指令通常存放在 Code 属性中;抽象方法和本地方法则没有 Code 属性。
- 属性与元数据
例如 SourceFile、LineNumberTable、StackMapTable、Signature 和注解相关属性。它们可以为验证、调试、泛型信息和注解处理等提供支持。
这里有一个容易忽略的区别:LocalVariableTable 等调试属性并非默认在所有编译配置下都完整存在。javac 的 -g、-g:lines、-g:vars、-g:none 等选项会影响相应调试信息的生成。
四、通过一个实际例子验证编译产物
1. 编写 Java 源码
创建 Calc.java:
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 文件
在终端运行:
bash
# -g:生成调试信息
# -d out:将编译产生的 Class 文件放进 out 目录
javac -g -d out Calc.java
预期得到:
out/
└── Calc.class
这里的 Calc.class 是二进制文件,而不是另一份 Java 源码,也不是一份可直接交给 CPU 执行的本地可执行文件。
3. 查看字节码
执行:
bash
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 |
将局部变量槽位 0 中的 int 值压入操作数栈 |
iload_1 |
将局部变量槽位 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 |
| 使用注解处理器 | 还可能生成额外源码、资源或 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二进制文件。
相关规范和源码:
最终学习结论: 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 源码
v
Calc.class
|
| JVM:加载、链接、初始化
v
JVM 执行引擎
|
+--> 解释执行字节码
|
+--> JIT 编译为本地机器码(取决于 JVM 实现及运行状态)
|
v
CPU 执行
这里最重要的边界是:
javac负责生成标准的 Class 文件。- JVMS 规定 Class 文件和字节码的格式、约束及执行语义。
- JVM 的具体实现负责加载和执行 Class 文件;某些实现还会进行 JIT 编译。
- 本地机器码由 JVM 的执行实现产生或由其他工具提供,并不是普通
javac编译过程的默认产物。
2. 从 javac 源码验证字节码生成过程
OpenJDK 源码位置:
该文件声明了 Gen 类,源码注释明确说明它将处理后的 Java 程序转换为字节码。
下面是 Gen.java 中 genMethod 方法的关键逻辑摘录,并添加学习注释:
java
// OpenJDK: com.sun.tools.javac.jvm.Gen
void 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 源码位置:
这个文件负责把编译器内部的类型和符号信息组织为 Class 文件格式。其二进制写出逻辑包含对多字节数据的处理。
例如,源码中的 putChar 方法可以这样理解:
java
// OpenJDK: com.sun.tools.javac.jvm.ClassWriter
void 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:
java
public class Calc {
public static int add(int a, int b) {
// 学习点:Java 的整数加法会被编译成 JVM 指令。
return a + b;
}
}
执行:
bash
# 编译并输出 Class 文件
javac -g -d out Calc.java
# 查看字节码和详细 Class 文件结构
javap -c -v -p -classpath out Calc
在 add 方法中,通常能够观察到类似以下字节码:
0: iload_0
1: iload_1
2: iadd
3: ireturn
javap -c 用于反汇编方法字节码,-v 用于显示更多 Class 文件信息。
这项实验可以直接验证:源码中的 a + b 经 javac 编译后,会被表示为 JVM 字节码指令,而不是直接变成某一条固定的 CPU 加法指令。
最终总结: 从系统工程师角度学习 JVMS,首先要建立"Java 源码 → Class 文件 → JVM 执行 → 本地机器指令(可能经过 JIT 编译)"的完整认知。之后再沿着 Class 文件结构、常量池、字节码、验证机制、类加载与运行时执行路径逐层阅读规范和 OpenJDK 源码,才能真正理解 Java 程序从编译到运行的底层过程。