目录
  1. 1. 一、javac 编译流水线概览
  2. 2. 二、Parse 阶段:从源码文本到 AST
    1. 2.1. 2.1 词法分析:JavaTokenizer
    2. 2.2. 2.2 语法分析:JavacParser
    3. 2.3. 2.3 歧义处理:菱形运算符与泛型
    4. 2.4. 2.4 错误恢复机制
    5. 2.5. 2.5 行号与列号的错误报告
  3. 3. 三、Enter 阶段:符号表的构建
    1. 3.1. 3.1 符号表架构
    2. 3.2. 3.2 作用域的实现:Scope
    3. 3.3. 3.3 MemberEnter:遍历 AST 注册符号
    4. 3.4. 3.4 延迟成员输入(Lazy Member Enter)
  4. 4. 四、Annotation Processing 阶段
    1. 4.1. 4.1 JSR 269 注解处理框架
    2. 4.2. 4.2 处理轮次(Rounds)
    3. 4.3. 4.3 注解处理器生成代码的内部流程
    4. 4.4. 4.4 Android 中的 APT 与 KSP
  5. 5. 五、Attr 阶段:属性标记与类型检查
    1. 5.1. 5.1 Attr 的职责
    2. 5.2. 5.2 类型检查的核心流程
    3. 5.3. 5.3 类型推断与泛型
  6. 6. 六、Flow 阶段:数据流分析
    1. 6.1. 6.1 Flow 的整体功能
    2. 6.2. 6.2 确定赋值分析的实现
    3. 6.3. 6.3 Effectively Final 检查
    4. 6.4. 6.4 异常检查
    5. 6.5. 6.5 可达性分析
  7. 7. 七、LambdaToMethod:Lambda 的脱糖
    1. 7.1. 7.1 Lambda 在字节码中的两种策略
    2. 7.2. 7.2 合成方法的命名规则
    3. 7.3. 7.3 方法引用的处理
  8. 8. 八、Desugar 阶段:语法糖的解糖
    1. 8.1. 8.1 Lower 的整体职责
    2. 8.2. 8.2 嵌套类与匿名类的提升
    3. 8.3. 8.3 String switch 的转换
    4. 8.4. 8.4 try-with-resources 的完整展开
    5. 8.5. 8.5 foreach 循环的两种展开
    6. 8.6. 8.6 其他语法糖消除
  9. 9. 九、Generate 阶段:字节码生成
    1. 9.1. 9.1 Gen 的职责与 Code 对象
    2. 9.2. 9.2 AST → Bytecode 的映射
    3. 9.3. 9.3 常量池的构建
    4. 9.4. 9.4 max_stack 与 max_locals 的计算
    5. 9.5. 9.5 StackMapTable 的生成
    6. 9.6. 9.6 异常表(Exception Table)的生成
    7. 9.7. 9.7 LineNumberTable 和 LocalVariableTable
  10. 10. 十、完整示例:跟踪一个简单类的编译全过程
    1. 10.1. 10.1 源文件
    2. 10.2. 10.2 阶段 1:Parse
    3. 10.3. 10.3 阶段 2:Enter
    4. 10.4. 10.4 阶段 4:Attr
    5. 10.5. 10.5 阶段 5:Flow
    6. 10.6. 10.6 阶段 6:Desugar
    7. 10.7. 10.7 阶段 7:Generate
    8. 10.8. 10.8 最终的 .class 文件结构
  11. 11. 十一、javac vs d8:分工边界与协通
  12. 12. 面试问答
【深入理解JVM字节码】第七篇、javac编译原理

一、javac 编译流水线概览

javac 是 OpenJDK 中的 Java 编译器,源码位于 src/jdk.compiler/share/classes/com/sun/tools/javac/。整个编译流水线分为七个核心阶段,每个阶段由独立的编译组件负责,以 AST(Abstract Syntax Tree,抽象语法树)为数据中枢在各阶段间传递。

这七个阶段依次执行,前一个阶段的输出是后一个阶段的输入:

阶段 组件 输入 输出 核心职责
1. Parse JavacParser + JavaTokenizer .java 字符流 JCTree AST 词法分析 + 语法分析,构建语法树
2. Enter Enter + MemberEnter AST AST + 符号表 将类/方法/变量定义注册到符号表
3. Annotation Processing JavacProcessingEnvironment AST + 符号表 可能的新源文件 调用注解处理器,支持生成新源码
4. Attr Attr + Check + Resolve AST + 符号表 类型标注的 AST 类型检查、名称解析、方法重载决议
5. Flow Flow + LambdaToMethod 类型标注的 AST 流分析完成的 AST 确定赋值、异常检查、可达性、Lambda 脱糖
6. Desugar Lower + TransTypes 流分析完成的 AST 解糖后的 AST 移除语法糖:嵌套类提升、switch-string、try-with-resources
7. Generate Gen + Code 解糖后的 AST .class 字节数组 AST → JVM 字节码指令序列 + 常量池 + StackMapTable

这几个阶段的执行入口在 com.sun.tools.javac.main.JavaCompiler 类中,大致的调用链为:

JavaCompiler.compile()
→ parseFiles() // 阶段 1:Parse
→ enterTrees() // 阶段 2:Enter(含 MemberEnter)
→ processAnnotations() // 阶段 3:Annotation Processing(可能触发新一轮 Parse+Enter)
→ attribute() // 阶段 4:Attr
→ flow() // 阶段 5:Flow
→ desugar() // 阶段 6:Desugar
→ generate() // 阶段 7:Generate

理解 javac 的每一个阶段,不仅能帮助我们深入理解 Java 语言特性的编译实现,也为后续学习字节码注入(Transform/ASM)和 d8/R8 优化打下基础——因为 javac 的输出(.class 文件)是所有 Android 字节码工具链的起点。

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

2.1 词法分析:JavaTokenizer

com.sun.tools.javac.parser.JavaTokenizer 负责将 .java 源文件的字符流转换为 Token 序列。javac 使用 Unicode 字符集,且支持 Unicode 转义(\uXXXX)——词法分析阶段需要先将所有 \uXXXX 转义序列还原为实际字符(在 UnicodeReader 中完成),然后再进行 Token 切分。

JavaTokenizer 将输入的字符流切分为以下 Token 类型(定义在 com.sun.tools.javac.parser.Tokens.TokenKind 枚举中):

Token 类别 示例
关键字 class, public, void, if, else, return, new
标识符 myVariable, MyClass, MAX_VALUE
字面量 42, 3.14, "hello", 'c', true, null
运算符/分隔符 +, -, ==, {, }, ;, ., @
特殊 EOF(文件结束标记)、ERROR(错误 Token)

JavaTokenizer 的内部状态机通过 nextToken() 方法驱动,每次调用返回一个 Token 对象。Token 记录了它在源文件中的位置信息(行号 line,列号 column,字符偏移 pos),这些信息用于后续的编译错误报告。

词法分析中的几个关键细节:

(1)数字字面量的解析:JavaTokenizer 需要处理十进制、十六进制(0x)、八进制(0 前缀)、二进制(0b)整数,以及 float/double 字面量(含科学计数法)。Java 7 引入的数字下划线分隔符(1_000_000)也在词法分析阶段处理——下划线只是提高了可读性,Tokenizer 会跳过它们并正常解析数字值。注意:下划线不能出现在数字的开头、结尾、小数点旁边、或 0x/0b 的旁边,这些规则完全在 Tokenizer 中校验。

(2)字符串字面量的处理:字符串在词法分析时处理转义序列(\n\t\\\" 等),并将结果存储在 Token 的 stringVal() 字段中。这包括 Unicode 转义( = 换行符)。注意字符串字面量在 Tokenizer 层面不做拼接——相邻字符串字面量的拼接("a" + "b""ab")是在后续的常量折叠阶段(AttrConstFold)完成的。

(3)注释的处理:单行注释 // 和多行注释 /* */ 在词法分析阶段被识别并丢弃(不产生 Token),但 javac 内部维护了注释与 AST 节点的关联(通过 DocCommentTable),以便注解处理器和 javadoc 工具使用。文档注释 /** */ 被特殊处理,关联到最近的声明元素上。

2.2 语法分析:JavacParser

com.sun.tools.javac.parser.JavacParser 采用递归下降(recursive descent)解析策略。解析器持有一个前瞻(lookahead)Token,根据当前 Token 的类型来决定应用哪条语法规则。递归下降解析器的核心特征是为每个非终结符(语法规则)编写一个独立的方法,方法内部根据 Token 类型进行分支和递归调用。

JavacParser 的主要解析方法对应关系:

Parser 方法 解析的语法结构 生成的 AST 节点
parseCompilationUnit() 整个 .java 文件 JCCompilationUnit
classDeclaration() 类/接口/枚举声明 JCClassDecl
methodDeclaration() 方法声明 JCMethodDecl
variableDeclarator() 变量/字段声明 JCVariableDecl
parseStatement() 各类语句 JCStatement 子类
parseExpression() 各类表达式 JCExpression 子类

对于如下简单类:

package com.example;

import java.util.List;

public class Hello {
private String message = "World";

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

解析器生成的 AST 结构为:

JCCompilationUnit
├── pid: JCFieldAccess (com.example) — package 声明
├── defs: List<JCTree>
│ ├── JCImport (java.util.List)
│ ├── JCClassDecl (name = "Hello", modifiers = ACC_PUBLIC)
│ │ ├── defs:
│ │ │ ├── JCVariableDecl (name = "message", vartype = String, init = "World")
│ │ │ │ └── modifiers: JCModifiers (ACC_PRIVATE)
│ │ │ └── JCMethodDecl (name = "say", restype = void, params = [JCVariableDecl name: String])
│ │ │ ├── body: JCBlock
│ │ │ │ └── stats:
│ │ │ │ └── JCExpressionStatement
│ │ │ │ └── expr: JCMethodInvocation
│ │ │ │ ├── meth: JCFieldAccess (System.out.println)
│ │ │ │ └── args:
│ │ │ │ └── JCBinary (operator = "+")
│ │ │ │ ├── lhs: JCLiteral ("Hello, ")
│ │ │ │ └── rhs: JCIdent (name)

JCTree 是整个 AST 的基类,位于 com.sun.tools.javac.tree.JCTree。它的核心子类包括:

  • **JCStatement**:所有语句的基类,子类包括 JCBlock(代码块)、JCIf(if 语句)、JCForLoop(for 循环)、JCWhileLoop(while 循环)、JCReturn(return 语句)、JCExpressionStatement(表达式语句)等。
  • **JCExpression**:所有表达式的基类,子类包括 JCLiteral(字面量)、JCIdent(标识符引用)、JCMethodInvocation(方法调用)、JCNewClass(new 表达式)、JCBinary(二元表达式)、JCUnary(一元表达式)、JCAssign(赋值)等。
  • **JCClassDecl**:类/接口/枚举声明。
  • **JCMethodDecl**:方法声明。
  • **JCVariableDecl**:变量/字段声明。

每个 JCTree 节点都有一个 pos 字段(源文件中的字符偏移位置),用于编译错误报告。AST 节点同时携带 type 字段——它在 Parse 阶段为 null,将在后续 Attr 阶段被填充。

2.3 歧义处理:菱形运算符与泛型

JavacParser 中最复杂的语法歧义处理之一是泛型类型参数的尖括号 <>。在 Java 语法中,< 既可以是小于运算符,也可以是泛型类型参数的开括号。歧义处理的策略是上下文相关的前瞻解析

// 情况 1:泛型中的 <>
List<String> list = new ArrayList<>();

// 情况 2:小于运算符
if (a < b > c) { ... } // 实际上 a < b 返回 boolean,> c 是语法错误

// 情况 3:位右移 >> vs 泛型嵌套 >>
Map<String, List<Integer>> map = new HashMap<>(); // >> 应解析为两个 >

对于 >> 的歧义,javac 使用了一个特殊规则:当解析器处于泛型类型参数的上下文中时,>> 被解析为两个 >(一个结束内层泛型参数,一个结束外层泛型参数)。当处于表达式上下文中时,>> 被解析为右移运算符。这个判断在 JavacParserterm2()typeArguments() 方法中实现。

JavacParser 的另一个关键设计是 lookahead 机制。对于需要前瞻才能确定语法的情况(如 (expr) 可能是强制类型转换也可能是括号表达式),解析器使用 S 参数化前瞻(Scanner.Factory),在不确定时使用一个独立的扫描器尝试解析备选语法,成功则采用该路径,失败则回溯到原路径。这避免了全局回溯的同时,保留了处理语法歧义的能力。

2.4 错误恢复机制

javac 的一大优势是其语法错误恢复(error recovery)能力——即使在源文件存在语法错误的情况下,编译器依然能够继续解析后面的代码,从而在一次编译中报告尽可能多的错误(而非遇到第一个错误就停止)。

JavacParser 的错误恢复策略基于”跳读”(skip / resync)机制:

核心策略:跳读到同步 Token

当解析器在当前语法规则中遇到不符合预期的 Token 时,它使用 S.skip(boolean stopAtError) 方法跳过后续的 Token,直到遇到一个”同步 Token”——通常是分号 ;、右花括号 }、或关键字(classvoidint 等声明关键字),这些 Token 标志着新语句或新声明的开始。

具体流程为:

  1. 解析器检测到语法错误 → 调用 log.error(pos, key) 报告错误。
  2. 调用 S.skip(true) 跳读到下一个同步点。
  3. 错误恢复后继续解析剩余的源文件,从同步点继续正常的语法分析。

示例:

public class Demo {
public void broken() {
int x = ; // 错误:缺少初始值
System.out.println(x); // 跳过错行分号后正常解析这一行
}
}

javac 会报告 int x = ; 行缺少表达式,但 System.out.println(x) 仍会被正常解析——JavacParservariableDeclarator() 方法中遇到 = 后无法解析表达式时,会 skip 到 ;,然后继续解析后续语句。

错误恢复的限制:大括号不匹配时错误恢复效果较差。如果缺少一个 },编译器可能将后续的所有内容视为当前代码块的继续,导致一连串的级联错误(cascading errors)。IDE(如 IntelliJ IDEA 和 Eclipse)的解析器通常有更复杂的错误恢复策略(如推断缺失的大括号位置),因为它们需要支持不完整代码的增量解析,而 javac 的解析器偏向于保守的错误恢复。

2.5 行号与列号的错误报告

javac 的错误消息包含精确的源文件和行列号信息,这来源于每个 AST 节点和 Token 都携带的 DiagnosticPosition

// com.sun.tools.javac.util.Position 中
// pos 编码格式:(line << Position.LINESHIFT) | column
// LINESHIFT = 10 (在较新版本中有所调整)

// 典型的错误报告示例:
// Hello.java:15: error: cannot find symbol
// System.out.println(misspelled);
// ^
// symbol: variable misspelled
// location: class Hello

log.error() 方法接收 JCDiagnostic.DiagnosticPosition,从中提取行号和列号,然后通过 JavacMessages 加载国际化错误消息模板,填充参数后输出。位置信息从 Token 的 pos / endPos 或 AST 节点中获取——这也是为什么 AST 节点需要在 Parse 阶段就携带位置信息。

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

3.1 符号表架构

Enter 阶段由 com.sun.tools.javac.comp.Enter 负责。它将 Parse 阶段生成的 AST 中的类、方法、变量等声明注册到符号表(Symbol Table)中。符号表是后续所有阶段(类型检查、流分析、代码生成)的核心基础设施——它不仅存储了”哪些标识符存在”,还记录了每个标识符的类型、可见性、所属作用域等信息。

符号体系(com.sun.tools.javac.code.Symbol):

符号类型 对应声明 关键字段
Symbol.PackageSymbol package 声明 fullname, members (Scope)
Symbol.ClassSymbol class/interface/enum 声明 name, owner, type, members (Scope), supertype, interfaces
Symbol.MethodSymbol 方法声明 name, owner, type (MethodType), params
Symbol.VarSymbol 字段/局部变量/参数 name, owner, type, pos
Symbol.OperatorSymbol 内置运算符 预定义的运算符符号
Symbol.TypeSymbol 类型相关的符号抽象 name 作为独立实体

Symtabcom.sun.tools.javac.code.Symtab)是一个预初始化的”宇宙符号表”,包含了所有 Java 语言预定义的类型和符号:原始类型(intlongfloat 等)、java.lang.Objectjava.lang.Stringjava.lang.Throwablejava.lang.annotation.Annotation、数组的 clone() 方法等。Symtab 在编译器初始化时创建,后续所有阶段通过它来引用这些已知类型。

3.2 作用域的实现:Scope

com.sun.tools.javac.code.Scope 是符号表的存储容器。作用域是嵌套的——每个作用域有一个 next 指针指向外层作用域,形成链式结构。当查找一个符号时,先搜索当前作用域,找不到则沿 next 链向外查找。

Scope 内部使用哈希表 + 冲突链实现。核心结构:

// com.sun.tools.javac.code.Scope 的核心字段(简化)
public class Scope {
public final Scope next; // 外层作用域
public final Symbol owner; // 此作用域所属的符号(如 ClassSymbol)
public Entry[] table; // 哈希表
int hashMask; // 哈希掩码

// Entry 是 Scope 中的单个条目(链表节点)
public static class Entry {
public Symbol sym; // 符号引用
public Entry shadowed; // 同一槽位的下一个 Entry(冲突链)
public Entry sibling; // 同一作用域中的下一个 Entry(顺序链)
public Scope scope; // 如果 sym 是 ClassSymbol,这是它的成员 Scope
}
}

符号查找(Scope.lookup(Name name))通过 name.hashCode() & hashMask 定位到哈希槽,遍历冲突链匹配 name,找到后返回 Entry。符号插入(Scope.enter(Symbol sym))类似地定位到哈希槽,将新 Entry 插入冲突链的头部。

关键:当 sym 是一个 ClassSymbol 时,它的 Entry.scope 字段指向该类的成员 Scope——这是作用域嵌套的具体实现。查找一个类的方法时,先在类的成员 Scope 中查找,找不到再到父类的成员 Scope 中查找。

3.3 MemberEnter:遍历 AST 注册符号

Enter 的第一阶段(通常称为”first phase”)只处理顶级声明(package、import、顶级类)。然后 MemberEntercom.sun.tools.javac.comp.MemberEnter)负责处理类内部的成员(字段、方法、内部类等)。

MemberEnter 的遍历策略

  1. 遇到 JCClassDecl → 在父作用域中注册 ClassSymbol。创建一个新的作用域(members Scope)作为该 ClassSymbol 的成员作用域。
  2. 遇到 JCMethodDecl → 创建 MethodSymbol,包含参数列表的 VarSymbol 和返回类型。将方法符号注册到所在类的 members Scope。
  3. 遇到 JCVariableDecl(字段)→ 创建 VarSymbol,注册到所在类的 members Scope。
  4. 遇到 JCImport → 将 import 的类或静态成员信息记录到编译单元的 namedImportScopestarImportScope。后续的名称解析会用到这些 import scope。

extends / implements 的解析:MemberEnter 通过 Resolve 组件解析超类和接口。它会将 AST 中的类型表达式(如 extends BaseActivity)通过 Attr.attribType()Resolve.findIdent() 解析为具体的 ClassSymbol,然后设置子类 ClassSymbol.type 中的 supertype_fieldinterfaces_field。class hierarchy 的完整性检查(如循环继承、final 类被继承等)在后续的 Attr 阶段进行。

继承方法的检查:当子类声明了一个与父类签名相同的方法时,MemberEnter 记录这个事实。如果父类方法不是 abstract,子类方法标记为 @Override,则在此阶段可以检测到 @Override 不匹配的情况。

3.4 延迟成员输入(Lazy Member Enter)

javac 使用了一种优化策略:延迟成员输入(lazy member enter)。对于大型类(如包含数千个方法的自动生成代码),编译器不会在 Enter 阶段一次性将所有成员的符号输入到符号表中,而是按需逐步输入。

机制:ClassSymbol 内部维护了一个 members field,但这个 field 在 Enter 第一阶段可能并不包含所有成员。当后续阶段需要查找某个方法时(如名称解析),符号表的查找会触发 ClassReader 从 .class 文件或 AST 中”补全”缺失的成员符号。

在 Android 开发中,延迟成员输入对于处理 AIDL 生成的 Stub 类尤其重要——这些类通常包含大量事务处理方法,全量 Enter 会显著增加内存占用和编译时间。

四、Annotation Processing 阶段

4.1 JSR 269 注解处理框架

com.sun.tools.javac.processing.JavacProcessingEnvironment 实现了 JSR 269(Pluggable Annotation Processing API)。该框架允许开发者在编译时插入自定义的注解处理器,读取和操作 AST(但不能修改已有的 AST 节点,只能生成新的源文件或 class 文件)。

处理器的发现机制:javac 通过 Java SPI(Service Provider Interface)机制发现并加载处理器。具体来说,扫描 classpath 中所有 META-INF/services/javax.annotation.processing.Processor 文件,每个文件包含一个或多个处理器的全限定类名,用换行分隔。javac 通过 ServiceLoader 或直接的文件解析加载这些类。

处理器的注册也可以通过 -processor 命令行参数显式指定,或通过 -processorpath 指定处理器搜索路径。在 Gradle 中,处理器通常通过 annotationProcessor 依赖配置自动注册:

dependencies {
annotationProcessor 'com.google.dagger:dagger-compiler:2.x'
annotationProcessor 'androidx.room:room-compiler:2.x'
}

4.2 处理轮次(Rounds)

注解处理以”轮次(round)”的形式运行:

  1. Round 1:javac 完成 Parse 和 Enter 后,将所有 AST 中的注解信息传递给已注册的处理器。每个处理器的 process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) 方法被调用。处理器可以:

    • 通过 roundEnv.getElementsAnnotatedWith(annotation) 获取被特定注解标记的元素集合。
    • 通过 processingEnv.getFiler().createSourceFile(name) 创建新的 .java 源文件。
    • 通过 processingEnv.getFiler().createClassFile(name) 创建新的 .class 文件(较少使用)。
    • 通过 processingEnv.getMessager().printMessage() 输出编译警告或错误。
    • 通过 processingEnv.getTypeUtils() 进行类型操作。
  2. Round 2 及后续:如果 Round 1 中生成了新的源文件,javac 对这些新文件重新执行 Parse 和 Enter,然后进入 Round 2。处理器再次被调用。这个过程反复进行,直到某个轮次没有新文件生成。

  3. 最后一个 RoundroundEnv.processingOver() 返回 true,处理器可以在此时释放资源(如关闭文件流)。

关键:处理器不能修改已有的 AST 节点——它们只能读取 AST 并生成新文件。这是 JSR 269 设计的一个核心约束(与 Lombok 等绕过此限制的工具形成对比)。Lombok 之所以能修改 AST,是因为它使用了 javac 的内部 API(通过 JavacAnnotationHandler 直接操作 JCTree 节点),这不是标准 JSR 269 的一部分。

4.3 注解处理器生成代码的内部流程

当处理器通过 Filer.createSourceFile("com.example.GeneratedClass") 创建源文件时:

  1. JavacFiler(javac 的内部 Filer 实现)创建一个 JavaFileObject 表示待生成的文件。
  2. JavacProcessingEnvironment 将这个 JavaFileObject 记录为”待处理的新源码”。
  3. 当前轮次结束后,javac 将生成的新源文件加入待编译队列。
  4. 下一轮开始时,javac 对新源文件执行 Parse 和 Enter。
  5. 在 Parse 新文件的过程中,javac 可能再次遇到注解,触发新一轮处理。

这解释了为什么某些注解处理器(如 Dagger)需要多次编译轮次——生成的工厂类可能被其他注解处理器(如 Room)进一步处理。

4.4 Android 中的 APT 与 KSP

在 Android 开发中,APT(Annotation Processing Tool)用于以下核心场景:

框架 注解 生成的代码
Dagger @Component, @Module DaggerXxxComponent 工厂类(提供依赖注入的实现)
Room @Dao, @Database XxxDao_Impl 实现类(包含 SQL 语句的查询实现)
DataBinding @Bindable XxxBindingImpl(绑定实现类)和 BR 常量
ButterKnife (已弃用) @BindView Xxx_ViewBinding 类(持有 findViewById 调用)
AutoValue @AutoValue AutoValue_Xxx 子类(实现 equals/hashCode/toString)

KSP(Kotlin Symbol Processing)是 Kotlin 的替代方案。它与 APT 的关键差异:

  • APT 操作的是 Java AST(javax.lang.model.*),对 Kotlin 源码支持有限(需要先编译为 Java stub)。
  • KSP 直接操作 Kotlin 的符号表,不需要生成 Java stub,因此处理速度更快(Google 官方测试中 KSP 比 APT 快约 2 倍)。
  • KSP 的 API 设计与 javax.lang.model 类似,但不完全相同,迁移需要一定的适配工作。

五、Attr 阶段:属性标记与类型检查

5.1 Attr 的职责

com.sun.tools.javac.comp.Attr 是 javac 类型检查的核心。它的主要职责是为 AST 中的每个表达式和声明标注类型信息(将 JCTree.type 字段从 null 填充为实际的 Type 对象),同时进行全面的类型检查。

Attr 在执行过程中工作的关键子组件:

子组件 职责
Check 具体类型检查规则的实现(如类型兼容性、cast 合法性)
Resolve 方法重载决议、字段访问解析、类型名称查找
Infer 泛型类型推断(如 List<String> list = new ArrayList<>() 中的 <> 推断)
ConstFold 编译期常量折叠(如 1 + 2 直接计算为 3

Attr 的执行顺序是自顶向下的——先标注类的声明(包括注解),然后标注类中的方法和字段,最后标注方法体中的语句和表达式。这个顺序确保了在标注方法体内的方法调用时,方法的符号和类型信息已经可用。

5.2 类型检查的核心流程

(1)赋值兼容性检查(Assign Conversion)

当 Attr 遇到赋值语句 x = expr 时,它检查 expr 的类型是否可以赋值给 x 的类型。这在 Check.checkType()Types.isAssignable() 中实现。

赋值兼容性规则包括:

  • 基本类型的加宽转换(widening)自动允许(如 intlongfloatdouble)。
  • 引用类型的向上转型(upcast)自动允许(如 StringObject)。
  • 原始类型到包装类型的自动装箱(如 intInteger)在 Java 5+ 中允许。
  • 引用类型到原始类型的自动拆箱(如 Integerint)允许,但需要注意 NPE 风险。

(2)捕获转换(Capture Conversion)

通配符泛型(如 List<? extends Number>)在类型检查时需要”捕获转换”——将通配符替换为一个具名的”捕获类型变量”(capture type variable),以便进行后续的类型检查。

例如:

void process(List<? extends Number> list) {
Number n = list.get(0); // OK: 捕获转换后 get() 返回 CAP#1 extends Number
list.add(42); // ERROR: 捕获转换后 add(CAP#1) 不允许传入 Integer
}

捕获转换的核心在 Types.capture(Type type) 中实现。它创建一个 CaptureType 对象,将通配符边界(upper bound 和 lower bound)转换为类型变量。

(3)方法重载决议(Method Overload Resolution)

当 Attr 遇到方法调用(如 foo(a, b)),且有多个候选方法(名称相同但参数类型不同)时,需要进行重载决议。javac 使用 JLS(Java Language Specification)第 15.12 节定义的三阶段重载决议算法

  1. Phase 1:Strict Invocation — 不做装箱/拆箱,不做变长参数展开。如果恰好有一个方法在此阶段匹配,则选择它。
  2. Phase 2:Loose Invocation — 允许装箱/拆箱,但不展开变长参数。
  3. Phase 3:Variable Arity Invocation — 允许全部转换,包括变长参数展开。

如果多个候选方法在同一阶段都适用,则需要most-specific test(最具体测试)来确定哪个方法”更具体”。一个方法 m1m2 更具体,当且仅当 m1 的每个形式参数类型都可以通过方法调用转换赋值给 m2 的对应形式参数类型。

Most-specific test 的一个典型歧义场景:

void foo(Integer x, int y) { ... }
void foo(int x, Integer y) { ... }
foo(1, 2); // 歧义错误:两个方法都无法证明比另一个更具体

javac 的 Resolve 类中 mostSpecific() 方法负责此项测试,内部会遍历所有候选方法,两两比较看是否一个比另一个更具体。

5.3 类型推断与泛型

Infercom.sun.tools.javac.comp.Infer)处理泛型方法的类型参数推断。当调用泛型方法而没有显式指定类型参数时:

<T> T pick(T a, T b) { return a; }
String result = pick("a", "b"); // T 被推断为 String

Infer 的推断算法基于约束求解:收集调用上下文中对类型参数的所有类型约束(如参数类型、期望的返回类型),然后求解最小上界(lub, least upper bound)或最大下界(glb, greatest lower bound)。具体实现使用了 UndetVar(未确定的类型变量)和 InferenceContext 来跟踪约束和待解变量。

对于 Java 8 引入的 java.util.stream.Stream 和 lambda 表达式,类型推断变得尤为复杂——因为 lambda 的主体可能影响类型参数的推断方向。Infer 中的 GraphInference 子类专门处理这种需要固定点迭代的复杂推断场景。

六、Flow 阶段:数据流分析

6.1 Flow 的整体功能

com.sun.tools.javac.comp.Flow 实现了 Java 语言的静态数据流分析。它的三个核心功能模块:

功能 检查内容 违反时错误
Definite Assignment(确定赋值) 局部变量在使用前是否一定被赋值 “variable x might not have been initialized”
Effectively Final(有效 final) Lambda 捕获的局部变量是否被多次赋值 “local variables referenced from a lambda must be final or effectively final”
异常检查 checked exception 是否被捕获或声明 “unreported exception … must be caught or declared to be thrown”
可达性分析 return/break/continue/throw 后的代码是否可达 “unreachable statement”

6.2 确定赋值分析的实现

确定赋值分析的核心是位向量跟踪。Flow 为每个局部变量维护一个”是否已赋值”的标志位(bit)。当分析到每个语句时,计算进入该语句时的赋值状态,以及退出该语句后的赋值状态。

对于 if-else 等控制流分支,退出状态是各分支退出状态的交集(如果某条路径上 x 未被赋值而另一条路径上已赋值,则 if-else 之后 x 的状态是”未赋值”)。

int x;
if (condition) {
x = 1; // 进入状态: x 未赋值; 退出状态: x 已赋值
} else {
// 没有对 x 赋值
// 进入状态: x 未赋值; 退出状态: x 未赋值
}
// 此处两条路径合并,x 的"确定赋值"状态 = 交集 = 未赋值
System.out.println(x); // 编译错误:x 可能未被初始化

Flow 使用 Bits 类(位集)来压缩存储多个变量的赋值状态。每个需要跟踪的局部变量分配一个 bit 位置,Bits 中的一个 int 数组存储这些 bit。

对于赋值分析的几个特殊情况:

  • finally 块中的赋值:finally 块一定会执行(除非 JVM 崩溃或调用 System.exit()),所以 finally 中对变量的赋值在 try-catch-finally 之后一定生效。
  • try-with-resources:资源变量在 try 块进入时已初始化,在 catch/finally 中可以安全使用。
  • this 在构造方法中的逃逸:在调用 super()this() 之前,this 不能被使用(因为对象还未完全构造)。

6.3 Effectively Final 检查

Java 8 引入了 effectively final 的概念:如果一个局部变量在声明后从未被重新赋值(包括 ++--+= 等复合赋值),则它被视为 effectively final。Lambda 表达式和匿名内部类只能捕获 effectively final 的局部变量。

Flow 通过记录每个变量的赋值次数来判定——如果赋值次数 > 1(包括声明时的初始化),则该变量不是 effectively final。

注意:effectively final 是编译期的概念,在 class 文件中没有特殊的标记。Lambda 捕获局部变量时,如果变量是 effectively final,javac 直接将它的值复制到 lambda 的合成构造方法中。

6.4 异常检查

Flow 分析所有可能被抛出但未被捕获的 checked exception。具体流程:

  1. 对于每个方法调用,检查它声明的 throws 子句(来自 MethodSymbol 的 getThrownTypes())。
  2. 对于方法体中的 throw 语句,获取抛出异常的类型。
  3. 如果异常类型是 checked exception 的子类(java.lang.Exception 但非 java.lang.RuntimeException),则检查:
    • 是否在调用方法的 throws 子句中声明了该异常或其超类型,或者
    • 是否在 try-catch 中被捕获(catch 块捕获的类型是该异常或其超类型)。

如果两者都不满足,Flow 报告”unreported exception … must be caught or declared to be thrown”。

6.5 可达性分析

Flow 检查在 returnthrowbreakcontinue 之后的语句是否不可达。但 Java 语言规范有一个特殊的例外:

if (false) {
System.out.println("never printed");
}
// 这不会产生编译错误,JLS 允许这样做以支持条件编译
while (false) {
// 同样不会报错
}

javac 的可达性分析区分”结构上不可达”(如 return 后的语句)和”条件编译不可达”(如 if (false))。前者产生编译错误,后者允许。

七、LambdaToMethod:Lambda 的脱糖

7.1 Lambda 在字节码中的两种策略

com.sun.tools.javac.comp.LambdaToMethod 负责将 lambda 表达式转换为 JVM 字节码可以表示的形式。javac 支持两种转换策略:

策略 1:invokedynamic + LambdaMetafactory(Java 8+ 标准)

这是 Java 8 引入的标准策略。编译器生成一条 invokedynamic 指令,其 Bootstrap Method 指向 java.lang.invoke.LambdaMetafactory.metafactory()。在运行时首次执行到该指令时,JVM 调用 LambdaMetafactory 动态生成一个实现函数式接口的类。

使用 invokedynamic 策略时,javac 的 LambdaToMethod 负责生成:

  • Bootstrap Method 条目:在 class 文件的 BootstrapMethods 属性中记录 LambdaMetafactory.metafactory() 的方法句柄和参数。
  • 合成方法(synthetic method):将 lambda 体提取为一个 private static 合成方法(如 lambda$methodName$0),该方法的参数包括捕获的局部变量和 lambda 的显式参数。
// 原始代码
list.forEach(s -> System.out.println(s));

// 脱糖后的等价概念(简化)
private static void lambda$main$0(String s) {
System.out.println(s);
}
// 使用 invokedynamic + metafactory 动态创建 Consumer<String> 实例

策略 2:匿名内部类(Java 8 之前)

javac 内部也保留了将 lambda 编译为匿名内部类的策略(LambdaToMethod 中的 translateLambda 方法),这主要用于运行时环境不支持 invokedynamic 的情况(如早期的 Android 版本)。但在现代的 javac 中(如 JDK 17+),invokedynamic 是默认且推荐的方式。

注意:Android 中 d8/R8 的 desugar 会将 invokedynamic 指令再次转换(因为旧版 Android Runtime 不支持 invokedynamic 或 LambdaMetafactory)。d8 会将 bootstrap method 调用替换为生成一个实现函数式接口的内部类,这与 javac 的匿名内部类策略在原理上类似,但在 .dex 层面实现。

7.2 合成方法的命名规则

LambdaToMethod 生成的合成方法遵循固定的命名规则:

lambda$<enclosing method name>$<sequence number>

例如:

  • lambda$main$0main 方法中的第一个 lambda
  • lambda$main$1main 方法中的第二个 lambda
  • lambda$init$0 — 构造方法中的第一个 lambda

这些合成方法带有 ACC_SYNTHETICACC_PRIVATE 标志,在反射中默认不可见(除非使用 getDeclaredMethods())。

7.3 方法引用的处理

方法引用(如 System.out::printlnString::lengthArrayList::new)也由 LambdaToMethod 处理。不同形式的方法引用对应不同的 LambdaMetafactory 处理方法句柄:

方法引用形式 对应的 metafactory 方法句柄类型 脱糖方式
Class::staticMethod 指向静态方法的 MethodHandle 不需要合成方法,直接使用方法句柄
object::instanceMethod (bound) 指向特定对象实例方法的 MethodHandle 不需要合成方法
Class::instanceMethod (unbound) 第一个参数变为接收者 不需要合成方法,由 MethodHandle 适配
Class::new 指向构造方法的 MethodHandle 不需要合成方法

对于实例方法引用(unbound,如 String::length),字节码中 invokedynamic 的调用点签名是 (String) -> Integer(接收者的类型作为函数式接口方法的第一个参数),JVM 在运行时通过 MethodHandles.insertArguments 等组合器自动适配。

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

8.1 Lower 的整体职责

com.sun.tools.javac.comp.Lower 是 javac 的语法糖消除模块。它工作于 Flow 之后的 AST,将 Java 高级语言特性转换为语义等价的、更底层的形式。解糖后的 AST 只包含 Java 语言的”核心子集”,方便后续的 Gen 阶段直接映射为字节码。

Lower 的输入 AST 可能包含:嵌套类、内部类、匿名类、foreach 循环、String switch、try-with-resources、diamond operator、类型擦除相关的桥接方法等。其输出 AST 只包含:顶层类、基本类型、基本控制流、基本方法调用。

8.2 嵌套类与匿名类的提升

Lower 的最核心工作之一是将嵌套类和匿名类提升为顶层类。对于 javac 来说,JVM 并不理解”内部类”的概念——所有类在 .class 文件中都是独立的顶层类。

匿名类提升:对于 new Runnable() { ... },Lower 创建一个新的顶层类(如 OuterClass$1),包含:

  • 一个合成构造方法,接收外部类实例作为参数(OuterClass$1(OuterClass this$0))。
  • 一个 final OuterClass this$0 字段,存储外部类实例的引用,用于匿名类中访问外部类的成员。
  • 捕获的局部变量的复制(构造参数传入,存储到 final 字段中)。

成员内部类提升:对于 class Outer { class Inner { ... } },Lower 为 Inner 生成一个独立的顶层类 Outer$Inner,同样持有 this$0 字段和对应的合成构造参数。

局部类提升:对于方法内部定义的局部类(local class),Lower 将其提升为顶层类,并通过合成构造方法传入捕获的局部变量(与匿名类的处理类似)。

8.3 String switch 的转换

String switch 在 javac 的 Lower 阶段被转换为两个嵌套的 switch:

// 原始代码
switch (str) {
case "A": doA(); break;
case "B": doB(); break;
case "C": doC(); break;
default: doDefault();
}

// Lower 解糖后的等价代码(概念表示)
int $hash = str.hashCode();
switch ($hash) {
case 65: // "A".hashCode()
if (str.equals("A")) { doA(); break; }
break;
case 66: // "B".hashCode()
if (str.equals("B")) { doB(); break; }
break;
case 67: // "C".hashCode()
if (str.equals("C")) { doC(); break; }
break;
}
doDefault(); // 如果哈希码都没匹配到

关键实现细节:

  • 使用 lookupswitch 而非 tableswitch。因为 String 的 hashCode 值分布稀疏,lookupswitch(二分查找,O(log n))比 tableswitch(跳转表,需要连续的 case 值)更节省空间。
  • 如果多个 case 字符串的 hashCode 发生了碰撞,Lower 为每个碰撞的 case 生成连续的 if-equals 级联检查。
  • str 的 null 检查也在 Lower 中插入——如果 str 为 null,直接跳转到 default 分支(因为 str.hashCode() 会 NPE)。
  • 对同一哈希值按 equals() 精确比较后命中则跳到对应 case。

8.4 try-with-resources 的完整展开

try-with-resources(Java 7)在 Lower 阶段被展开为一个嵌套的 try-catch-finally 结构,这个展开是 javac 中语法糖消除最复杂的案例之一。

// 原始代码
try (BufferedReader br = new BufferedReader(new FileReader("file.txt"))) {
return br.readLine();
}

// Lower 展开后的等价代码(概念表示)
BufferedReader br = new BufferedReader(new FileReader("file.txt"));
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();
}
}
}

关键变量和逻辑:

  • $primary:保存 try 块中可能抛出的主异常。如果 close() 也抛出异常,close 异常通过 addSuppressed() 附加到主异常上。
  • 空值检查 if (br != null):资源变量声明后可能抛出异常(如 FileReader 构造函数失败),此时 br 为 null,不应调用 close()。
  • Suppressed Exception 机制:Throwable.addSuppressed(Throwable) 在 Java 7 中引入,用于记录”在资源清理过程中发生的异常”,避免掩盖原始异常。

8.5 foreach 循环的两种展开

foreach 循环(enhanced for loop)在 Lower 中根据目标对象类型有两种不同的展开方式:

对 Iterable 对象(如 ListSet):

// 原始代码
for (String s : list) {
System.out.println(s);
}

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

$it 是编译器生成的不重复的临时变量名。注意迭代器变量在 for 循环头部声明,循环结束后就被回收。

对数组

// 原始代码
for (String s : array) {
System.out.println(s);
}

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

将数组赋值给局部变量 $arr 是为了防止在循环过程中原数组引用被修改导致的并发问题——$arr.length 在循环过程中不会变化。

8.6 其他语法糖消除

Lower 还处理以下语法糖:

变长参数(varargs):调用点 foo("a", "b") 解糖为 foo(new String[]{"a", "b"})。被调用方法在 class 文件中保留 ACC_VARARGS 标志,但方法体中的数组访问与普通数组无异。

自动装箱/拆箱Integer x = 42 解糖为 Integer x = Integer.valueOf(42)int y = x 解糖为 int y = x.intValue()

桥接方法(Bridge Method):泛型擦除后,如果子类实现了一个参数类型更具体的方法(擦除后与父类方法签名相同但参数类型不同),Lower 生成一个桥接方法。例如:

class Parent<T> { void foo(T t) {} }
class Child extends Parent<String> {
@Override
void foo(String s) {} // 擦除后:void foo(String)
}

// Lower 为 Child 生成桥接方法:
// void foo(Object t) { this.foo((String) t); }

这个桥接方法确保通过 Parent 引用调用 foo(Object) 时正确路由到 Child.foo(String)

枚举(Enum)enum 类被转换为继承自 java.lang.Enum 的普通类,枚举常量变为 public static final 字段,values()valueOf() 方法由编译器自动生成。

九、Generate 阶段:字节码生成

9.1 Gen 的职责与 Code 对象

com.sun.tools.javac.jvm.Gen 是 javac 编译流水线的最后阶段,负责将带有完整类型标注和流分析信息的 AST 转换为 JVM 字节码指令序列。Gen 为每个方法生成 class 文件中的 Code 属性,包含实际的方法体字节码。

Gen 的核心数据结构:

  • **Code**(com.sun.tools.javac.jvm.Code):一个可增长的字节数组,同时维护了操作数栈深度的模拟和局部变量槽位的分配状态。
  • **Items**(com.sun.tools.javac.jvm.Items):辅助类,用于生成加载常量/字段/方法引用到操作数栈的代码片段。
  • **CRTable**:字符范围表(Character Range Table),用于跟踪源码字符位置与字节码偏移的对应关系(用于调试器)。
  • **Pool**(com.sun.tools.javac.jvm.Pool):常量池管理器,负责分配常量池索引(如类引用、方法引用、字符串常量)。

Gen 的执行入口是 Gen.genClass(Env<AttrContext> env, JCClassDecl cdef),它遍历类的每个方法声明,对每个方法调用 genMethod(),生成对应的 Code 属性。

9.2 AST → Bytecode 的映射

Gen 如何将 AST 节点映射为 JVM 指令?核心方法 genStat(JCTree tree, Env<GenContext> env) 是一个大的 visitor,针对不同的 AST 节点类型生成对应的字节码指令序列:

AST 节点 生成的 JVM 指令模式
JCLiteral (int 42) bipush 42iconst_<n>ldc #index
JCIdent (局部变量 x) iload_<n> / aload_<n> 等(根据类型)
JCAssign (x = expr) 先求值 expr 压栈 → dup(如需要)→ istore n / astore n
JCMethodInvocation (foo(a, b)) 计算接收者(实例方法)/ 参数压栈 → invokevirtual / invokestatic / invokespecial / invokeinterface
JCIf (if (cond) { … }) 计算 cond → ifeq / ifne 跳转到 else 或下一条指令
JCReturn (return expr;) 计算 expr → 对应 ireturn / areturn / return
JCNewClass (new Foo()) new #indexdup → 参数压栈 → invokespecial <init>
JCCatch 生成异常表条目:tryStart / tryEnd / handler 对应的字节码偏移

9.3 常量池的构建

javac 的常量池(Constant Pool)不是在 Gen 阶段一次性构建的,而是在 Gen 的过程中按需动态构建Pool 类维护了常量池条目的内部映射,当 Gen 需要引用一个类、方法、字符串或数值常量时,调用 Pool 的对应方法获取常量池索引。

常量池的类型(定义在 com.sun.tools.javac.jvm.Pool 中):

常量类型 含义 示例
CONSTANT_Class 类或接口的符号引用 java/lang/String
CONSTANT_Fieldref 字段的符号引用 System.out
CONSTANT_Methodref 方法的符号引用 println(Ljava/lang/String;)V
CONSTANT_InterfaceMethodref 接口方法的符号引用 Iterator.hasNext()Z
CONSTANT_String 字符串常量 "Hello, World"
CONSTANT_Integer / CONSTANT_Float / CONSTANT_Long / CONSTANT_Double 基本类型数值常量 42, 3.14
CONSTANT_NameAndType 名称和类型描述符 message : Ljava/lang/String;
CONSTANT_Utf8 UTF-8 编码的字符串 实际存储的字符串内容
CONSTANT_MethodHandle 方法句柄(Java 7+) LambdaMetafactory 引用
CONSTANT_MethodType 方法类型(Java 7+) (Ljava/lang/Object;)V
CONSTANT_InvokeDynamic invokedynamic 调用点(Java 7+) lambda 调用点

9.4 max_stack 与 max_locals 的计算

class 文件的 Code 属性要求记录方法的 max_stack(方法执行过程中操作数栈的最大深度)和 max_locals(方法需要的局部变量槽位总数)。

max_stack 的计算:Gen 通过模拟指令执行来跟踪操作数栈的深度。Code 类内部维护一个 cur_stack 计数器,每生成一条指令,根据该指令对操作数栈的影响调整计数器:

  • iconst_0:stack +1
  • iadd:stack -2 + 1 = -1(弹出两个 int,压入一个 int)
  • invokevirtual:stack 减少参数量 + 减少接收者 + 增加返回值(如果有)
  • dup:stack +1
  • dup2:stack +2

max_stack = 记录到的 cur_stack 的最大值。对于包含分支和跳转的方法,Gen 需要保守估算所有执行路径上的最大栈深度——如果不同路径的栈深度不同,取所有路径的最大值。

max_locals 的计算:Gen 为每个局部变量分配一个”槽位”(对于 longdouble 类型占两个槽位)。槽位的分配策略:方法参数占用前 N 个槽位(实例方法的第 0 号槽位是 this),后续的局部变量依次分配。max_locals = 分配的最大槽位索引 + 1 + 数据类型宽度校正。

9.5 StackMapTable 的生成

Java 7 引入的 StackMapTable 属性 是类型检查字节码验证(Type-Checking Verification)的关键数据。Gen 需要为每个分支目标(跳转的目标标签)和异常处理器的入口生成 StackMapFrame,记录该位置的局部变量类型和操作数栈类型。

StackMapFrame 的类型包括:

Frame Type 编码 含义
SAME 0-63 与前一帧的局部变量相同,操作数栈为空
SAME_LOCALS_1_STACK_ITEM 64-127 局部变量相同,栈上多一个元素
APPEND 252-254 局部变量追加了 1-3 个元素
CHOP 248-250 局部变量减少了 1-3 个元素(编译期优化导致)
FULL_FRAME 255 完整帧:需要指定所有局部变量类型和栈类型

Gen 使用 CRTable(Character Range Table)和 Code.fillStackMap() 方法生成这些帧。在现代 javac 中,StackMapTable 通常在 Gen 完成后通过 Flow.generateStackMapTable() 重新计算,以确保帧信息准确。

在 Android 开发中,ASM 的 COMPUTE_FRAMES 标志(对应 ClassWriter.COMPUTE_FRAMES)可以让 ASM 在修改字节码后自动重新计算 StackMapTable。这是最安全和推荐的做法——手动维护 StackMapTable 极其容易出错,导致 VerifyError

9.6 异常表(Exception Table)的生成

Gen 为每个 try-catch 块生成异常表条目(Exception Table Entry),每个条目包含:

  • start_pc:try 块起始指令的字节码偏移
  • end_pc:try 块结束指令的字节码偏移(exclusive,即不包含 end_pc 指向的指令)
  • handler_pc:catch 块第一条指令的字节码偏移
  • catch_type:捕获的异常类型(常量池中 CONSTANT_Class 的索引),0 表示 catch all(finally 或 catch(Throwable)

多个异常表条目可以重叠,表示嵌套的 try-catch 块。JVM 在运行时根据异常类型匹配最内层的、catch_type 匹配的异常处理器。

9.7 LineNumberTable 和 LocalVariableTable

Gen 同时生成调试属性:

  • LineNumberTable:将字节码偏移映射到源文件行号。每个条目包含 start_pc(字节码偏移)和 line_number(源文件行号)。调试器依赖这个表来实现单步执行和断点定位。
  • LocalVariableTable:记录每个局部变量的名字、类型描述符、类型签名、作用范围(start_pc 到 end_pc)和槽位索引。调试器依赖这个表来显示变量值。

十、完整示例:跟踪一个简单类的编译全过程

10.1 源文件

// File: Demo.java
public class Demo {
public int compute(int n) {
int sum = 0;
for (int i = 1; i <= n; i++) {
sum += i;
}
return sum;
}
}

10.2 阶段 1:Parse

JavacParser.parseCompilationUnit() 被调用。JavaTokenizer 将源码切分为 Token 流:

[PUBLIC] [CLASS] [IDENTIFIER "Demo"] [LBRACE]
[PUBLIC] [INT] [IDENTIFIER "compute"] [LPAREN] [INT] [IDENTIFIER "n"] [RPAREN] [LBRACE]
[INT] [IDENTIFIER "sum"] [EQ] [INTLITERAL 0] [SEMI]
[FOR] [LPAREN] [INT] [IDENTIFIER "i"] [EQ] [INTLITERAL 1] [SEMI]
[IDENTIFIER "i"] [LTEQ] [IDENTIFIER "n"] [SEMI] [IDENTIFIER "i"] [PLUSPLUS] [RPAREN] [LBRACE]
[IDENTIFIER "sum"] [PLUSEQ] [IDENTIFIER "i"] [SEMI]
[RBRACE]
[RETURN] [IDENTIFIER "sum"] [SEMI]
[RBRACE]
[RBRACE]
[EOF]

JavacParser 基于 Token 构建 AST:

JCCompilationUnit
├── defs[0]: JCClassDecl (name = "Demo", modifiers = ACC_PUBLIC)
│ └── defs[0]: JCMethodDecl (name = "compute", restype = int, params = [n: int])
│ └── body: JCBlock
│ ├── stats[0]: JCVariableDecl (name = "sum", type = int, init = 0)
│ ├── stats[1]: JCForLoop
│ │ ├── init: JCVariableDecl (name = "i", type = int, init = 1)
│ │ ├── cond: JCBinary (op = LTE, lhs = JCIdent "i", rhs = JCIdent "n")
│ │ ├── step: JCUnary (op = POSTINC, arg = JCIdent "i")
│ │ └── body: JCBlock
│ │ └── stats[0]: JCExpressionStatement
│ │ └── JCAssign (op = PLUSEQ, lhs = JCIdent "sum", rhs = JCIdent "i")
│ └── stats[2]: JCReturn (expr = JCIdent "sum")

10.3 阶段 2:Enter

EnterMemberEnter 将符号注册到符号表:

  • 创建 ClassSymbol(Demo),其 members Scope 初始为空。
  • MethodSymbol(compute, int, [n: int]) 注册到 Demo 的 members Scope。
  • VarSymbol(sum, int)VarSymbol(n, int)VarSymbol(i, int) 注册到 compute 方法的局部作用域中。
  • int 类型引用指向 Symtab.intType(预定义符号)。

10.4 阶段 4:Attr

Attr 进行类型标注:

  • compute 方法的 JCTree.type = MethodType(int, [int])
  • sumJCTree.type = int
  • JCBinary(i <= n)JCTree.type = boolean
  • JCAssign(sum += i)JCTree.type = int
  • JCReturn 检查返回类型 int 与方法声明的返回类型 int 匹配

10.5 阶段 5:Flow

Flow 进行数据流分析:

  • sumint sum = 0; 处被确定赋值,之后的使用有效。
  • iint i = 1; 处被确定赋值。
  • 检查 reachability:return sum; 之后没有代码,合理。

10.6 阶段 6:Desugar

Lower 对 foreach 进行解糖(本例中是基本 for 循环,无需解糖)。没有需要提升的嵌套类。没有 try-with-resources。此阶段基本是 pass-through。

10.7 阶段 7:Generate

Gen 生成字节码。对于 compute 方法,生成的 JVM 指令序列(概念表示)如下:

// int compute(int n)
// max_stack = 2, max_locals = 4 (this, n, sum, i)

0: iconst_0 // 压入常量 0
1: istore_2 // sum = 0,存储到局部变量槽位 2
2: iconst_1 // 压入常量 1
3: istore_3 // i = 1,存储到局部变量槽位 3
4: iload_3 // 加载 i
5: iload_1 // 加载 n
6: if_icmpgt 19 // 如果 i > n,跳转到偏移 19(循环结束)
9: iload_2 // 加载 sum
10: iload_3 // 加载 i
11: iadd // sum + i
12: istore_2 // sum = sum + i
13: iinc 3, 1 // i++(直接在局部变量中递增)
16: goto 4 // 跳回循环条件检查
19: iload_2 // 加载 sum
20: ireturn // 返回 sum,栈顶元素为 int

常量池条目(简化):

  • #1 = Methodref java/lang/Object.<init>:()V(默认构造方法的父类调用)
  • #2 = Class Demo
  • #3 = Utf8 compute
  • #4 = Utf8 (I)I
  • #5 = Utf8 SourceFile
  • #6 = Utf8 Demo.java

异常表:空(方法体内没有 try-catch)

StackMapTable:

  • Frame0 (offset 4): locals=[Demo, int, int, int], stack=[]
  • Frame1 (offset 19): locals=[Demo, int, int, int], stack=[]

10.8 最终的 .class 文件结构

生成的 Demo.class 文件包含以下主要部分:

ClassFile {
magic: 0xCAFEBABE
minor_version: 0
major_version: 52 (Java 8)
constant_pool_count: 22
constant_pool: [ ... 21 entries ... ]
access_flags: ACC_PUBLIC | ACC_SUPER
this_class: #2 (Demo)
super_class: #7 (java/lang/Object)
interfaces_count: 0
fields_count: 0
methods_count: 2
methods[0]: // 默认构造方法
access_flags: ACC_PUBLIC
name_index: #8 (<init>)
descriptor_index: #9 (()V)
Code: aload_0; invokespecial Object.<init>; return
methods[1]: // compute
access_flags: ACC_PUBLIC
name_index: #3 (compute)
descriptor_index: #4 ((I)I)
Code:
max_stack: 2
max_locals: 4
code: [iconst_0, istore_2, iconst_1, istore_3, iload_3,
iload_1, if_icmpgt 19, iload_2, iload_3, iadd,
istore_2, iinc 3 1, goto 4, iload_2, ireturn]
exception_table_length: 0
attributes: [StackMapTable, LineNumberTable, LocalVariableTable]
attributes: [SourceFile("Demo.java")]
}

这就是一个简单的 Demo.java 文件经过 javac 的七个阶段后,最终生成 Demo.class 文件的完整过程。

十一、javac vs d8:分工边界与协通

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

维度 javac d8 (Android)
输入 .java 源文件 .class 文件(javac 输出)
输出 .class(JVM bytecode) .dex(Dalvik bytecode)
语法糖处理 try-with-resources、foreach、switch-string、自动装箱/拆箱、varargs、内部类提升 lambdas、default methods、static interface methods(Java 8+ 的 API desugaring)
优化 少量常量折叠(1 + 23 主要优化器:常量传播、死代码消除、方法内联、字符串去重、类合并、枚举拆箱
类型系统 JVM 类型系统(栈式指令集) Dalvik 类型系统(寄存器式指令集)
运行时支持 依赖 JVM 提供的标准库 依赖 ART/Dalvik + Android SDK + desugar_jdk_libs

d8 的一个关键角色是 API desugaring。当代码中使用了较新版本的 Java API(如 java.time.LocalDatejava.util.stream.Streamjava.util.Optional)时,如果目标 Android 版本不支持这些 API,d8 自动将调用重写为对 desugar_jdk_libs 支持库中替代实现的调用。这是通过 d8 内部的 D8ApiDesugaring 实现的。


面试问答

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

A:七个阶段依次是:Parse(JavacParser + JavaTokenizer 解析源码生成 JCTree AST)、Enter(MemberEnter 构建符号表,将声明注册到 Scope 中)、Annotation Processing(JavacProcessingEnvironment 调用 JSR 269 注解处理器,可生成新源文件并触发新一轮 Parse+Enter)、Attr(Attr+Check+Resolve+Infer 进行类型检查与类型标注)、Flow(确定赋值分析、异常检查、可达性分析、effectively final 检查、LambdaToMethod 脱糖)、Desugar(Lower 解语法糖:嵌套类提升、switch-string、try-with-resources、foreach、自动装箱)、Generate(Gen+Code 从类型标注的 AST 生成 JVM 字节码和常量池)。所有七个阶段都操作 AST:Parse 创建 AST,Enter 在 AST 的基础上构建符号表,Annotation Processing 读取 AST 生成新源码(间接触发新的 AST),Attr 为 AST 标注类型信息,Flow 在 AST 上进行数据流分析,Desugar 修改/转换 AST 节点,Generate 遍历 AST 生成字节码。但严格来说,Annotation Processing 生成的新源文件需要通过新一轮 Parse 才能回到 AST。

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

A:javac 的 Lower 将 try-with-resources 展开为多层 try-catch-finally。首先在 try 之前生成资源变量的声明和初始化。然后在 try 块外部定义 Throwable $primary 变量(初始为 null)。try 块内的代码保持不变。外层 catch 捕获 Throwable,将异常存储在 $primary 中并重新 throw。finally 块中:检查资源变量是否为 null → 如果 $primary != null,则在嵌套的 try-catch 中调用 resource.close(),如果 close 抛出异常则调用 $primary.addSuppressed(suppressed) 将 close 异常作为 suppressed exception 附加到主异常上;如果 $primary == null,则直接调用 resource.close()。字节码中会出现:多个异常表条目(嵌套的 try-catch-finally)、astore/aload 操作 $primary 变量、invokevirtual Throwable.addSuppressed(Throwable) 调用、条件跳转(ifnull / ifnonnull 判断资源是否为 null)、多路径的 close() 调用。整个展开可能将一个简单的 try-with-resources 扩展为 30+ 条字节码指令。

Q3:String switch 的字节码是如何实现 O(1) 分派的?为什么不需要担心哈希碰撞?

A:javac 的 Lower 将 String switch 转换为两阶段分派:第一阶段对 str.hashCode()lookupswitch(哈希值通常稀疏,所以用二分查找的 lookupswitch 而非跳转表的 tableswitch),跳转到对应 hash 值分支;第二阶段在每个 hash 分支中对 str.equals(caseValue) 做精确比较,如果 equals 返回 true 则跳转到业务代码,否则 fall through。平均时间复杂度接近 O(1)(hashCode + equals 检查),但最坏情况下为 O(n)(所有 case 字符串 hash 碰撞)。哈希碰撞确实会发生:Java 的 String.hashCode() 是众所周知的(s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]),攻击者可以构造碰撞字符串。但对于正常业务代码,碰撞概率极低。如果发生碰撞,javac 正确地在同一个 hash 分支中生成连续的 if-equals 级联,语义正确但性能退化。此外,javac 会在 hash 值不匹配时插入 null 检查(直接跳转到 default),避免 str.hashCode() 的 NPE。

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

A:d8 desugaring 解决的核心问题是 Android 平台碎片化——较新版本的 Java API(如 java.time.LocalDatejava.util.stream.Streamjava.util.Optionaljava.util.function 包中的函数式接口、interface 中的 default 方法和 static 方法)在低版本 Android(API level < 24 或 < 26)上根本不存在或行为不同。d8 在 .class.dex 转换时,将对缺失 API 的调用重写为对 desugar_jdk_libs 库中替代实现的调用。这与 javac 的 Desugar 有本质不同:javac Desugar 处理的是语法级糖(try-with-resources、foreach、switch-string、内部类等),在 .class 文件生成前完成,目标是降低 AST → 字节码的映射复杂度;d8 desugaring 处理的是API 级缺失,在 .class.dex 转换阶段完成,目标是保证代码在低版本 Android 上正确运行。此外,d8 还处理 Java 8 的 interface default/static methods(通过在 companion class 中生成对应的静态方法),以及 lambda 表达式的脱糖(将 invokedynamic + LambdaMetafactory 模式转换为生成内部类的传统模式)。javac 本身不处理这些,因为它们在标准 JVM 上由 java.lang.invoke 包支持。

打赏
  • 微信
  • 支付宝

评论