恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
从零实现Java编译器:词法分析到字节码生成全实践
首页
资讯中心
/
从零实现Java编译器:词法分析到字节码生成全实践
从零实现Java编译器:词法分析到字节码生成全实践
发布时间:2026/10/13 1:15:37
简介压缩包仅约2KB内含两个文件一个Java源码雏形与一份Markdown说明文档围绕Java编译器的实现展开。内容覆盖将Java源码转换为JVM字节码所需的核心阶段包括词法分析、语法分析、语义分析、代码生成与优化等可帮助读者理解javac等编译器的基本工作流程。Java源码文件可能提供简易的编译、运行接口说明文档则记录项目用途、配置方式和使用建议两者配合可以快速建立从源码到字节码的直观认识适合作为编译原理课程的补充案例也可为自制编译器或IDE提供起步参考。相比大型工程这种极简结构更容易聚焦编译器主体逻辑降低初学者上手门槛。目前已有58人学习下载适合希望以最小成本体验编译器实现过程、深入理解Java语言底层机制的开发者。1. 为什么值得亲手实现一个 Java 编译器这个 zip 解决的是「黑匣子」问题如果你写过几年代码却回答不出「javac 按下回车之后你的 .java 到底经历了什么」——那这个标题里的 zip 大概率是为你准备的。Java 编译器实现不是一门「看看原理就够」的课它要求你把词法分析、语法分析、语义检查、字节码生成每一层都落到代码上。很多人学编译原理时用的是玩具语言但真正让他写一个能输出 .class 文件、能被 java 命令直接跑起来的 Java 子集编译器才会发现坑远比想象多。这个方向适合三类人准备 Java 开发工程师面试想彻底搞懂 JVM 前端的人、编译原理课程设计选了类 Java 语言的人、以及单纯想拆开黑匣子看看 javac 每天都在干什么的人。下面按我从词法到字节码的落地顺序把整条链路讲清楚。2. 词法分析与语法分析先把源码拆成 Token再组装成 AST2.1 词法分析手写 Tokenizer识别关键字、标识符、数字和运算符词法分析是整个编译器里最「机械」却最容易出错的一层。它的任务很简单把源代码字符串切成一串有类型的 Token。比如int a 42;应该被切成KEYWORD_INT、IDENTIFIER(a)、ASSIGN、NUMBER(42)、SEMICOLON这五个 Token。很多人觉得这步可以用正则表达式库对付过去但做 Java 编译器实现时我强烈建议手写一个状态机式的扫描器。原因有两个一是手写扫描器能精确控制错误位置比如第几行第几列出现非法字符二是后续语法分析阶段需要 Token 携带行号正则库在这件事上很别扭。一个能跑的最小词法分析器可以这样组织先定义 Token 类型枚举再定义 Token 类最后写 Lexer 的nextToken()。我一般会把关键字表放在一个 HashMap 里标识符扫描完再去查表这样能自然区分「int」是关键字还是变量名。public enum TokenType { IDENT, NUMBER, KEYWORD, OP, LPAREN, RPAREN, LBRACE, RBRACE, SEMI, EOF } public class Token { public final TokenType type; public final String text; public final int line; public Token(TokenType type, String text, int line) { this.type type; this.text text; this.line line; } } public class Lexer { private final String src; private int pos 0; private int line 1; private static final SetString KEYWORDS Set.of(int, long, if, else, while, return, void); public Lexer(String src) { this.src src; } public Token nextToken() { while (pos src.length() Character.isWhitespace(src.charAt(pos))) { if (src.charAt(pos) \n) line; pos; } if (pos src.length()) return new Token(TokenType.EOF, , line); char c src.charAt(pos); if (Character.isDigit(c)) return number(); if (Character.isLetter(c) || c _) return identOrKeyword(); if (c || c || c - || c * || c /) { pos; return new Token(TokenType.OP, String.valueOf(c), line); } if (c () { pos; return new Token(TokenType.LPAREN, (, line); } if (c )) { pos; return new Token(TokenType.RPAREN, ), line); } if (c {) { pos; return new Token(TokenType.LBRACE, {, line); } if (c }) { pos; return new Token(TokenType.RBRACE, }, line); } if (c ;) { pos; return new Token(TokenType.SEMI, ;, line); } throw new RuntimeException(第 line 行出现非法字符: c); } private Token number() { StringBuilder sb new StringBuilder(); while (pos src.length() Character.isDigit(src.charAt(pos))) sb.append(src.charAt(pos)); return new Token(TokenType.NUMBER, sb.toString(), line); } private Token identOrKeyword() { StringBuilder sb new StringBuilder(); while (pos src.length() (Character.isLetterOrDigit(src.charAt(pos)) || src.charAt(pos) _)) sb.append(src.charAt(pos)); String word sb.toString(); TokenType type KEYWORDS.contains(word) ? TokenType.KEYWORD : TokenType.IDENT; return new Token(type, word, line); } }这段代码的逻辑本身不复杂但有三个参数值得注意。第一是KEYWORDS集合的选取它是编译器支持语言范围的直接体现如果你后续要支持boolean、class必须在这里补上否则boolean会被当成普通标识符。第二是line计数方式我只在nextToken()开头对换行计数这种惰性计数的好处是每个 Token 拿到的行号都准确坏处是如果源文件里有//注释你必须在这里跳过否则注释里的换行会被漏掉。第三是运算符扫描现在只处理单字符运算符一旦要支持、就要改成「先读一个字符再看下一个字符能不能拼成双字符运算符」的 lookahead 写法。词法层最常见的翻车现场是把和混在一起。如果你一开始就支持扫描器必须写成if (c peek() ) { pos 2; return new Token(TokenType.OP, , line); }。这看似小事但等你做语法分析时发现if (a b)竟然被解析成了赋值表达式而不是报错再回头改词法代价就高了。所以我的经验是词法分析器的运算符表要在动笔写 Parser 之前定全哪怕有些运算符暂时用不上也先留好 Token 类型。2.2 递归下降语法分析一组函数把 Token 流变成 AST词法输出的是扁平 Token 流语法分析负责把它们变成有层级的 AST。Java 编译器的语法分析主流方案有两种一种是写 yacc/ANTLR 文法让工具生成解析器另一种是手写递归下降解析器。在自己实现一个教学级 Java 编译器时我几乎总是选递归下降。原因很直接递归下降的代码结构和文法规则一一对应出错了你能在栈上看到是哪个函数在处理哪个产生式调试体验比查生成器冲突报告好太多。下面是一个能解析「变量声明 赋值 返回语句」极简文法的手写 Parser。AST 节点我用一个接口加若干实现类表示Expr和Stmt分成两个继承体系这是 Java 编译器里最经典的结构设计。interface Stmt {} class AssignStmt implements Stmt { String name; Expr value; } class ReturnStmt implements Stmt { Expr value; } interface Expr {} class NumberExpr implements Expr { int value; } class VarExpr implements Expr { String name; } class BinaryExpr implements Expr { String op; Expr left, right; } public class Parser { private final Lexer lexer; private Token cur; public Parser(Lexer lexer) { this.lexer lexer; cur lexer.nextToken(); } private void advance() { cur lexer.nextToken(); } private boolean check(TokenType t) { return cur.type t; } private boolean match(TokenType t) { if (check(t)) { advance(); return true; } return false; } public Stmt parseStatement() { if (match(TokenType.KEYWORD)) { if (cur ! null cur.text.equals(return)) { advance(); Expr v parseExpression(); match(TokenType.SEMI); return new ReturnStmt(v); } if (cur ! null cur.text.equals(int)) { advance(); String name cur.text; advance(); match(TokenType.OP); // 吃掉 Expr v parseExpression(); match(TokenType.SEMI); return new AssignStmt(name, v); } if (cur ! null cur.text.equals(if)) return parseIfStmt(); } throw new RuntimeException(无法解析的语句位于第 cur.line 行: cur.text); } public Expr parseExpression() { return parseAdditive(); } private Expr parseAdditive() { Expr left parseMultiplicative(); while (cur.type TokenType.OP (cur.text.equals() || cur.text.equals(-))) { String op cur.text; advance(); Expr right parseMultiplicative(); left new BinaryExpr(op, left, right); } return left; } private Expr parseMultiplicative() { Expr left parsePrimary(); while (cur.type TokenType.OP (cur.text.equals(*) || cur.text.equals(/))) { String op cur.text; advance(); Expr right parsePrimary(); left new BinaryExpr(op, left, right); } return left; } private Expr parsePrimary() { if (cur.type TokenType.NUMBER) { int v Integer.parseInt(cur.text); advance(); return new NumberExpr(v); } if (cur.type TokenType.IDENT) { String name cur.text; advance(); return new VarExpr(name); } if (match(TokenType.LPAREN)) { Expr e parseExpression(); match(TokenType.RPAREN); return e; } throw new RuntimeException(意外的表达式起点: cur.text 第 cur.line 行); } }这段代码的语法分析思路是「每个产生式对应一个方法方法里按需调用其他方法」。parseExpression走parseAdditive加法表达式又依赖parseMultiplicative乘法表达式再依赖parsePrimary这种分层保证了运算符优先级a b * c会被解析成a (b * c)而不是(a b) * c。而while循环而不是if递归是为了处理同级运算符的左结合比如a - b - c会被正确地构建成(a - b) - c。这里有几个只有写的时候才会意识到的细节。第一是match和check的职责划分match会吃掉一个 Tokencheck只是看一眼当前 Token。写 Parser 时最容易翻车的就是在if判断里用了match结果 Token 被提前消费后续的解析全部错位而且这种错误的表现是「后面的语句全部解析失败」排查起来很费劲。第二是我这个 Parser 里对int的处理其实偷懒了它只在匹配到int关键字时才处理声明而且没有校验变量是否重复声明——这一层校验应该留给语义分析阶段的符号表来做Parser 只管语法结构。第三是错误恢复机制。这个极简版本一遇到语法错误就抛异常中断整个编译真实的编译器应该在 catch 到异常后跳过当前语句继续解析好一次性报出多个错误。你可以在parseStatement外层包一个try { ... } catch (RuntimeException e) { synchronizeToSemicolon(); }把错误恢复到下一个分号这样用户不用反复编译十次才把语法错误改完。3. 语义分析与符号表类型检查不是可选项是编译器的体检3.1 符号表设计作用域、变量声明、方法签名怎么挂词法和语法分析都过了编译器还不能生成字节码——它必须先回答两个问题「这个变量在这个位置可见吗」「这个表达式算出来是什么类型」这两个问题的答案都记录在符号表里。符号表本质是一个按作用域分层的名字映射表。很多第一次写编译器的人会用一个全局 HashMap 搞定所有变量这在只有单一作用域的玩具语言里没问题但一旦出现方法嵌套和局部变量遮蔽全局变量全局表立刻失控。我一般这样设计一个Scope类表示一层作用域内部持有自己的 HashMap同时持有指向父作用域的引用查询变量时先查当前层查不到再逐级向上。public class Scope { private final Scope parent; private final MapString, VarInfo variables new HashMap(); public Scope(Scope parent) { this.parent parent; } public void declare(String name, String type, int index) { if (variables.containsKey(name)) { throw new RuntimeException(变量重复声明: name); } variables.put(name, new VarInfo(type, index)); } public VarInfo lookup(String name) { Scope s this; while (s ! null) { VarInfo info s.variables.get(name); if (info ! null) return info; s s.parent; } return null; } }VarInfo里我记了三样东西变量名、类型、局部变量索引。前两个是语义分析用的索引是给字节码生成阶段用的。这里有个很关键的参数局部变量索引index必须由声明顺序决定。Java 虚拟机规范里局部变量表按索引编号int、float、reference类型占 1 个槽位long和double占 2 个槽位。所以你在declare的时候就要决定索引怎么分配不能等生成字节码时再数——否则一旦遇到long类型后面所有变量的索引都会错位生成的 .class 文件跑起来全是玄学错误。作用域链的管理方式也很值得说。遇到{就应该new Scope(currentScope)作为新的当前作用域遇到}则回退到父作用域。这意味着你的 Parser 在解析复合语句时必须把作用域切换的逻辑嵌进去而不是把整个方法体当作一个扁平语句块。另一个常见做法是为方法和类各建一层作用域类作用域挂在方法作用域之上这样方法体里可以访问成员变量方法参数先于局部变量声明进方法作用域。符号表里的查找顺序最终会和 JVM 的解析顺序对齐这一点在后面生成字节码时能少踩很多坑。3.2 类型检查和常量折叠在生成字节码之前把错误拦下来符号表建好之后就需要遍历 AST 做类型检查。常见做法是写一个TypeChecker对每种 AST 节点返回它的类型同时在上文语境里做合法性判断。比如BinaryExpr的左右操作数都必须是数值类型AssignStmt的右侧表达式类型必须能赋给左侧变量类型ReturnStmt的返回值类型必须与方法声明的返回类型一致。这个过程很像给 AST 做一次「体检」把所有能在编译期发现的问题都在这里报出来不要拖到运行期。public class TypeChecker { private final Scope globalScope; public TypeChecker(Scope scope) { this.globalScope scope; } public String check(Stmt stmt, Scope scope, String expectReturnType) { if (stmt instanceof AssignStmt) { AssignStmt a (AssignStmt) stmt; VarInfo vi scope.lookup(a.name); if (vi null) throw new RuntimeException(未定义的变量: a.name); String exprType checkExpr(a.value, scope); if (!vi.type.equals(exprType)) { throw new RuntimeException(类型不匹配: a.name 是 vi.type 不能赋值为 exprType); } } return null; } public String checkExpr(Expr e, Scope scope) { if (e instanceof NumberExpr) return int; if (e instanceof VarExpr) { VarInfo vi scope.lookup(((VarExpr) e).name); if (vi null) throw new RuntimeException(未定义的变量: ((VarExpr) e).name); return vi.type; } if (e instanceof BinaryExpr) { BinaryExpr b (BinaryExpr) e; String l checkExpr(b.left, scope); String r checkExpr(b.right, scope); if (!l.equals(int) || !r.equals(int)) throw new RuntimeException(算术运算只支持 int); return int; } return void; } }这个类型检查器在编译流程里的位置是在 AST 构建完成之后、字节码生成之前。很多人的误区是跳过类型检查直接生成字节码结果.class文件编译期不报错加载时 JVM 跑出ClassCastException或VerifyError调试成本高出一个量级。编译器的设计哲学就是把能提前发现的问题尽量提前——这就是为什么 javac 会在一轮编译里报出所有类型错误而不是遇到第一个就停。常量折叠是类型检查做完之后顺手就能做的小优化也正是热词「编译器优化」里门槛最低的一个方向。比如int x 2 3;AST 里是一个BinaryExpr(, 2, 3)如果你在语义分析阶段发现左右都是字面量可以直接把整个节点替换成NumberExpr(5)。我一般会在checkExpr里顺手做这件事左操作数是NumberExpr且右操作数也是NumberExpr时直接算出结果用一个新节点替换旧节点。这一步能避免后面字节码生成阶段为无意义的常量运算发射iconst和iadd指令生成出来的 .class 也更干净。要注意的是常量折叠不能改变语义所以遇到除法要小心除零——1 / 0如果被折叠成编译期异常会改变运行期行为真实编译器会在这里做边界判断。4. 生成 JVM 字节码从 AST 到 .class 文件核心是操作数栈4.1 字节码生成策略栈式机器的代码生成套路Java 编译器最终输出物不是机器码而是 JVM 字节码。JVM 是栈式架构所有运算都通过操作数栈完成这和 x86 的寄存器架构有本质区别也是从 AST 生成代码时最需要适应的思维转换。生成一条加法表达式a b的字节码你要做的是把变量 a 的值压栈iload把变量 b 的值压栈iload然后执行 iadd——iadd 从栈顶弹出两个 int相加后再把结果压回栈。这种「先算子表达式再把结果交给父表达式」的思路天然契合 AST 的后序遍历。public class CodeGenerator { private final ListString instructions new ArrayList(); private int localIndex 1; // 0 号槽位留给 this public void generate(Stmt stmt) { if (stmt instanceof AssignStmt) { AssignStmt a (AssignStmt) stmt; generateExpr(a.value); int idx findLocalIndex(a.name); instructions.add(istore idx); } else if (stmt instanceof ReturnStmt) { ReturnStmt r (ReturnStmt) stmt; generateExpr(r.value); instructions.add(ireturn); } } public void generateExpr(Expr e) { if (e instanceof NumberExpr) { int v ((NumberExpr) e).value; if (v -1 v 5) instructions.add(iconst_ v); else if (v Byte.MIN_VALUE v Byte.MAX_VALUE) instructions.add(bipush v); else instructions.add(ldc v); } else if (e instanceof VarExpr) { int idx findLocalIndex(((VarExpr) e).name); instructions.add(iload idx); } else if (e instanceof BinaryExpr) { BinaryExpr b (BinaryExpr) e; generateExpr(b.left); generateExpr(b.right); switch (b.op) { case : instructions.add(iadd); break; case -: instructions.add(isub); break; case *: instructions.add(imul); break; case /: instructions.add(idiv); break; } } } }这段生成代码的核心逻辑是「表达式嵌套即指令嵌套」。generateExpr(b.left)会把左子树的取值指令先发射出去然后右子树最后才是运算指令。因为 JVM 操作数栈是 LIFO 的左子树的结果会先入栈、留在栈底右子树的结果再入栈iadd弹出时正好先弹右后弹左顺序完全正确。这也是栈式代码生成最优雅的地方只要严格后序遍历 AST你不需要操心临时变量怎么分配栈天然承担了寄存器的作用。iconst_0到iconst_5这些指令是 JVM 对 -1 到 5 的常量优化这个范围之外的小整数用bipush更大则用ldc从常量池加载。这三档选择不是性能洁癖而是实打实的字节码长度差异iconst_0只有 1 字节bipush是 2 字节ldc是 2 字节但需要额外占用常量池项。在实现编译器时你完全可以直接全用ldc但学会分档能让生成的 .class 更小也让你体会到 javac 内部的常量加载优化思路。4.2 从方法体到完整 .class常量池、方法描述符与入口表达式级别的字节码生成只是局部要生成能被java命令直接执行的 .class 文件还差三样硬东西magic number、常量池、方法描述符。很多人第一次实现 Java 编译器时会在这一步卡两天——不是不理解指令而是 .class 文件格式太「二进制」手写 ByteBuffer 时索引动不动就错位。我的建议是分三层推进第一层先实现最简单的类文件头写入验证「一个类没有方法也能生成 .class」第二层在常量池里登记main方法名和描述符第三层再写方法体的指令序列。public byte[] toBytecode(String className, ListString bodyInstructions) { ByteBuffer buf ByteBuffer.allocate(1024); buf.putInt(0xCAFEBABE); // magic buf.putShort((short) 0); // minor_version buf.putShort((short) 52); // major_version - Java 8 // constant_pool_count 和常量池条目省略按顺序写入 buf.putShort((short) 0x0001); // access_flags: ACC_PUBLIC // this_class、super_class 索引 // 方法表一个 public static void main(String[])V return Arrays.copyOf(buf.array(), buf.position()); }这个示例只展示了类文件头的骨架真正的实现里最繁琐的是常量池。常量池里每一条 CONSTANT_Utf8、CONSTANT_Class、CONSTANT_NameAndType、CONSTANT_Methodref 都有固定的 tag 和结构每个索引都必须和你指令里引用的名称精确对应。我一般会先写一个ConstantPool类内部用一个 Map 做「字符串去重 索引分配」凡是遇到类名、方法名、字段名、描述符字符串都先intern进常量池拿到索引再在生成指令时用这个索引。顺序必须是「先登记后引用」否则就会出现用了还没写入的索引最终 .class 解析失败。方法描述符是另一个高频出错点。main方法的描述符是([Ljava/lang/String;)V表示参数是 String 数组、返回值是 void。中间那个[L表示引用类型数组;表示类名结束。如果你手写的描述符里L后面没跟完整类名JVM 类加载器会直接抛ClassFormatError。我见过不少人在这里翻车明明 Java 源码里main(String[] args)是对的手写字节码时写成了([String;)V结果怎么跑都是「错误: 未包含main类型」。所以生成方法描述符时最好也写一个小工具函数从参数类型列表自动拼字符串不要手工敲。方法体生成完毕之后还必须在Code属性里声明max_stack和max_locals。max_stack指的是方法执行过程中操作数栈的最大深度max_locals是局部变量表的最大槽位数。这两个值不是随便填的JVM 在类加载验证阶段会检查指令实际访问的栈深度和局部变量索引是否超出声明范围超出就抛VerifyError。最简单可靠的算法规是后序遍历 AST记录每个表达式节点的「栈深度增量」兄弟节点取最大整个方法体里出现的最大瞬时深度就是max_stack。这个计算要和代码生成同步做不要事后猜——血泪经验告诉我事后估算的max_stack十个有八个是错的。5. 实现过程中的常见坑与排查方法这些坑我全踩过5.1 运行时报「错误: 未包含main类型」class 怎么都不认现象用你生成的编译器编出一个 .class 文件java Demo执行时报错误: 未包含main类型。原因这个错误名字很有迷惑性它不是说你没写 main而是一系列问题都归到这一句——main 方法签名不对、类名和文件名不一致、方法描述符里的String[]写错、甚至 access_flags 里少了ACC_PUBLIC和ACC_STATIC。解决分三步排查。先用javap -p Demo看 JVM 实际看到的方法签名确认输出里有public static void main(java.lang.String[])再用javap -v Demo检查常量池里的描述符字符串最后查类文件头里的 access_flags 是否为0x0001 | 0x0009public static。这三步能定位九成问题。这是我调试编译器时用得最多的命令没有之一。5.2 VerifyError: 操作数栈深度或局部变量索引对不上现象方法逻辑看起来没问题但类加载阶段直接VerifyError报StackMapFrame或Bad local variable type之类。原因分支语句if、while会在运行时合并控制流而 JVM 要求所有汇合点上的操作数栈状态必须一致。初学者写 if 语句的字节码时常常在 true 分支里 push 了一个值false 分支忘 push 了或者 if 条件表达式计算完后没有把栈清干净就 jump。解决写 if 语句的代码生成时严格按「先压条件 → ifeq 跳转 → 条件成立分支 → goto 绕过 → 条件不成立分支」的顺序并在每个分支末尾检查当前栈深度是否和入口处一致。我一般会在 CodeGenerator 里维护一个currentStackDepth变量每条指令发射时手动更新分支跳转前断言深度相同。这一步做好VerifyError 基本绝迹。5.3 long 和 double 占两个槽位局部变量索引全部错位现象程序里只有 int 变量时一切正常加入一个 long 局部变量后运行结果完全错乱甚至编译器生成的字节码里变量张冠李戴。原因JVM 局部变量表里 long 和 double 占 2 个槽位而istore这类指令的索引是按槽位编的不是按「第几个变量」编的。如果声明long x; int y;x 占 index 1 和 2y 必须在 index 3而不是 index 2。解决在符号表的declare方法里按类型分配索引——遇到long/double就index 2否则index 1把分配的索引存进VarInfo。同时也要注意lload和lstore必须用 long 版本的指令不能拿iload去加载 long 变量。这一条是最容易让新手怀疑人生的坑因为 int 版本全对一遇 long 就像玄学抽风。5.4 手写字节码时常量池索引错位ClassFormatError 频发现象生成的 .class 文件有时能跑、有时报ClassFormatError: Constant pool tag而且错误位置每次都不一样像玄学。原因常量池条目的引用是索引式的你插入一条新常量后所有既有索引都会变但如果只改了一处引用而没改另一处就会出现「引用指向了一个不是期望类型的常量池条目」。解决不要手工维护索引写一个ConstantPool类所有常量先intern拿索引之后生成的指令一律用拿到的返回值。另外每次生成完 .class 后第一件事就是用javap -v全量检查常量池 tag 序列是否合法。记住手写字节码不是写作文是写链表插入顺序就是一切。5.5 编译器自身堆空间不足解析大文件直接 OOM现象手册里的javac不会出现的问题你自己的编译器在解析稍大一点的 .java 文件时抛OutOfMemoryError。原因AST 节点对象太肥、常量和字符串全攒在内存里加上递归下降解析在表达式很深时调用栈暴涨。解决第一层先给 JVM 加启动参数-Xmx2g缓解第二层优化 AST 节点——数字字面量直接用int字段而不是再包一层对象第三层如果还要更大规模就需要考虑流式解析边解析边生成而不是全量 AST 后再遍历。对一个教学级编译器来说能把前两层做好已经达标了。这一条也回应了很多人问的「编译器开发的堆空间不足怎么办」——先看是不是自己代码持有引用没释放再看是不是节点设计太浪费。6. 进阶验证技巧用 javap 和 JUnit 给你的编译器做体检写编译器的人最容易陷入的误区是自己写的代码自己不敢看输出。实际上验证编译器正确性有一套很成熟的组合拳对所有语法特性写最小用例跑完编译后用javap -c反编译查看字节码再用java实际执行并断言输出。这几步组合起来就是一套不需要 debugger 的体检流程。我建议你建一个cases/目录里面每个子目录放一个.java文件和一个expected.txt然后用 JUnit 写一个参数化测试遍历目录编译、执行、比对输出。这样每次你改完词法分析器或者代码生成器跑一遍测试就知道有没有把旧功能改坏——这种回归保护在编译器这种链条极长的项目里是救命稻草。字节码验证的另一个好习惯是「和 javac 对拍」。你自己编译器生成的 class 和 javac 生成的 class用javap -c反编译出来对比虽然指令序列不会完全一样但你能看到「数量级」的差异如果 javac 对a b * c生成了 5 条指令你的编译器生成了 20 条那八成是某层把表达式结构搞坏了。反过来如果数量差不多即使个别指令顺序不同基本可以判定逻辑没大问题。这种对拍式的验证比单纯跑通 hello world 更能暴露边界问题比如连续赋值、嵌套括号、多级运算符优先级。最后想说的还是那句老话编译器是少数几个「做出来不算完跑出正确结果才算完」的工程。我自己最开始实现 Java 编译器的头两周几乎所有时间都花在追 VerifyError 和 ClassFormatError 上后来养成了「每生成一个 .class 文件就立刻 javap 检查一把」的习惯夭折的时间直接少了一大半。所以如果你正准备打开这个 zip我的建议是把javap -v当成你最重要的调试伙伴把「生成即验证」当成铁律——这比任何技巧都更能让你少走弯路。希望帮到你。本文还有配套的精品资源点击获取