目录
  1. 1. 一、javac 编译流水线概览
  2. 2. 二、Parse 阶段:从源码文本到 AST
  3. 3. 三、Enter 阶段:符号表的构建
  4. 4. 四、Annotation Processing 阶段
  5. 5. 五、Flow 分析:确定赋值与可达性
  6. 6. 六、Desugar 阶段:语法糖的解糖
  7. 7. 七、Generate:字节码生成
  8. 8. 八、javac vs d8:分工边界
  9. 9. 面试问答
【深入理解JVM字节码】第七篇、javac编译原理

一、javac 编译流水线概览

javac 的编译流水线可以分为七个阶段(基于 OpenJDK javac 源码,包路径为 com.sun.tools.javac.*):

  1. Parse(解析)JavacParser 读取 .java 源文件,进行词法分析(Scanner / JavaTokenizer)和语法分析,构建 AST(抽象语法树)。每个语法节点对应 com.sun.tools.javac.tree.JCTree 的子类(如 JCMethodDeclJCVariableDeclJCExpressionStatement)。

  2. Enter(符号输入)Enter 类遍历 AST,将类、方法、变量等定义注册到符号表(Symtab / Scope)中。符号表存储每个标识符的类型、作用域和关联的 AST 节点。

  3. Annotation Processing(注解处理)JavacProcessingEnvironment 调用注册的注解处理器(如 Lombok、Dagger、ButterKnife,javax.annotation.processing.Processor 接口)。处理器可以读取 AST 并生成新的源文件或 class 文件。这一步可能触发多轮(round)编译,直到没有新的生成。

  4. Attr(属性分析)Attr 类进行类型检查、名称解析。为 AST 节点标注类型信息(JCTree.type),检查类型兼容性、方法签名匹配等。这是类型安全的保障阶段。

  5. Flow(流分析)Flow 类进行数据流分析,包括:确定变量是否在赋值前使用(definite assignment)、异常处理路径是否完整、语句是否可达(reachability analysis)。Lambda 表达式中 effectively final 变量的检查也在此阶段。

  6. Desugar(解语法糖)Lower 类(有时与 TransTypes 协同)将 Java 语法糖转换为基本的 Java 语言结构。这是理解「javac 生成什么 vs d8 再变换什么」的关键阶段。

  7. Generate(代码生成)Gen 类遍历带类型注解的 AST,生成字节码。为每个方法生成 Code 属性(包括 max_stack、max_locals 计算)、异常表、LineNumberTable 等。

二、Parse 阶段:从源码文本到 AST

JavacParsercom.sun.tools.javac.parser.JavacParser)采用递归下降(recursive descent)解析策略。对于一个简单的类声明:

public class Hello {
public void say(String name) {
System.out.println("Hello " + name);
}
}

解析器生成以下 AST 结构:

  • JCCompilationUnit(根节点)
    • JCClassDecl (name = “Hello”, modifiers = ACC_PUBLIC)
      • JCMethodDecl (name = “say”, returnType = void, params = [name: String])
        • JCBlock
          • JCExpressionStatement
            • JCMethodInvocation (method = “println”, receiver = System.out)
              • JCBinary (operator = “+”, left = “Hello “, right = name)

JavacParser 使用一个前瞻(lookahead)Token 来确定下一个语法规则。对于二义性文法(如 >> 在泛型中应解析为两个 > 还是右移运算符),javac 使用上下文辅助判定。泛型的类型参数括号 <> 的歧义消除是 JavacParser 中最复杂的部分之一。

三、Enter 阶段:符号表的构建

Enter 阶段(com.sun.tools.javac.comp.Enter)的目标是为每个命名元素建立符号(Symbol),并组织到作用域(Scope)中。

关键数据结构:

  • **Symbol.ClassSymbol**:表示一个类的符号,持有类的 Scope(成员作用域)。
  • **Symbol.MethodSymbol**:表示一个方法,包含参数列表符号和返回类型。
  • **Symbol.VarSymbol**:表示变量/字段/参数。
  • **Scope**:以 hash table + chain 实现的作用域,支持嵌套(Scope.next 指向外层作用域)。

Enter 遍历 AST 的流程:

  1. 遇到 JCClassDecl → 创建 ClassSymbol,构建类的 Scope
  2. 遇到 JCMethodDecl → 创建 MethodSymbol,添加到类的 Scope。
  3. 遇到 JCVariableDecl(字段)→ 创建 VarSymbol,添加到类的 Scope。
  4. 遇到 JCImport → 将 import 的类或包信息记录到编译单元的命名上下文中。

符号表的重要特性:延迟成员输入(lazy member enter)。对于大型类(如 android.os.Build),所有成员方法的符号不在一轮中全部完成,而是按需逐步输入,节省内存。

四、Annotation Processing 阶段

JavacProcessingEnvironmentcom.sun.tools.javac.processing.JavacProcessingEnvironment)实现 JSR 269(Pluggable Annotation Processing API)。流程:

  1. 发现:扫描 classpath 中 META-INF/services/javax.annotation.processing.Processor 文件,加载所有注解处理器。
  2. Round 1:javac 完成 Parse 和 Enter 后,将 AST 和符号表提供给所有处理器。处理器可以观察注解、生成警告/错误、创建新的源文件(通过 Filer.createSourceFile)或资源文件。
  3. 后续 Round:如果 Round 1 中生成了新文件,则重新进入 Parse+Enter,开始 Round 2。反复直到没有新文件生成。
  4. 最终 Round:处理器在最终轮次可以释放资源。

在 Android 开发中,APT(替代 KSP)最常见的应用包括:Dagger 的 @Component / @Module 生成工厂类;ButterKnife(已弃用)的 @BindView 生成 ViewBinding 代码;Room 的 @Dao / @Database 生成 DAO 实现类;DataBinding 的 @Bindable 生成 BR 常量。

五、Flow 分析:确定赋值与可达性

Flow 阶段(com.sun.tools.javac.comp.Flow)做的事情远超直觉中的「数据流分析」。核心功能包括:

1. 确定赋值分析(Definite Assignment)

Java 语言规范要求局部变量在使用前必须被确定赋值。Flow 为每个语句标注进入/退出时的赋值状态:

int x;
if (condition) {
x = 1;
} else {
x = 2;
}
System.out.println(x); // x 在这里被确定赋值——两条路径都赋值了

Flow 通过跟踪所有执行路径来判定。如果某条路径中变量可能未被赋值就使用,javac 报错「variable x might not have been initialized」。

2. Effectively Final 检查

Lambda 表达式中捕获的局部变量必须是 effectively final 的。Flow 检查变量赋值次数——如果变量仅被赋值一次(包括声明处的初始化),则是 effectively final。

3. 异常检查

检查受检异常(checked exception)是否被捕获或声明抛出。未被处理的受检异常会在 Flow 阶段报错。

4. 可达性分析

检测 returnbreakcontinuethrow 之后的代码是否不可达。javac 对不可达代码的处理比较微妙——它只在语句不可达时报警,而对表达式不可达保持静默(这是 Java 语言规范的设计:允许 if (false) { ... } 作为条件编译手段)。

六、Desugar 阶段:语法糖的解糖

Desugar 阶段(Lower 类 + TransTypes 类)将 Java 高级语言特性翻译为基本的语言结构。这是理解「javac 生成什么字节码」的核心。

1. try-with-resources(Java 7)

try (BufferedReader br = new BufferedReader(...)) {
return br.readLine();
}

javac 解糖后等价于:

BufferedReader br = new BufferedReader(...);
Throwable $primary = null;
try {
return br.readLine();
} catch (Throwable $t) {
$primary = $t;
throw $t;
} finally {
if (br != null) {
if ($primary != null) {
try { br.close(); } catch (Throwable $suppressed) { $primary.addSuppressed($suppressed); }
} else {
br.close();
}
}
}

2. for-each 循环(Java 5)

for (String s : list) {
System.out.println(s);
}

解糖为:

for (Iterator<String> $it = list.iterator(); $it.hasNext(); ) {
String s = $it.next();
System.out.println(s);
}

对数组的 for-each 解糖为:

String[] $arr = array;
for (int $i = 0; $i < $arr.length; $i++) {
String s = $arr[$i];
System.out.println(s);
}

3. String switch(Java 7)

switch (str) {
case "A": doA(); break;
case "B": doB(); break;
default: doDefault();
}

解糖为两阶段 switch:首先对 str.hashCode()lookupswitch,然后对匹配的 hashCode 再对 str.equals() 做字符串精确比较。

4. Diamond Operator(Java 7)

List<String> list = new ArrayList<>();

<> 在 javac 中不产生任何字节码变化——它只是编译期类型推断的提示。擦除后生成的字节码与 new ArrayList() 完全一致。

5. Varargs(Java 5)

void foo(String... args) {}
foo("a", "b");

javac 在调用点解糖为:

foo(new String[]{"a", "b"});

并且被调用方法在 class 文件中增加了 ACC_VARARGS 标志和 [Ljava/lang/String; 的参数类型。

七、Generate:字节码生成

Generate 阶段(com.sun.tools.javac.jvm.Gen)将类型标注后的 AST 转换为方法体的字节码序列。Gen 使用 Code 对象(com.sun.tools.javac.jvm.Code)来暂存指令流,指令通过 ItemsCode.emitXxx() 系列方法生成。

关键职责:

  • 为每个 JCMethodDecl 生成 Code 属性,包含实际的 JVM 指令序列。
  • 计算 max_stack(通过模拟指令执行跟踪操作数栈深度)和 max_locals
  • 为带有 try-catch 的代码生成异常表(Exception table)。
  • 生成 LineNumberTable 和 LocalVariableTable 调试属性。
  • 生成 StackMapTable 属性(Java 7+ 的类型检查字节码验证所需)。

Gen 不负责的工作:Gen 只生成 .class 文件。在 Android 上,后续还有 d8(或旧的 dx)编译器将 .class 转换为 .dex 格式。d8 会做额外的优化:常量折叠、死代码消除、方法内联、字符串常量去重等。

八、javac vs d8:分工边界

理解 javac 和 d8 的分工对 Android 开发者至关重要:

阶段 javac d8
输入 .java 源文件 .class 文件(javac 输出)
输出 .class(JVM bytecode) .dex(Dalvik bytecode)
语法糖处理 解糖 try-with-resources、for-each、switch-string 等 解糖 lambdas、default methods、static interface methods(Java 8+ 特性,在 Android 上由 d8 的 desugaring 处理)
优化 少量常量折叠 主要优化器:常量传播、死代码消除、方法内联、字符串去重
类型系统 JVM 类型系统 Dalvik 类型系统(基于寄存器的指令集)

d8 的一个关键角色是 API desugaring。当 Java 8 的 java.timejava.util.stream 等 API 在旧版 Android 上不可用时,d8 能将调用这些 API 的代码转换为对 desugar_jdk_libs 支持库的调用。


面试问答

Q1:javac 编译的七个阶段分别是什么?哪些阶段是在 AST 上操作的?

A:七个阶段依次是:Parse(解析源码生成 AST)、Enter(构建符号表)、Annotation Processing(注解处理器读写 AST 并可能生成新源码)、Attr(类型检查与属性标注)、Flow(数据流分析:确定赋值、可达性、effectively final)、Desugar(解语法糖,Lower 将高级语法转为基本结构)、Generate(Gen 从 AST 生成字节码)。Parse、Enter、Annotation Processing、Attr、Desugar 都在 AST 上操作。Flow 需要 AST 和符号表。Generate 读取 AST 输出 Code 指令序列。

Q2:try-with-resources 在 javac 的 Desugar 阶段是如何展开的?字节码中会出现哪些结构?

A:javac 将 try-with-resources 展开为一个多层 try-catch-finally 结构。生成的字节码中会出现:资源变量声明和初始化、Throwable $primary 变量用于记录主异常、外层 try-catch 捕获所有 Throwable、finally 块中检查资源是否为 null 并调用 .close()、如果主异常存在则通过 Throwable.addSuppressed() 附加 close 异常(suppressed exception)、多个 athrow 用于重新抛出。因此 try-with-resources 的字节码远比表面看到的复杂,包含嵌套的异常表和多个跳转指令。

Q3:d8 的 desugaring 解决了什么问题?与 javac 的 Desugar 有何不同?

A:d8 desugaring 解决的核心问题是 Android 平台碎片化——新版本 Java API(如 java.time.LocalDatejava.util.stream.Streamjava.util.Optional)在低版本 Android 上根本不存在。d8 在编译时将对这些 API 的调用重写为对 desugar_jdk_libs 库中替代实现的调用。这与 javac 的 Desugar 不同:javac Desugar 处理的是语法级的糖(try-with-resources、for-each),在 .class 生成前完成;d8 desugaring 处理的是API 级的缺失,在 .class → .dex 转换阶段完成。此外,d8 还处理 interface 中的 default 方法和 static 方法(Java 8+),这在老版本 ART/Dalvik 上对应companion class 模式。

Q4:String switch 的字节码是如何实现 O(1) 分派的?

A:javac 将 String switch 转换为两阶段分派。第一阶段对 str.hashCode()lookupswitch(哈希值通常稀疏,所以用 lookupswitch 而非 tableswitch),跳转到对应哈希值的代码块。第二阶段在每个哈希匹配的代码块中对 str.equals(caseValue) 做精确比较,如果 equals 返回 true 则跳转到对应的业务代码块,否则 fall through 到 default。因为有哈希碰撞的可能性,同一个哈希值的代码块中可能有多个 if-equals 级联。这个两阶段设计使得 String switch 的平均复杂度接近 O(1),最坏情况下 O(n)(所有 case 字符串哈希碰撞)。

打赏
  • 微信
  • 支付宝

评论