简介:一个Java编译器实现的最小压缩包,面向Java学习者、编译原理课程学生以及希望理解javac底层逻辑的开发者。包内共2个文件,包含1个Java源代码文件和1个Markdown说明文档,整体大小仅2KB。Java源文件对应简易IDE或编译器的实现入口,能够展示从源码读取、词法分析、语法分析到语义检查和中间代码生成的完整主线;说明文档则汇总项目背景、运行环境、使用步骤与FAQ,降低上手门槛。内容紧扣Java编译器典型工作流程,涉及词法分析器、语法树构建、语义分析、代码优化及字节码生成等关键环节,适合对照教材补充实践。目前已有59人浏览/学习,可作为课程设计、实验报告或入门编译技术的参考材料。虽然体积很小,但代码结构清晰,结合JDK自带javac或Eclipse ECJ对比阅读,能帮助读者快速体会不同编译策略的取舍,为后续实现自定义语言编译器打下基础。
1. Java编译器实现:从字节码到可执行文件的完整链路
接手"Java编译器的实现"这个题目时,我第一反应是:这不就是javac那点事吗?但真把整个链路拆开才发现,从.java源文件到 JVM 能识别的.class字节码,中间藏着词法分析、语法分析、语义分析、字节码生成四大关卡,每一关都有不少坑。这篇文章不是讲 Javac 源码赏析,而是带你用手写一个最小可用编译器的思路,把整条链路从黑匣子变成看得见摸得着的东西。适合刚读完编译原理想落地验证的同学,也适合工作两年左右、天天写 Java 却想知道 class 文件里到底是什么的从业者。读完你不仅能跑通一个 demo 编译器,还能在自己项目里复现词法/语法分析流程,遇到 JVM 相关报错时也能快速定位问题源头。
2. 编译前端:词法分析与 Token 流构建
2.1 为什么先做词法分析而不是直接解析源码
很多第一次接触编译器实现的人容易犯一个错误:上来就想着怎么处理完整的 Java 语法。实际上编译器的第一个阶段是词法分析,它干的事情很纯粹——把源代码字符串按照规则切分成一个个有意义的单词,也就是 Token。这个过程类似于把一段中文按词切开,只不过切分的规则由编程语言的词法规范决定。
Java 的词法分析需要处理的 Token 类型大致包括:关键字(class、public、static)、标识符(类名、方法名、变量名)、字面量(整数、浮点数、字符串、布尔值)、运算符(+、-、*、=)、分隔符({、}、(、))以及注释。每一类都有自己的正则匹配规则,而手写词法分析器通常采用逐个字符扫描 + 状态机转移的方式实现。
我一般会建议新人在动手写代码前,先画一个简单的状态转移草图。比如识别一个标识符时,遇到字母或下划线进入"标识符状态",随后持续接收字母、数字、下划线,直到遇到其他字符才结束并输出 Token。这个草图可能只有五六个状态,但画完以后代码写起来特别顺,能省掉大量调试时间。
2.2 最小可用的词法分析器代码骨架
下面是一个能处理class、public、static、void、main、标识符、数字、字符串以及基础运算符的简化版词法分析器。它虽然不能编译完整 Java 文件,但足够跑通HelloWorld以及简单赋值语句的 Token 化过程。
import java.util.ArrayList; import java.util.List; public class Lexer { private final String source; private int pos = 0; private int line = 1; public Lexer(String source) { this.source = source; } public List<Token> tokenize() { List<Token> tokens = new ArrayList<>(); while (pos < source.length()) { char c = source.charAt(pos); // 跳过空格,换行时行号递增 if (Character.isWhitespace(c)) { if (c == '\n') line++; pos++; continue; } // 处理注释:单行 // 与多行 /* */ if (c == '/' && pos + 1 < source.length() && source.charAt(pos + 1) == '/') { while (pos < source.length() && source.charAt(pos) != '\n') pos++; continue; } if (c == '/' && pos + 1 < source.length() && source.charAt(pos + 1) == '*') { pos += 2; while (pos + 1 < source.length() && !(source.charAt(pos) == '*' && source.charAt(pos + 1) == '/')) { if (source.charAt(pos) == '\n') line++; pos++; } pos += 2; continue; } // 标识符与关键字 if (Character.isJavaIdentifierStart(c)) { int start = pos; while (pos < source.length() && Character.isJavaIdentifierPart(source.charAt(pos))) { pos++; } String word = source.substring(start, pos); tokens.add(new Token(TokenType.IDENTIFIER, word, line)); continue; } // 数字字面量 if (Character.isDigit(c)) { int start = pos; while (pos < source.length() && Character.isDigit(source.charAt(pos))) { pos++; } tokens.add(new Token(TokenType.INTEGER_LITERAL, source.substring(start, pos), line)); continue; } // 字符串字面量 if (c == '"') { pos++; int start = pos; while (pos < source.length() && source.charAt(pos) != '"') { if (source.charAt(pos) == '\n') line++; pos++; } if (pos >= source.length()) { throw new RuntimeException("Unterminated string at line " + line); } String value = source.substring(start, pos); pos++; // 跳过结尾的 " tokens.add(new Token(TokenType.STRING_LITERAL, value, line)); continue; } // 运算符和分隔符 if (isOperatorOrDelimiter(c)) { tokens.add(new Token(TokenType.OPERATOR, String.valueOf(c), line)); pos++; continue; } throw new RuntimeException("Unexpected character '" + c + "' at line " + line); } tokens.add(new Token(TokenType.EOF, "", line)); return tokens; } private boolean isOperatorOrDelimiter(char c) { return "+-*/=;{}()".indexOf(c) >= 0; } }这段代码的逻辑并不复杂:主循环不断从源码中取一个字符并判断类型,空格与注释直接跳过但换行要记录行号;标识符和关键字共用同一套识别逻辑,区别在于后续语法分析阶段会查表判断它是不是关键字;数字只处理了整数形式,浮点数需要额外处理小数点后的部分;字符串遇到未闭合的情况直接抛异常,帮助快速定位源码中的问题。
需要注意的一个细节是行号的维护。编译器报错时,用户最关心的就是出错位置。如果不维护行号,后期语法分析阶段报错只能显示 Token 序列,排查成本会翻好几倍。这个简陋版里我特地在换行时递增行号,并在 Token 里带上行号信息,就是为后续错误提示做的铺垫。
2.3 Token 流与常见词法陷阱
词法分析输出的 Token 流是下一阶段语法分析的输入。每个 Token 至少要包含类型和字面值两个字段,行号属于锦上添花但强烈建议保留。设计 Token 类时,我习惯把它写成不可变对象,这样调试时不会因为某个阶段误修改了 Token 内容而排查半天。
public class Token { public final TokenType type; public final String value; public final int line; public Token(TokenType type, String value, int line) { this.type = type; this.value = value; this.line = line; } @Override public String toString() { return "Token{" + type + ", '" + value + "', line " + line + "}"; } }见惯了各种翻车现场后,我总结出一个血泪经验:编译器的坑很少在正路上,几乎都在边界条件里。举个例子,处理==和=时,如果词法分析器只按单字符切分,语法分析阶段就需要额外合并逻辑,会产生不自然的语法树结构;处理++和+时同理。解决方法是词法分析阶段就做最长匹配——遇到+字符后先看一眼下一个字符是不是+,是就输出自增运算符 Token。这类问题在写完整语法分析器时才会暴露,提前在词法层解决是最省力的路径。
3. 语法分析:构建抽象语法树的核心方法
3.1 递归下降解析器的设计思路
拿到 Token 流以后,下一步是语法分析——检查 Token 序列是否符合 Java 语法规则,并生成抽象语法树(AST)。AST 是编译器的中枢数据结构,后续的语义分析和字节码生成都基于它展开。
业界实现语法分析大体有两条路线:用工具生成(如 ANTLR、JavaCC)和手写递归下降解析器。对于想理解编译器本质的人来说,手写递归下降是绕不开的基础功。它的核心思想是:为每一条语法规则写一个解析函数,函数之间通过互相调用来匹配嵌套的语言结构。例如解析一个类声明时,先匹配class关键字,再匹配类名标识符,然后在匹配{后递归调用"成员解析函数"处理类体内的字段和方法。
递归下降解析器代码可读性好、报错位置精准,缺点是不能处理左递归语法规则。好在 Java 的语法规则经过精心设计,直接用递归下降完全够用。我个人的习惯是:手写解析器 + 轻量级 AST 节点定义,这套方案在中小规模语言上工程效率最高。
3.2 从类声明到方法体的 AST 构建示例
下面这段代码演示如何把简化后的类声明解析成抽象语法树。这个版本支持空类体、带一个方法声明的类,方法内支持变量声明和赋值语句,已经能覆盖教学级编译器的语法范围。
import java.util.ArrayList; import java.util.List; public class Parser { private final List<Token> tokens; private int pos = 0; public Parser(List<Token> tokens) { this.tokens = tokens; } public ClassNode parseClass() { // 期望 class + 类名 + { expect(TokenType.IDENTIFIER, "class"); Token className = expect(TokenType.IDENTIFIER, null); expect(TokenType.OPERATOR, "{"); List<MethodNode> methods = new ArrayList<>(); while (current().type == TokenType.IDENTIFIER && isMethodStart(current().value)) { methods.add(parseMethod()); } expect(TokenType.OPERATOR, "}"); return new ClassNode(className.value, methods); } private MethodNode parseMethod() { // 简化版:public static void main(String[] args) { ... } expect(TokenType.IDENTIFIER, "public"); expect(TokenType.IDENTIFIER, "static"); expect(TokenType.IDENTIFIER, "void"); Token name = expect(TokenType.IDENTIFIER, null); expect(TokenType.OPERATOR, "("); // 这里省略参数列表解析 expect(TokenType.OPERATOR, ")"); expect(TokenType.OPERATOR, "{"); List<StatementNode> body = parseStatements(); expect(TokenType.OPERATOR, "}"); return new MethodNode(name.value, body); } private List<StatementNode> parseStatements() { List<StatementNode> statements = new ArrayList<>(); while (current().type == TokenType.IDENTIFIER || current().type == TokenType.INTEGER_LITERAL) { // 变量声明或赋值语句 Token identifier = expect(TokenType.IDENTIFIER, null); if (current().type == TokenType.OPERATOR && current().value.equals("=")) { pos++; // 跳过 = Token value = expect(TokenType.INTEGER_LITERAL, null); expect(TokenType.OPERATOR, ";"); statements.add(new AssignNode(identifier.value, value.value)); } else if (current().type == TokenType.OPERATOR && current().value.equals(";")) { pos++; statements.add(new VarDeclNode(identifier.value)); } else { throw new RuntimeException("Unexpected token at line " + current().line + ": " + current().value); } } return statements; } private Token expect(TokenType type, String value) { Token token = current(); if (token.type != type || (value != null && !token.value.equals(value))) { throw new RuntimeException("Expected " + type + " " + value + " but got " + token + " at line " + token.line); } pos++; return token; } private Token current() { return tokens.get(Math.min(pos, tokens.size() - 1)); } private boolean isMethodStart(String word) { return word.equals("public") || word.equals("static") || word.equals("void") || word.equals("int") || word.equals("String"); } }这个解析器有几个值得注意的设计决策。第一个是把public、static、void都当作普通标识符来处理,解析时直接按值匹配——这样避免了在词法阶段维护关键字的繁琐逻辑,代价是语法分析阶段需要自己判断标识符是不是关键字。第二个是用current()统一获取当前 Token,并配合pos前移来实现消费操作,整个解析过程线性推进,不会出现回溯。
变量声明和赋值语句的解析逻辑有一点简化:它把int x;和x = 42;分开处理,但不区分int x = 42;这种声明并初始化的场景。实际扩展时可以在赋值解析分支里判断等号左边标识符是否被声明过,这里留作练习比直接写死更有意义。
3.3 语法分析阶段的 3 条避坑经验
坑一:解析器吞掉错误 Token 导致报错位置偏移。现象是源码第 5 行出错,报错信息却指向第 2 行。原因通常是解析某个结构失败后没有立即抛出异常,而是继续向后解析直到遇到不匹配才报错。解决方法是每当expect()失败时立即抛出带行号的异常,不要尝试恢复。
坑二:空指针异常不显示任何定位信息。现象是没有自定义异常,而是 JVM 直接抛NullPointerException。原因是 AST 节点的某个字段没有初始化,而语法分析阶段没有相应校验。解决方法是 AST 节点字段使用final修饰并在构造函数里全部初始化,这样编译器会在构造时暴露缺失字段。
坑三:处理else悬空问题时出现语法歧义。现象是嵌套 if-else 解析出来的 AST 结对错误。原因是没有约定else与最近的if配对。解决方法是实现parseIfStatement()时,在解析完if条件后的语句体后,主动检查下一个 Token 是否为else,是则继续解析else分支,否则直接返回——这个约定与 Java 语言规范一致。
4. 语义分析与符号表:让代码真正"有意义"
4.1 语义分析在编译链路中的位置与职责
语法分析只保证代码"长得像"Java,语义分析才负责检查代码"是否有意义"。经典的检查项包括:变量是否声明后再使用、方法调用参数个数和类型是否匹配、赋值操作两侧类型是否兼容、return语句是否出现在没有返回值的方法里。
语义分析的核心数据结构是符号表(Symbol Table)。符号表维护了从名字到其类型和属性的映射关系,作用域嵌套时采用链式结构——内层作用域可以访问外层符号,但反过来不行。整个语义分析过程几乎就是遍历 AST,在遍历过程中查询和更新符号表。
对于教学级编译器,语义分析的一个重要简化策略是:AST 上直接标注类型信息,而不是单独维护一个庞大的符号表类。好处是字节码生成阶段可以直接从 AST 节点读取类型,省去信息传递的繁琐步骤。
4.2 类型检查与作用域管理的最小实现
下面这段代码展示如何为简易 AST 做类型检查。它遍历类下的方法体,在方法入口处为参数建立作用域,然后逐条语句检查变量使用是否合法。
import java.util.HashMap; import java.util.Map; public class SemanticAnalyzer { private final Map<String, String> typeEnv = new HashMap<>(); public void analyze(ClassNode classNode) { for (MethodNode method : classNode.methods) { analyzeMethod(method); } } private void analyzeMethod(MethodNode method) { // 方法参数加入符号表:这里的参数名是 args,类型是 String[] typeEnv.put("args", "String[]"); for (StatementNode stmt : method.body) { if (stmt instanceof VarDeclNode) { // 变量声明:int x; 这种形式 VarDeclNode decl = (VarDeclNode) stmt; if (typeEnv.containsKey(decl.name)) { throw new RuntimeException("Variable " + decl.name + " already declared in method " + method.name); } typeEnv.put(decl.name, "int"); } else if (stmt instanceof AssignNode) { AssignNode assign = (AssignNode) stmt; if (!typeEnv.containsKey(assign.name)) { throw new RuntimeException("Variable " + assign.name + " not declared at line " + assign.line); } // 这里简化了类型检查:只允许 int = int if (!typeEnv.get(assign.name).equals("int")) { throw new RuntimeException("Type mismatch for " + assign.name); } } } } }这个实现的精妙之处在于用Map<String, String>充当符号表,key 是变量名,value 是类型字符串。作用域管理目前是扁平的,方法内嵌套代码块时会发现变量泄漏问题——比如{ int x; }之后的语句还能访问x,这显然不符合 Java 语义。真实项目里需要引入作用域栈,在进入代码块时压入新层,退出时弹出,保证变量只在自己的作用域内可见。
4.3 语义分析常见的两个隐蔽问题
问题一:变量遮蔽导致符号表污染。现象是方法参数叫value,方法内部又声明了一个局部变量value,后面的代码误用了最开始那个参数的类型。原因是符号表没有区分作用域层级,后插入的符号直接覆盖了外层同名符号。解决方法是维护一个作用域栈,查找符号时从栈顶向下逐层查询,先命中先返回,而不是直接查全局 Map。
问题二:类型检查顺序错误导致下游信息缺失。现象是编译器报告"未声明变量"但源码里明明声明了。原因是语义分析按源码顺序逐条检查,而 Java 的规则是允许先使用后声明(仅限类字段),局部变量必须先声明后使用。区分这两类规则需要在语义分析阶段明确变量声明的位置——类字段属于类型级别符号,局部变量属于方法体级别符号,两者查找时机不同。解决方法是先完成类字段的符号登记,再进入方法体做局部变量的透查。
5. Java编译器实现避坑指南:从词法到字节码的 5 个典型翻车点
5.1 词法阶段:注释与字符串交叉误判
现象:源码里字符串常量包含//序列,例如String s = "http://example";,词法分析器把//后面部分当成注释,字符串提前截断,导致后续 Token 错乱。
原因:状态机设计没有区分"字符串内"和"字符串外"两个状态。我的第一版词法分析器就是先判断注释,再判断字符串,结果"http://"里的//被优先匹配为注释起点。
解决:调整判断顺序,先完整匹配字符串字面量,再匹配注释。这样遇到"时就进入字符串状态,直到闭合引号前不检查任何注释符号。代码顺序就是先写字符串分支,再写注释分支。
5.2 语法阶段:赋值语句中的括号无法解析
现象:编译int x = (1 + 2) * 3;时报错 "Unexpected token ( at line 1"。
原因:简易解析器没有实现表达式优先级和括号处理,遇到(1 + 2)时不知道如何递归解析。我最初的设计把赋值语句的右值限定为单个字面量 Token,完全忽略了表达式的存在。
解决:在赋值语句解析分支中,把右值解析逻辑委托给parseExpression()函数,这个函数实现+ - * /的标准优先级嵌套:先解析乘除项,再解析加减项,括号内递归调用整个表达式解析逻辑。
5.3 语义阶段:main方法参数类型误判为 String[]
现象:定义了public static void main(String[] args),但语义分析阶段把args的类型记录成了String,导致后续所有对args的操作类型检查报错。
原因:语义分析器在方法入口处错误地将参数类型简化为String,没有正确解析方括号[]。这种"看起来差不多但细节不同"的问题在真实编译器里格外阴险,因为报错位置往往离真正出错的地方很远。
解决:在 AST 的设计上,参数类型单独用一个类型节点表示,数组类型用内部元素类型 + 维度来记录,语义分析阶段只读取这个类型节点的描述,不做任何字符串拼接。这样String[]和String从 AST 结构上就是不同的类型,天然不会混淆。
5.4 字节码生成阶段:局部变量表索引错位
现象:方法内有多个局部变量时,字节码运行结果与源码语义不一致,某个变量的值被另一个变量覆盖了。
原因:生成istore指令时,局部变量槽位分配顺序与变量声明顺序不完全一致。Java 虚拟机规范里main(String[] args)的参数占槽位 0,后续局部变量从槽位 1 开始分配,但某些实现里this引用在实例方法中占据槽位 0,静态方法没有this,两者的基址不同。
解决:在生成局部变量指令前,先遍历方法参数和局部变量,按 JVM 规范真实计算每个变量的槽位序号。参数和实例方法的this放在最前面,然后才是方法体内声明的局部变量,最后将这个槽位映射表贯穿整个字节码生成过程。
5.5 完整链路层面:JDK 版本差异导致的 class 文件版本问题
现象:用新版 JDK 编译出来的 class 文件放在旧版 JVM 上运行,直接报UnsupportedClassVersionError。
原因:class 文件头部有一个major_version字段,JVM 只接受不高于自身支持版本的 class 文件。新版 JDK 默认以自身版本为目标,不会自动降级兼容较老 JVM。
解决:如果是教学实验项目,编译目标用-target 8或者直接用 JDK 8 编译。手写字节码生成器时,固定写死major_version字段为 52(对应 Java 8),这样生成的 class 文件在绝大多数 JDK 上都能运行。这条问题容易被忽略,但它每次都炸在最不该炸的生产环境上。
6. 字节码生成与验证:用 200 行代码产出可运行的 class 文件
6.1 理解 class 文件的二进制布局
语义分析完成并确定了所有变量的类型与槽位后,最后一步是把 AST 翻译成 JVM 能执行的字节码。class 文件的本质是一个结构化的二进制文件,关键区块包括:魔数(0xCAFEBABE)、次版本号、主版本号、常量池、访问标志、类名索引、父类索引、接口集合、字段表、方法表、属性表。
教学级实现中最核心的是常量池和方法表两块。常量池存放类名、方法名、字符串字面量等常量数据,每个常量都有一个 tag 标明类型;方法表里的 Code 属性包含实际字节码指令序列。整个 class 文件就是一个大字节数组,用 DataOutputStream 按序写入整数和字节即可完成生成。
6.2 最小字节码生成器:输出可运行的 HelloWorld
下面这段代码生成一个最简单类Hello,它有一个public static void main(String[] args)方法,方法体内执行System.out.println("Hello")。为了展示重点,我用常量池索引直接写入,省略了System.out的字段解析细节。
import java.io.DataOutputStream; import java.io.FileOutputStream; import java.io.IOException; public class BytecodeGenerator { private static final int MAGIC = 0xCAFEBABE; private static final int MINOR_VERSION = 0; private static final int MAJOR_VERSION = 52; // Java 8 /** * 常量池简化为有限几个条目,实际需要支持几十种常量类型 */ public void generate(String className, String outputPath) throws IOException { try (DataOutputStream out = new DataOutputStream(new FileOutputStream(outputPath))) { out.writeInt(MAGIC); out.writeShort(MINOR_VERSION); out.writeShort(MAJOR_VERSION); // 常量池表数量(简化为 0,便于阅读结构) // 真实实现需要计算常量数量 out.writeShort(0); // 访问标志:public out.writeShort(0x0021); // ACC_PUBLIC + ACC_SUPER // 类名索引与父类索引(简化为 0) out.writeShort(0); out.writeShort(0); // 接口数量为 0,字段数量为 0 out.writeShort(0); out.writeShort(0); // 方法数量为 1 out.writeShort(1); // 这里开始写 main 方法的描述 writeMainMethod(out); } } private void writeMainMethod(DataOutputStream out) throws IOException { // 方法访问标志:public static out.writeShort(0x0009); // 方法名索引与描述符索引(简化:0,重名索引) out.writeShort(0); out.writeShort(0); // 属性数量为 1:Code out.writeShort(1); out.writeShort(0); // 属性名索引(Code) out.writeInt(1); // 属性长度占位 out.writeShort(1); // 最大栈深度 out.writeShort(1); // 局部变量槽位数 out.writeInt(2); // 代码长度 out.writeByte(0xB1); // return 指令 out.writeShort(0); // 异常表长度 out.writeShort(0); // 属性数量 } }这段代码生成的 class 文件虽然方法体和参数索引全是占位符,但已经完整展示了 class 文件二进制布局的骨架。真实项目需要在常量池里注册类名Hello、方法名main、描述符([Ljava/lang/String;)V,然后在 Code 属性里写入真正的指令序列。
关于字节码生成,我最想提醒的一点是:不要手工计算常量池索引。Java 常量池数量一多,手写索引几乎必然出错。正确做法是维护一个字符串到索引的映射表,每次往常量池中添加新常量时自动分配索引,代码生成时只查询这个映射表。
6.3 用javap验证生成的字节码是否合法
写完字节码生成器后,最怕的是生成了结构错误的 class 文件却不知道错在哪。验证手段有两种:
第一步,用javap -verbose解析生成的 class 文件,查看常量池、方法表和字节码指令是否与预期一致。javap是 JDK 自带的类文件反汇编工具,当它能正常解析时,至少说明二进制结构没大问题。
第二步,直接java Hello运行测试。如果 JVM 报出VerifyError,通常是因为方法描述符与真实签名不匹配、局部变量槽位使用越界或操作数栈深度不足。这一类错误信息比较抽象,我一般先把常量池导出来逐项对比,大多数问题都能在常量池阶段暴露——比如方法名写成Main而 JVM 期望main,描述符里漏写了括号等。
字节码排障的核心习惯是:先javap再运行。跳过javap直接运行,遇到问题就少了一个定位维度。我踩过的坑里,有一个就是方法描述符少写了一个L前缀,导致 JVM 认为方法签名不合法,javap一看就明白了。
6.4 进阶建议:从固定输出到 AST 驱动的通用生成器
上面那个字节码生成器写死了 main 方法的输出,距离"编译任意 AST"还有几步路要走。常见做法是写一个CodeGenerator类,它遍历 AST 并为每种节点生成对应指令:VarDeclNode生成iload/istore指令,AssignNode生成iconst_*加istore,MethodCallNode生成invokevirtual等。这一步工作量最大,但也是编译器从玩具走向可用的分水岭。
入门路线我建议这样安排:先保持 AST 和代码生成器一对一映射,每种节点类型对应一个visit方法,不要提前做优化。能正确为int x = 10; int y = x + 1;生成字节码并算对结果,我的编译器之路才算真正跑通。之后再考虑表达式优先级优化、分支跳转偏移量计算和局部变量表合并这些进阶话题,每一步都有对应的验证手段,不会迷路。
最后分享一个我的习惯:每次动手改编译器的某个阶段前,先写一个最小失败用例,等代码改完用例能通过,就说明这个阶段没有回归问题。尤其改完语法分析器后,把之前所有能通过的 Java 文件重新编译一遍,是最朴素也最有效的防翻车手段。希望这篇笔记能帮你在编译器的实现路上少走几个来回,照着上面的链路把词法、语法、语义、字节码生成挨个跑通,你就是能把 JVM 的黑匣子点亮的人了,希望帮到你。
本文还有配套的精品资源,点击获取