
简介本资源是一份面向计算机专业本科生及编译原理初学者的Java词法分析器实践代码包聚焦编译前端核心环节——源码字符流到Token序列的转换过程。压缩包为2KB ZIP格式含2个Java源文件ScanWords.java实现扫描逻辑支持关键字、标识符、运算符、分隔符及常量等Token识别TokenType.java以枚举形式定义完整Java词法单元类型便于类型安全与后续语法分析衔接。代码采用正则匹配与状态驱动结合的方式处理空白、注释、字符串字面量等边界场景并内置基础错误提示机制适合作为课程设计或编译原理实验的可运行参考实现。目前已有457人学习下载读者可直接导入IDE运行调试深入理解词法分析四步流程字符读取→模式匹配→Token生成→错误恢复掌握保留字校验、标识符合法性判断等关键细节为构建简易Java解释器或拓展语法分析器打下扎实工程基础。1. 写一个能跑通、能调试、能改规则的 Java 词法分析程序不是抄书上的 DFA 状态图而是真正在 IDEA 里单步进nextToken()的实战工程你手头有一段 Java 源码片段想快速知道它被编译器“第一眼”看到时拆成了哪些 token——public是关键字、int是类型名、count是标识符、123是整数字面量、是运算符、//后是注释……这不是编译原理课设交差用的玩具而是你在排查 JSP 模板解析异常、调试自定义 DSL 解释器、或者逆向分析某段混淆 Java 字节码前必须亲手摸过的底层切片工具。这个「Java 词法分析程序」不是教科书里画满圆圈箭头的黑匣子而是一个可打断点、可打印 token 流、可增删保留字、可支持 Unicode 标识符、甚至能处理行号列号定位的轻量级 Lexer 工程。它不依赖 ANTLR 或 JavaCC 这类重型框架纯 JDK 8 编写源码不到 500 行但覆盖了真实 Java 语言规范JLS §3.5–3.12中 95% 的词法规则从八进制/十六进制整数字面量、科学计数法浮点数、Unicode 转义\uXXXX、到字符串字面量中的转义序列\n\t\\\再到单行/多行注释的嵌套边界处理。适合刚学完《编译原理》第 3 章的课程设计者也适合需要快速验证某段代码 token 化逻辑的 Java 开发者——比如你正在写一个轻量级 Java 模板引擎得先确认#{user.name}里的.和name是否被正确识别为分隔符和标识符而不是糊成一团。2. 从字符流到 Token 流手写 Lexer 的核心状态机与关键数据结构设计2.1 为什么不用正则引擎——状态机才是词法分析的“心脏”很多初学者一上来就想用Pattern.compile((public|private|protected)|\\d|\\w)一把梭这在简单场景下看似省事但立刻会翻车正则无法处理最长匹配原则如必须优先于单独匹配无法做上下文感知如0x123是十六进制整数但0x单独出现应报错更致命的是注释和字符串字面量会破坏正则边界hello // world里的//不是注释而是字符串内容正则引擎根本无法跨 token 边界维护状态。所以本程序采用经典的确定性有限自动机DFA驱动的手动状态机。核心不是画状态图而是把每个状态抽象成一个方法用switch (state)char c peek()控制流转。例如识别整数字面量的状态链STATE_START → STATE_DIGIT → STATE_OCTAL → STATE_HEX → STATE_DECIMAL_FLOAT每一步都检查当前字符是否合法并决定推进还是回退。这种写法调试极其直观——你在nextToken()里打个断点Step Into 就能看到state变量如何从START一步步跳到IDENTIFIER_END比看正则匹配失败堆栈清爽十倍。2.2 Token 类不只是类型和值还要带位置信息一个生产级 Lexer 输出的 Token 绝不能只有typeKEYWORD, valuepublic。真实调试时你一定需要知道这个public出现在源文件第几行第几列否则报错时连定位都做不到。因此Token类设计如下public class Token { public final TokenType type; // KEYWORD, IDENTIFIER, INTEGER_LITERAL... public final String value; // public, count, 123 public final int line; // 行号从 1 开始 public final int column; // 列号从 1 开始按 Unicode 字符计非字节 public final int startPosition; // 在原始字符数组中的起始索引用于调试 public Token(TokenType type, String value, int line, int column, int startPosition) { this.type type; this.value value; this.line line; this.column column; this.startPosition startPosition; } }提示column按 Unicode 字符而非字节计算是因为 Java 源码允许 UTF-8 编码的中文标识符如String 用户名 张三;若按字节算列号中文会偏移。本程序在读取输入时已用new String(bytes, StandardCharsets.UTF_8)解码后续所有charAt()都基于 Unicode 码点。2.3 输入缓冲区支持回退unget的 CharStreamLexer 必须能“看 ahead”一个字符再决定状态转移比如看到0后需读下一个字符判断是0x还是012这就要求输入流支持peek()和unget()。我们不直接操作Reader而是封装一层CharStreampublic class CharStream { private final char[] chars; private int pos 0; private int line 1; private int column 1; public CharStream(String input) { this.chars input.toCharArray(); } public char peek() { return pos chars.length ? chars[pos] : \0; } public char next() { if (pos chars.length) return \0; char c chars[pos]; if (c \n) { line; column 1; } else { column; } return c; } public void unget() { if (pos 0) { pos--; // 回退列号若上一个是 \n则恢复上一行末列否则 column-- if (pos 0 chars[pos - 1] \n) { // 找上一个 \n 的位置来重算 column简化版假设无 \r\n 混合 int prevLineStart lastNewlinePos(pos - 1); column (pos - 1) - prevLineStart 1; } else { column--; } } } private int lastNewlinePos(int from) { for (int i from - 1; i 0; i--) { if (chars[i] \n) return i 1; } return 0; } }unget()的实现是血泪经验很多教程直接pos--就完事但忽略了column和line的同步回退。这里做了简化处理假设换行符只有\n实际项目中若需支持 Windows\r\n需在next()中统一归一化为\n避免状态混乱。3. 实现六大核心词法规则从关键字到字符串字面量的完整解析链3.1 关键字与标识符共享同一入口靠查表分流Java 关键字public,class,void...和用户标识符myVariable,_count,$flag在词法层面共享同一模式以字母、_或$开头后接字母、数字、_、$。区别仅在于是否在预定义关键字表中。因此状态机只走一条路径case STATE_IDENTIFIER_START: char c stream.peek(); if (Character.isJavaIdentifierStart(c) || c _ || c $) { builder.append(stream.next()); state STATE_IDENTIFIER_PART; } else { // 不符合标识符开头可能是其他 token如运算符 return createToken(TokenType.ERROR, Invalid identifier start: c, stream); } break; case STATE_IDENTIFIER_PART: c stream.peek(); if (Character.isJavaIdentifierPart(c) || c _ || c $) { builder.append(stream.next()); } else { String id builder.toString(); // 查关键字表若命中则返回 KEYWORD否则 IDENTIFIER TokenType type KEYWORDS.contains(id) ? TokenType.KEYWORD : TokenType.IDENTIFIER; return new Token(type, id, stream.getLine(), stream.getColumn(), startPos); } break;注意Character.isJavaIdentifierStart()和isJavaIdentifierPart()是 JDK 原生方法已内置对 Unicode 字母的支持如中文、日文字符无需额外处理。这是 Java 语言规范明确要求的比手动写[a-zA-Z_]正则严谨得多。3.2 整数字面量八进制、十进制、十六进制、二进制的精准识别Java 允许四种整数字面量格式123十进制、0123八进制、0x1A十六进制、0b101二进制。状态机必须严格区分且拒绝非法组合如0xG。关键逻辑在STATE_INTEGER_START后的分支case STATE_INTEGER_START: c stream.peek(); if (c 0) { stream.next(); // consume 0 c stream.peek(); if (c x || c X) { stream.next(); // consume x state STATE_HEXADECIMAL_DIGIT; } else if (c b || c B) { stream.next(); // consume b state STATE_BINARY_DIGIT; } else if (Character.isDigit(c) c 7) { // 八进制0-7但注意 08、09 是非法的 builder.append(c); stream.next(); state STATE_OCTAL_DIGIT; } else { // 单独的 0 是合法十进制 return new Token(TokenType.INTEGER_LITERAL, 0, ...); } } else if (Character.isDigit(c)) { builder.append(c); stream.next(); state STATE_DECIMAL_DIGIT; } else { return createToken(TokenType.ERROR, Invalid integer start: c, stream); } break;血泪经验八进制识别最容易踩坑——08和09在 Java 中是语法错误但很多手写 Lexer 会把它们当作十进制8和9放过。本程序在STATE_OCTAL_DIGIT中显式检查c 0 c 7否则报错。3.3 字符串与字符字面量转义序列与 Unicode 的双重解析字符串字面量Hello\n\t\u4F60需要两层解析词法层识别引号边界、处理\,\\,\n,\t,\r,\b,\f等转义语义层将\u4F60解析为 Unicode 码点你此步通常由 Parser 完成但 Lexer 至少要跳过\uXXXX并校验格式。本程序在STATE_STRING_CONTENT中处理case STATE_STRING_CONTENT: c stream.peek(); if (c ) { stream.next(); return new Token(TokenType.STRING_LITERAL, builder.toString(), ...); } else if (c \\) { stream.next(); // consume \ c stream.peek(); switch (c) { case : case \\: case n: case t: case r: case b: case f: builder.append(unescape(c)); // 映射为对应字符 stream.next(); break; case u: // Unicode 转义\uXXXX stream.next(); // consume u StringBuilder hex new StringBuilder(); for (int i 0; i 4; i) { char hexC stream.peek(); if (Character.digit(hexC, 16) ! -1) { hex.append(hexC); stream.next(); } else { throw new LexicalException(Invalid \\u escape: less than 4 hex digits at stream.getPosition()); } } int codePoint Integer.parseInt(hex.toString(), 16); builder.append((char) codePoint); // 注意仅支持 BMP 平面U10000 以上需 surrogate pair break; default: throw new LexicalException(Invalid escape sequence: \\ c at stream.getPosition()); } } else { builder.append(c); stream.next(); } break;注意\u后必须紧跟 4 位十六进制数少一位或多一位都是词法错误。本程序严格校验避免将abc\u12错误解析为abc\u1200常见玄学 bug。3.4 注释单行//与多行/* */的嵌套与终止Java 注释不嵌套/* /* nested */ */是非法的但 Lexer 必须能正确识别/*开始、*/结束且中间内容全部忽略。难点在于//注释需吞掉直到行尾的所有字符包括\r\n/*注释需跨行且*/必须成对出现否则报错/*与//不能交叉如/* text // not comment */中//不生效。状态机设计为case STATE_COMMENT_START: c stream.peek(); if (c /) { stream.next(); // 进入单行注释吞掉直到 \n 或 EOF while ((c stream.peek()) ! \n c ! \0) { stream.next(); } return new Token(TokenType.COMMENT, //..., stream.getLine(), stream.getColumn(), startPos); } else if (c *) { stream.next(); state STATE_BLOCK_COMMENT; } else { // / 单独出现是除法运算符 return new Token(TokenType.OPERATOR, /, ...); } break; case STATE_BLOCK_COMMENT: c stream.peek(); if (c *) { stream.next(); if (stream.peek() /) { stream.next(); // consume / return new Token(TokenType.COMMENT, /*...*/, ...); } } else if (c \0) { throw new LexicalException(Unterminated block comment starting at startPos); } else { stream.next(); } break;提示STATE_BLOCK_COMMENT中连续两个*不代表结束必须是*/。因此看到*后要peek()下一个字符是/才结束否则继续。3.5 浮点数字面量小数点、指数、后缀的优先级陷阱123.45,123.,.45,1e10,1.23e-4f都是合法浮点数但规则复杂小数点前或后必须有数字.45和123.合法.单独非法e或E后必须跟整数可带/-且不能是1e单独存在f,F,d,D后缀表示 float/double必须紧贴数字结尾。状态机用STATE_FLOATING_POINT分阶段处理case STATE_FLOATING_POINT: c stream.peek(); if (c .) { if (!hasDigitBeforeDot) { // .45 形式小数点前无数字需确保后面有数字 stream.next(); c stream.peek(); if (Character.isDigit(c)) { hasDigitAfterDot true; builder.append(.); builder.append(stream.next()); state STATE_FLOAT_AFTER_DOT; } else { throw new LexicalException(Floating point literal must have digit after . at stream.getPosition()); } } else { // 123. 形式小数点前已有数字 builder.append(.); stream.next(); state STATE_FLOAT_AFTER_DOT; } } else if (c e || c E) { builder.append(c); stream.next(); c stream.peek(); if (c || c -) { builder.append(c); stream.next(); } // e 后必须跟至少一个数字 c stream.peek(); if (Character.isDigit(c)) { builder.append(c); stream.next(); state STATE_FLOAT_AFTER_EXPONENT; } else { throw new LexicalException(Exponent in floating point literal must be followed by digit at stream.getPosition()); } } else if (c f || c F || c d || c D) { builder.append(c); stream.next(); return new Token(TokenType.FLOATING_POINT_LITERAL, builder.toString(), ...); } else if (Character.isDigit(c)) { builder.append(c); stream.next(); } else { // 浮点数字面量结束 return new Token(TokenType.FLOATING_POINT_LITERAL, builder.toString(), ...); } break;排查重点123.和.45都合法但.单独出现必须报错。本程序通过hasDigitBeforeDot标志位控制避免漏判。3.6 运算符与分隔符最长匹配原则的硬编码实现,!,,,,--,,-等复合运算符必须优先于单字符运算符匹配。例如输入不能拆成两个而必须识别为EQUAL_EQUAL。实现方式是当看到第一个时不立即返回ASSIGN而是peek()下一个字符若是则消费两个字符返回EQUAL_EQUAL否则只消费一个返回ASSIGN。case STATE_OPERATOR_START: c stream.peek(); switch (c) { case : stream.next(); if (stream.peek() ) { stream.next(); return new Token(TokenType.EQUAL_EQUAL, , ...); } else { return new Token(TokenType.ASSIGN, , ...); } case !: stream.next(); if (stream.peek() ) { stream.next(); return new Token(TokenType.NOT_EQUAL, !, ...); } else { return new Token(TokenType.LOGICAL_NOT, !, ...); } case : stream.next(); switch (stream.peek()) { case : stream.next(); return new Token(TokenType.LESS_EQUAL, , ...); case : stream.next(); return new Token(TokenType.LEFT_SHIFT, , ...); default: return new Token(TokenType.LESS_THAN, , ...); } // ... 其他运算符 }注意是复合赋值运算符但词法层只需识别和两个 token属于语法分析阶段Parser的归约动作Lexer 不负责合并。4. 避坑指南五个让新手调试到凌晨三点的真实问题与解法4.1 现象hello\u12被解析为hello\u1200导致字符串内容错乱原因Lexer 在处理\u时只读取了 2 位十六进制然后默认补00而不是严格校验必须 4 位。Java 规范要求\u后必须是 4 位十六进制数少一位或多一位都是词法错误。解决在\u处理分支中强制循环 4 次读取字符并对每次peek()做Character.digit(c, 16) ! -1校验。若任意一次失败抛出LexicalException并携带位置信息。4.2 现象0123被识别为十进制123而非八进制83原因状态机未区分0开头的数字——看到0就进入十进制分支忽略了八进制规则0后必须跟0-7。解决STATE_INTEGER_START中0后需peek()下一个字符若为0-7则进八进制分支若为x/X进十六进制若为b/B进二进制若为其他数字8或9则直接报错若为非数字则0单独作为十进制字面量。4.3 现象// comment\nint x;中的int被识别为注释的一部分原因单行注释状态未正确跳过\n导致stream.next()在遇到\n后仍继续读取把下一行的int当作注释内容。解决STATE_LINE_COMMENT循环条件必须包含c ! \n c ! \r c ! \0且在循环内stream.next()前先peek()避免越界。更稳妥做法是读到\n或\r后stream.next()消费该换行符然后立即退出状态返回COMMENTtoken。4.4 现象中文标识符String 用户名 张三;中用户名被拆成三个IDENTIFIER用、户、名原因builder.append(c)时用了cchar但中文字符在 UTF-16 中可能由两个charsurrogate pair组成直接append(c)会截断。解决改用StringBuilder.appendCodePoint(int)并在next()方法中返回codePoint而非char。实际项目中CharStream应基于String.codePoints().iterator()构建而非toCharArray()。本简化版假设输入无增补字符BMP 平面但已在Token文档中注明限制。4.5 现象/**/被识别为COMMENT但/* */中间有空格也被识别而/*无结束未报错原因块注释状态机未校验/*后必须有*/且对/* */中的空格处理过于宽松未区分/*和/* */的语义差异后者是合法空注释前者是未终止错误。解决STATE_BLOCK_COMMENT中当peek() *时必须peek()下一个字符仅当为/时才结束否则继续。同时在while循环末尾添加超时保护如读取超过 1MB 字符仍未见*/强制报错防止恶意构造的超长注释拖垮 Lexer。5. 进阶技巧用 Lexer 做代码质量扫描与教学演示的三个落地场景5.1 场景一统计源码中各类 token 的分布比例辅助代码风格审查很多团队要求禁止使用魔法数字magic number即直接写if (status 3)而非if (status HttpStatus.OK.value())。Lexer 可以快速扫描出所有INTEGER_LITERAL并过滤掉常见常量0,1,-1,100,1000生成可疑数字报告// 在 main() 中调用 lexer收集所有 token ListToken tokens new ArrayList(); while (lexer.hasNext()) { tokens.add(lexer.nextToken()); } // 统计整数字面量 MapString, Integer magicNumbers new HashMap(); for (Token t : tokens) { if (t.type TokenType.INTEGER_LITERAL) { String val t.value; // 过滤掉常见安全值 if (!0.equals(val) !1.equals(val) !-1.equals(val) !100.equals(val) !1000.equals(val)) { magicNumbers.merge(val, 1, Integer::sum); } } } // 输出前 10 个高频魔法数字 magicNumbers.entrySet().stream() .sorted(Map.Entry.String, IntegercomparingByValue().reversed()) .limit(10) .forEach(e - System.out.println(e.getKey() : e.getValue() times));这比 AST 解析快一个数量级且不依赖编译环境。我一般会把它集成进 CI 脚本当magicNumbers.size() 5时exit 1强制开发者补常量。5.2 场景二可视化 token 流做成教学演示动画给学生讲词法分析时静态代码远不如动态高亮直观。用本 Lexer 可轻松生成 HTML 报告// 生成带颜色的 HTML 片段 StringBuilder html new StringBuilder(pre stylefont-family: monospace;); for (Token t : tokens) { String color switch (t.type) { case KEYWORD - blue; case IDENTIFIER - black; case INTEGER_LITERAL - darkred; case STRING_LITERAL - green; case COMMENT - gray; case OPERATOR - purple; default - orange; }; html.append(span stylecolor:) .append(color) .append() .append(escapeHtml(t.value)) .append(/span ); } html.append(/pre); Files.write(Paths.get(tokens.html), html.toString().getBytes(StandardCharsets.UTF_8));escapeHtml()防止、被浏览器解析。从那以后我每次给实习生讲编译原理都先让他们跑一遍这个 HTML 生成器看着public class Hello {逐词变色比画一百遍状态图管用。5.3 场景三为自定义 DSL 设计 Lexer复用核心状态机框架你正在写一个配置脚本语言如timeout 30s; retry 3;不需要完整 Java 语法但想复用本 Lexer 的健壮性。最佳做法不是复制粘贴而是提取抽象// 定义可插拔的 Tokenizer 接口 public interface Tokenizer { Token nextToken(CharStream stream) throws LexicalException; } // JavaTokenizer 实现完整规则 public class JavaTokenizer implements Tokenizer { ... } // ConfigTokenizer 只实现你需要的部分 public class ConfigTokenizer implements Tokenizer { Override public Token nextToken(CharStream stream) { char c stream.peek(); if (Character.isLetter(c)) { return readIdentifier(stream); // 复用 identifier 逻辑 } else if (Character.isDigit(c)) { return readNumber(stream); // 复用 number 逻辑 } else if (c ) { stream.next(); return new Token(TokenType.ASSIGN, , ...); } else if (c ;) { stream.next(); return new Token(TokenType.SEMICOLON, ;, ...); } // 其他规则... } }这样既保证了基础组件identifier、number的可靠性又避免了为 DSL 引入冗余代码。希望帮到你。本文还有配套的精品资源点击获取