一、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() |
理解 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")是在后续的常量折叠阶段(Attr 或 ConstFold)完成的。
(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; |
解析器生成的 AST 结构为:
JCCompilationUnit |
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:泛型中的 <> |
对于 >> 的歧义,javac 使用了一个特殊规则:当解析器处于泛型类型参数的上下文中时,>> 被解析为两个 >(一个结束内层泛型参数,一个结束外层泛型参数)。当处于表达式上下文中时,>> 被解析为右移运算符。这个判断在 JavacParser 的 term2() 和 typeArguments() 方法中实现。
JavacParser 的另一个关键设计是 lookahead 机制。对于需要前瞻才能确定语法的情况(如 (expr) 可能是强制类型转换也可能是括号表达式),解析器使用 S 参数化前瞻(Scanner.Factory),在不确定时使用一个独立的扫描器尝试解析备选语法,成功则采用该路径,失败则回溯到原路径。这避免了全局回溯的同时,保留了处理语法歧义的能力。
2.4 错误恢复机制
javac 的一大优势是其语法错误恢复(error recovery)能力——即使在源文件存在语法错误的情况下,编译器依然能够继续解析后面的代码,从而在一次编译中报告尽可能多的错误(而非遇到第一个错误就停止)。
JavacParser 的错误恢复策略基于”跳读”(skip / resync)机制:
核心策略:跳读到同步 Token
当解析器在当前语法规则中遇到不符合预期的 Token 时,它使用 S.skip(boolean stopAtError) 方法跳过后续的 Token,直到遇到一个”同步 Token”——通常是分号 ;、右花括号 }、或关键字(class、void、int 等声明关键字),这些 Token 标志着新语句或新声明的开始。
具体流程为:
- 解析器检测到语法错误 → 调用
log.error(pos, key)报告错误。 - 调用
S.skip(true)跳读到下一个同步点。 - 错误恢复后继续解析剩余的源文件,从同步点继续正常的语法分析。
示例:
public class Demo { |
javac 会报告 int x = ; 行缺少表达式,但 System.out.println(x) 仍会被正常解析——JavacParser 在 variableDeclarator() 方法中遇到 = 后无法解析表达式时,会 skip 到 ;,然后继续解析后续语句。
错误恢复的限制:大括号不匹配时错误恢复效果较差。如果缺少一个 },编译器可能将后续的所有内容视为当前代码块的继续,导致一连串的级联错误(cascading errors)。IDE(如 IntelliJ IDEA 和 Eclipse)的解析器通常有更复杂的错误恢复策略(如推断缺失的大括号位置),因为它们需要支持不完整代码的增量解析,而 javac 的解析器偏向于保守的错误恢复。
2.5 行号与列号的错误报告
javac 的错误消息包含精确的源文件和行列号信息,这来源于每个 AST 节点和 Token 都携带的 DiagnosticPosition:
// com.sun.tools.javac.util.Position 中 |
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 作为独立实体 |
Symtab(com.sun.tools.javac.code.Symtab)是一个预初始化的”宇宙符号表”,包含了所有 Java 语言预定义的类型和符号:原始类型(int、long、float 等)、java.lang.Object、java.lang.String、java.lang.Throwable、java.lang.annotation.Annotation、数组的 clone() 方法等。Symtab 在编译器初始化时创建,后续所有阶段通过它来引用这些已知类型。
3.2 作用域的实现:Scope
com.sun.tools.javac.code.Scope 是符号表的存储容器。作用域是嵌套的——每个作用域有一个 next 指针指向外层作用域,形成链式结构。当查找一个符号时,先搜索当前作用域,找不到则沿 next 链向外查找。
Scope 内部使用哈希表 + 冲突链实现。核心结构:
// com.sun.tools.javac.code.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、顶级类)。然后 MemberEnter(com.sun.tools.javac.comp.MemberEnter)负责处理类内部的成员(字段、方法、内部类等)。
MemberEnter 的遍历策略:
- 遇到
JCClassDecl→ 在父作用域中注册ClassSymbol。创建一个新的作用域(membersScope)作为该 ClassSymbol 的成员作用域。 - 遇到
JCMethodDecl→ 创建MethodSymbol,包含参数列表的VarSymbol和返回类型。将方法符号注册到所在类的membersScope。 - 遇到
JCVariableDecl(字段)→ 创建VarSymbol,注册到所在类的membersScope。 - 遇到
JCImport→ 将 import 的类或静态成员信息记录到编译单元的namedImportScope或starImportScope。后续的名称解析会用到这些 import scope。
extends / implements 的解析:MemberEnter 通过 Resolve 组件解析超类和接口。它会将 AST 中的类型表达式(如 extends BaseActivity)通过 Attr.attribType() 或 Resolve.findIdent() 解析为具体的 ClassSymbol,然后设置子类 ClassSymbol.type 中的 supertype_field 和 interfaces_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 { |
4.2 处理轮次(Rounds)
注解处理以”轮次(round)”的形式运行:
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()进行类型操作。
- 通过
Round 2 及后续:如果 Round 1 中生成了新的源文件,javac 对这些新文件重新执行 Parse 和 Enter,然后进入 Round 2。处理器再次被调用。这个过程反复进行,直到某个轮次没有新文件生成。
最后一个 Round:
roundEnv.processingOver()返回true,处理器可以在此时释放资源(如关闭文件流)。
关键:处理器不能修改已有的 AST 节点——它们只能读取 AST 并生成新文件。这是 JSR 269 设计的一个核心约束(与 Lombok 等绕过此限制的工具形成对比)。Lombok 之所以能修改 AST,是因为它使用了 javac 的内部 API(通过 JavacAnnotationHandler 直接操作 JCTree 节点),这不是标准 JSR 269 的一部分。
4.3 注解处理器生成代码的内部流程
当处理器通过 Filer.createSourceFile("com.example.GeneratedClass") 创建源文件时:
JavacFiler(javac 的内部Filer实现)创建一个JavaFileObject表示待生成的文件。JavacProcessingEnvironment将这个JavaFileObject记录为”待处理的新源码”。- 当前轮次结束后,javac 将生成的新源文件加入待编译队列。
- 下一轮开始时,javac 对新源文件执行 Parse 和 Enter。
- 在 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)自动允许(如
int→long、float→double)。 - 引用类型的向上转型(upcast)自动允许(如
String→Object)。 - 原始类型到包装类型的自动装箱(如
int→Integer)在 Java 5+ 中允许。 - 引用类型到原始类型的自动拆箱(如
Integer→int)允许,但需要注意 NPE 风险。
(2)捕获转换(Capture Conversion)
通配符泛型(如 List<? extends Number>)在类型检查时需要”捕获转换”——将通配符替换为一个具名的”捕获类型变量”(capture type variable),以便进行后续的类型检查。
例如:
void process(List<? extends Number> list) { |
捕获转换的核心在 Types.capture(Type type) 中实现。它创建一个 CaptureType 对象,将通配符边界(upper bound 和 lower bound)转换为类型变量。
(3)方法重载决议(Method Overload Resolution)
当 Attr 遇到方法调用(如 foo(a, b)),且有多个候选方法(名称相同但参数类型不同)时,需要进行重载决议。javac 使用 JLS(Java Language Specification)第 15.12 节定义的三阶段重载决议算法:
- Phase 1:Strict Invocation — 不做装箱/拆箱,不做变长参数展开。如果恰好有一个方法在此阶段匹配,则选择它。
- Phase 2:Loose Invocation — 允许装箱/拆箱,但不展开变长参数。
- Phase 3:Variable Arity Invocation — 允许全部转换,包括变长参数展开。
如果多个候选方法在同一阶段都适用,则需要most-specific test(最具体测试)来确定哪个方法”更具体”。一个方法 m1 比 m2 更具体,当且仅当 m1 的每个形式参数类型都可以通过方法调用转换赋值给 m2 的对应形式参数类型。
Most-specific test 的一个典型歧义场景:
void foo(Integer x, int y) { ... } |
javac 的 Resolve 类中 mostSpecific() 方法负责此项测试,内部会遍历所有候选方法,两两比较看是否一个比另一个更具体。
5.3 类型推断与泛型
Infer(com.sun.tools.javac.comp.Infer)处理泛型方法的类型参数推断。当调用泛型方法而没有显式指定类型参数时:
<T> T pick(T a, T b) { return a; } |
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; |
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。具体流程:
- 对于每个方法调用,检查它声明的 throws 子句(来自 MethodSymbol 的
getThrownTypes())。 - 对于方法体中的 throw 语句,获取抛出异常的类型。
- 如果异常类型是 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 检查在 return、throw、break、continue 之后的语句是否不可达。但 Java 语言规范有一个特殊的例外:
if (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 的显式参数。
// 原始代码 |
策略 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$0—main方法中的第一个 lambdalambda$main$1—main方法中的第二个 lambdalambda$init$0— 构造方法中的第一个 lambda
这些合成方法带有 ACC_SYNTHETIC 和 ACC_PRIVATE 标志,在反射中默认不可见(除非使用 getDeclaredMethods())。
7.3 方法引用的处理
方法引用(如 System.out::println、String::length、ArrayList::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:
// 原始代码 |
关键实现细节:
- 使用
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 中语法糖消除最复杂的案例之一。
// 原始代码 |
关键变量和逻辑:
$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 对象(如 List、Set):
// 原始代码 |
$it 是编译器生成的不重复的临时变量名。注意迭代器变量在 for 循环头部声明,循环结束后就被回收。
对数组:
// 原始代码 |
将数组赋值给局部变量 $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) {} } |
这个桥接方法确保通过 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 42 或 iconst_<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 #index → dup → 参数压栈 → 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 +1iadd:stack -2 + 1 = -1(弹出两个 int,压入一个 int)invokevirtual:stack 减少参数量 + 减少接收者 + 增加返回值(如果有)dup:stack +1dup2:stack +2
max_stack = 记录到的 cur_stack 的最大值。对于包含分支和跳转的方法,Gen 需要保守估算所有执行路径上的最大栈深度——如果不同路径的栈深度不同,取所有路径的最大值。
max_locals 的计算:Gen 为每个局部变量分配一个”槽位”(对于 long 和 double 类型占两个槽位)。槽位的分配策略:方法参数占用前 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 |
10.2 阶段 1:Parse
JavacParser.parseCompilationUnit() 被调用。JavaTokenizer 将源码切分为 Token 流:
[PUBLIC] [CLASS] [IDENTIFIER "Demo"] [LBRACE] |
JavacParser 基于 Token 构建 AST:
JCCompilationUnit |
10.3 阶段 2:Enter
Enter 和 MemberEnter 将符号注册到符号表:
- 创建
ClassSymbol(Demo),其membersScope 初始为空。 - 将
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])sum的JCTree.type=intJCBinary(i <= n)的JCTree.type=booleanJCAssign(sum += i)的JCTree.type=intJCReturn检查返回类型int与方法声明的返回类型int匹配
10.5 阶段 5:Flow
Flow 进行数据流分析:
sum在int sum = 0;处被确定赋值,之后的使用有效。i在int 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) |
常量池条目(简化):
- #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 { |
这就是一个简单的 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 + 2 → 3) |
主要优化器:常量传播、死代码消除、方法内联、字符串去重、类合并、枚举拆箱 |
| 类型系统 | JVM 类型系统(栈式指令集) | Dalvik 类型系统(寄存器式指令集) |
| 运行时支持 | 依赖 JVM 提供的标准库 | 依赖 ART/Dalvik + Android SDK + desugar_jdk_libs |
d8 的一个关键角色是 API desugaring。当代码中使用了较新版本的 Java API(如 java.time.LocalDate、java.util.stream.Stream、java.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.LocalDate、java.util.stream.Stream、java.util.Optional、java.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 包支持。


