JVM规范学习总结-Java源码经过javac编译后产物

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 文件)
    • [三、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. 建议的验证实验)

前言

本文旨在记录近期研读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[]; // 类级元数据和其他属性
}

这是一份学习用的结构摘录:数组长度在规范中由对应的计数字段决定,上述写法简化了具体数组声明。

重点理解六类信息

  1. 文件标识和版本

魔数为 0xCAFEBABE。主、次版本号用于标识 Class 文件格式版本,帮助 JVM 判断文件是否符合其支持范围。

  1. 常量池

存储类名、字段名、方法名、类型描述符、字符串常量和符号引用等。字节码可以通过常量池索引引用类、字段或方法,而不是在每条指令中重复存放完整名称。

  1. 类、父类和接口信息

描述当前类型的名称、访问标志、父类和接口。这些信息参与类加载、类型检查、链接和方法解析。

  1. 字段信息

包括字段名称、类型描述符、访问标志及相关属性。它描述类声明的字段,但不等同于某个对象在堆中的实际内存布局。

  1. 方法信息和字节码

包括方法名、描述符、访问标志和属性。具有方法体的普通 Java 方法,其 JVM 指令通常存放在 Code 属性中;抽象方法和本地方法则没有 Code 属性。

  1. 属性与元数据

例如 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:javac 字节码生成器

该文件声明了 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 真实方法中关键逻辑的摘录,不是完整的可独立编译代码。完整实现请查看上述源码。

学习总结

这段源码可以验证三个结论:

  1. javac 会针对方法体生成 JVM 字节码。
  2. 生成过程不仅涉及指令,还需要维护操作数栈状态、局部变量、跳转和方法结束等信息。
  3. 方法体字节码只是 Class 文件的一部分,后续还需要由编译器输出组件写出类、字段、方法及属性等完整结构。

3. 从 ClassWriter.java 理解二进制文件写出

OpenJDK 源码位置:

ClassWriter.java:Class 文件写出组件

这个文件负责把编译器内部的类型和符号信息组织为 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 程序从编译到运行的底层过程。

相关推荐
java_nnnn1 小时前
Linux 学习第二篇:从零认识 Linux 文件系统、目录结构与基本命令
linux·运维·学习·1024程序员节
ly76891 小时前
Spring Security 过滤器链定制:认证、CORS 与 CSRF 的先后顺序
java·spring boot·spring·spring security·csrf·cors·过滤器链
蹊黎1 小时前
C++中stack、queue、priority queue类的部分简单实现
数据结构·c++·visualstudio·1024程序员节
yuniko-n1 小时前
【JUC】读写锁 StampedLock
java·1024程序员节
小白今天也很酷1 小时前
MCXN947更新板载DAP_Link固件的方法
c语言·python·单片机·ubuntu·excel·1024程序员节
.道阻且长.1 小时前
Linux 基本开发工具:编辑器vim,配置vim
linux·运维·服务器·职场和发展·编辑器·vim·1024程序员节
攻城狮Soar1 小时前
详解volatile
开发语言·c++·1024程序员节
文丑颜不良啊1 小时前
LeetCode701~725题目解析
java·leetcode
江屿风1 小时前
【Linux-System】【System V共享内存:从原理到实战的完整解析】流食般投喂
linux·运维·服务器·笔记·系统架构·1024程序员节