先请教一个问题你从网页、PDF或者聊天记录里复制一段 Java 代码粘进 IDE 之后编译器报illegal character: \u200b一个肉眼根本看不见的字符。我第一次遇到这个报错时对着屏幕愣了十几秒最后才发现是源码里混进了零宽空格。这类问题的根源往往不在语法本身而在 Java 语言规范JLS 17的第 3 章词法结构Lexical Structure。这一章讲的是编译器读源码时最底层的“切词”规则字符怎么进来、怎么被识别、怎么变成一个个 Token。不管你是刚入门 Java还是已经在写代码生成器、静态分析工具、IDE 插件搞懂这一章能少踩很多莫名其妙的坑。1. JLS 17第3章到底在管什么词法分析的地基1.1 编译器读源码的第一道工序编译器拿到一份.java文件时并不是直接“看懂”代码而是先把一堆物理字符拆成有意义的单元再做语法分析。这个过程就是词法分析Lexical Analysis对应的规范就是 JLS 第 3 章。打个比方中文句子要理解得先分词。比如“南京市长江大桥”从字符流“南、京、市、长、江、大、桥”到词语“南京/市长/江大桥”还是“南京市/长江大桥”这一步没定清楚后面的语法分析无从谈起。Java 编译器也一样它要把连续的字符流切分成 Token 序列比如关键字int、标识符a、运算符、字面量1然后才进入句法分析。JLS 17 是 Java SE 17 对应的官方语言规范。它定义的是 Java 语言的语法和语义和 JVM 字节码规范是两码事。第 3 章属于整本规范里最“编译器视角”的部分它不关心你写了多少行代码只关心你源码文件里的字符能不能被干净利落地切成词。1.2 这一章解决了什么问题词法结构规范最重要的作用是保证任何 Java 编译器在处理同一段源码时得到一模一样的 Token 流。无论是标准 JDK 自带的编译器还是某个 IDE 内置的增量编译器或是某些高端运行时自带的 JIT 编译器前端都必须遵守同一套切词规则。没有这套规则可能出现同一个文件在不同的编译器下解析结果不同轻则报错信息不一致重则同一份代码在不同环境中行为不同。JLS 第 3 章把这些边界划得清清楚楚什么字符合法、什么字符非法、注释怎么认定、字符串从哪开始结束全部有标准答案。对工具开发者来说这一章就是写代码高亮、静态检查、自动补全、重构工具的“施工手册”。我早期给某个内部代码扫描工具写过检查规则一开始以为做检测就是看 AST 语法树后来发现很多问题根本不需要看语法树——在词法阶段就能抓住比如代码里混入了不可见字符、字符串字面量写得反人类、注释里塞了诡异的转义序列。1.3 适合谁看如果你属于以下几种情况这一章值得认真读在 IDE 里见过“非法字符”“未结束的字符串字面量”“需要括号”等报错但想不明白代码明明长得没问题想搞清楚为什么中文变量名能编译、某些看上去正常的字符却不行在做代码扫描、代码生成、编译器前端相关的工具准备 Java 面试尤其是问到关键字、文本块、Unicode 转义、标识符规则这些细节。这一章内容不厚但信息密度极高很多知识点是你写代码时天天碰到的只是平时没意识到它们背后有规范约束。2. 词法翻译三阶段Unicode转义、换行处理与Token生成的顺序JLS 17 在第 3.2 节明确规定了词法翻译的顺序一共三个阶段。这个顺序极其重要很多诡异的编译问题都出在对这个顺序的误解上。2.1 阶段一Unicode转义——比注释更早发生的“预处理”第一个阶段是把 Unicode 转义序列翻译成真实字符。\u0041会被替换成字符A\u000a会被替换成真实的换行符LF。这个处理发生在所有其他词法规则之前注意是所有其他规则包括注释识别。为什么这样设计因为 Java 平台在设计早期要考虑跨平台的源码传输问题。当年编辑器、文件系统对字符集支持参差不齐允许在源码任何地方用纯 ASCII 的\uXXXX表示任意 Unicode 字符就能保证代码在奇怪的字符编码环境下也能被正确读取。这个设计初衷在今天确实有点“历史包袱”了但它依然是规范的一部分而且会实实在在给你挖坑。JLS 17 中 Unicode 转义的形式是一个反斜杠\后面跟一个或多个u再跟 4 个十六进制数字。比如\uuuu0041也是合法的最终也会被翻译成A。如果\u后面的内容不足 4 个十六进制数字编译器直接报错。这里有个最经典的坑注释里的\u000a。因为 Unicode 转义先执行源码中的// \u000a会被翻译成//加一个真实换行注释在换行处就结束了后面的内容会被当作真实代码继续解析。我在给一个同事排查线上问题时遇到过相似情况某段注释里贴了一段外部文档里面恰好包含类似\u000a的文字结果注释提前断开后面的内容被当成 Java 代码编译器报出一堆莫名其妙的方法体错误当事人怎么都看不出来原因是注释里的“换行转义”。再补一个更隐蔽的String s \u000a;在 Java 里是编译不过的。因为\u000a先被翻译成真实换行而字符串字面量里不允许出现未转义的原始换行于是报“未结束的字符串字面量”。如果你真的想在字符串里表达\u000a这六个字符的文本形态需要写成\\u000a让两个反斜杠保护后面的u避免被当成 Unicode 转义处理。提示写博客、写注释、写文档时如果只是展示性的提到\u序列建议写成\\u或者用全角否则很容易触发那个早期的翻译阶段。2.2 阶段二行终止符——一行的边界到底是谁说了算第二阶段是把上一步得到的字符序列按行终止符切分成“输入行”。JLS 定义的行终止符只有三种LF\u000A、CR\u000D、CRLF\u000D\u000A。这个设计让 Java 源码在不同操作系统之间迁移时换行符差异不会影响语法。Windows 文件里常见的 CRLF、Linux/macOS 下常见的 LF在编译器眼里都是合法的行终止符都表示“这一行结束”。有一个理论上的边角情况CRLF 被算作一个行终止符而 LFCR 被算作两个行终止符中间会产生一个空行。虽然正常的编辑器不会生成 LFCR但在某些老旧文件的拼接、字符替换场景中确实可能出现。这个细节平时用不到知道了也不算亏。很多开发者意识不到的是行号计算也在这一阶段发生。编译器报错信息里的行号是按照这些“输入行”来数的。所以如果源码里混入了 Unicode 转义生成的换行你看到的报错行号可能和编辑器里显示的行号对不上这就是阶段一和阶段二顺序导致的后果。2.3 阶段三输入元素——空白、注释与Token的分界第三阶段才是真正“切词”的环节。编译器把每个输入行进一步解析成输入元素输入元素分三类空白、注释、Token。其中空白和注释在大多数情况下都会被丢弃不生成 TokenToken 才是语法分析真正要用的东西。空白包括空格、制表符、换页等。注释有三种//行注释到行尾结束/* ... */传统注释/** ... */文档注释。注意传统注释不嵌套/* /* */里的第一个*/就结束了整个注释后面的内容会重新进入代码解析。这一阶段的另一个核心规则叫“最大匹配”Maximal Munch编译器总是一口气吞下最长、合法的 Token。比如ab会被切分为a、、、b而不是a、、、b。把这个规则落到表达式里ab等价于(a) b而不是a (b)。很多人在面试题里见过这个例子背过结论但真正遇到时未必能反应过来这是词法阶段的规则在起作用。2.4 一个真实场景为什么注释里的转义序列会改变代码结构把三个阶段串起来看最典型的案例就是// \u000a 你想写注释但它提前结束了 System.out.println(这条语句会被执行吗);这段代码经过阶段一的处理\u000a变成真实换行原本的注释行被切开第二行的System.out.println(...)变成了普通代码于是它真的会被执行。如果你是在一个已有的方法里看到这种结构编译器很容易报出“缺少返回值”之类的错误因为方法的注释被你无意中断开了。反过来如果注释里写的是\u000d它也会被翻译成回车同样会断开注释。这类问题最恼人的地方在于肉眼看到的注释文本和编译器实际解析到的内容完全不同。排查时先在 IDE 里打开“显示空白字符/显示控制字符”开关或者用文本工具查看文件的十六进制内容往往一眼就能找到问题。3. 令牌Token全解标识符、关键字、字面量与运算符的具体规则Token 是词法结构的最终产物。JLS 17 把 Token 分成五类标识符、关键字、字面量、分隔符、运算符。下面把每一类的关键规则拆开讲。3.1 标识符为什么中文变量名能编译而_当变量名却不行标识符就是变量名、方法名、类名这些。它的规则是开头必须是 Java 字母、下划线_或美元符$后续字符可以是这些加数字。这里的“Java 字母”不是指 ASCII 里那 26 个字母而是 Unicode 中一大类被判定为字母的字符。所以中文变量名、日文假名、希腊字母在语法上完全合法String 中文变量 ok;编译器不会报错。很多团队出于代码规范禁止这么做但那属于工程约束不是 JLS 的约束。$是合法的标识符字符JDK 内部生成匿名内部类、Lambda 表达式对应的类名时经常使用$比如Outer$1。但手写代码刻意用$开头的变量名没有实际意义还容易被代码生成工具弄混淆不推荐。有一个值得记忆的变化_单独作为标识符在 Java 8 及以前是合法的从 Java 9 开始被列为关键字所以现在写int _ 5;直接编译报错。如果看到老代码里有_做变量名的升级到新版本时记得改掉。3.2 关键字与受限关键字var、yield、record的“半关键字”状态JLS 17 的关键字列表是固定的按分类大概这样分类关键字基础关键字abstract、assert、boolean、break、byte、case、catch、char、class、continue、default、do、double、else、enum、extends、final、finally、float、for、if、implements、import、instanceof、int、interface、long、native、new、package、private、protected、public、return、short、static、strictfp、super、switch、synchronized、this、throw、throws、transient、try、void、volatile、while保留字const、goto不能用作标识符受限/上下文关键字var、yield、record、sealed、permits基础关键字很好理解这些词不能被拿来当变量名、方法名、类名。int int 1;是标准错误。受限关键字有意思。它们只在特定语法位置才被当作关键字其他位置仍然可以当标识符。比如局部变量类型推断里的varvar x 10;里面var是关键字但int var 10;里var只是普通变量名完全合法。yield在 switch 表达式里作为跳转关键字使用但作为一个普通方法名在有些场景里还能编译通过。record在声明记录类时是关键字但作为字段名通常不冲突。这种“半关键字”设计是为了语言的演进兼容性。Java 每加一个新语法直接从老代码里抢标识符会引发大规模破坏于是采用“上下文相关”的方式。理解这一点你在看sealed、permits这些新东西时就不会觉得奇怪了。3.3 字面量的细节数值分隔符、各种前缀、文本块字面量就是代码里的常数值。JLS 17 对它们的定义非常细致这里挑几个容易出问题的点。整数方面支持十进制、十六进制0x/0X、八进制0开头、二进制0b/0B。Java 7 以后允许数字中间放下划线做分隔比如1_000_000读起来比1000000舒服得多。但下划线只能放在数字之间开头、结尾、小数点旁边、后缀字母旁边、进制前缀后面都不能放。0b_1010是非法的123_L也是非法的。长整型后缀L推荐用大写。long x 123l;里小写l和数字1长得太像代码评审时容易眼花。浮点方面除了常见的3.14d、1e-9fJLS 还支持十六进制浮点比如0x1.8p1这在某些对位精确表示有要求的场景下会用到平时很少写。字符和字符串方面转义序列是重点\b、\t、\n、\f、\r、\、\、\\。还有一个容易混淆的点字符串里的\uXXXX形式和源码里的 Unicode 转义长得像但行为不同。在字符串字面量内部写\u0041和A最终结果相同但由于 Unicode 转义先执行对换行类字符\u000a等就会出问题这一点在前面已经讲过了。文本块Text Block是 Java 15 正式引入的特性JLS 17 也覆盖了。它用三个双引号包裹内容主要解决多行文本的转义地狱问题。比如一段 SQL、一段 JSON写普通字符串时要处理\n和\写文本块直接原样排版就行。文本块还有一套自己的行终止符和缩进处理规则使用时不建议手动在里面塞奇怪的 Unicode 转义容易让可读性归零。布尔字面量true、false和null也可以算作特殊字面量但它们不是关键字。JLS 里它们被称为“布尔字面量”和“空字面量”。这个分类细节在解析器实现时要注意它们和关键字在 Token 类型上是有区别的。3.4 运算符与分隔符最大匹配规则的实际影响运算符方面JLS 17 定义的符号数量不少包括、、、!、~、?、:、-、::、、、、!、、||、、--、、-、*、/、、|、^、%、、、、、-、*、/、、|、^、%、、、等。这些多字符运算符在词法分析时就是靠最大匹配规则切分的。比如a b编译器会优先匹配最长的作为一个无符号右移运算符而不是拆成和。写代码时如果忘了在运算符两边加空格解析出的 Token 可能和你脑补的完全不一样。比如int a 1; int b 2; int c 1; int r ab; // 实际上是 (a) b这种写法正式代码里不该出现但代码压缩、字符串拼接生成源文件时可能会“制造”出来。理解了最大匹配看到这类代码就不会一头雾水。分隔符相对简单( ) { } [ ] ; , . ... ::这些。注意...是可变参数语法的一部分::是方法引用运算符都不属于分隔符范畴。是注解起始符也单独算一个 Token。4. 实操现场手写一个极简词法器验证JLS规则规范读再多不如动动手跑一遍。这里我做一个小实验手写一个简化版的词法分析器把 JLS 第 3 章里的部分规则落地。4.1 先给源码编码定调所有编译器参数统一UTF-8现实中的.java文件在磁盘上是字节序列JLS 规范假设输入已经是字符序列但 javac 实际读取时会使用某种字符编码。在 JDK 17 时代javac 默认使用平台默认编码——中文 Windows 上可能是 GBKLinux/macOS 上通常是 UTF-8。这会导致同一个源文件在不同机器上编译结果不同甚至是编译失败。解决方案很朴素所有编译环境显式指定编码。命令行里加上-encoding UTF-8构建脚本里也统一配置 UTF-8源文件保存时一律用“UTF-8 无 BOM”。别小看这个细节很多“代码在我机器上能跑”的离奇故事根源就是编码不一致。4.2 实现一个简化版Tokenizer下面的代码不是生产级的词法器但能演示 JLS 的几个关键规则跳过空白、跳过单行注释、按最大匹配识别标识符和整数、简单运算符的匹配。import java.util.ArrayList; import java.util.List; public class SimpleTokenizer { public static void main(String[] args) { String src int a 1_000; // 这是注释\n String name \hello\;\n ij;; for (String token : tokenize(src)) { System.out.println(token); } } static ListString tokenize(String src) { ListString tokens new ArrayList(); int i 0; int n src.length(); while (i n) { char c src.charAt(i); // 空白直接跳过 if (Character.isWhitespace(c)) { i; continue; } // 单行注释跳到换行 if (c / i 1 n src.charAt(i 1) /) { while (i n src.charAt(i) ! \n) { i; } continue; } // 标识符用 Character 的 isJavaIdentifierStart/Part if (Character.isJavaIdentifierStart(c)) { int j i; while (j n Character.isJavaIdentifierPart(src.charAt(j))) { j; } tokens.add(src.substring(i, j)); i j; continue; } // 数字这里只处理十进制和数字分隔符 if (Character.isDigit(c)) { int j i; while (j n (Character.isDigit(src.charAt(j)) || src.charAt(j) _)) { j; } tokens.add(src.substring(i, j)); i j; continue; } // 字符串字面量简化版不处理转义 if (c ) { int j i 1; while (j n src.charAt(j) ! ) { j; } tokens.add(src.substring(i, j 1)); i j 1; continue; } // 运算符最大匹配规则的一个小例子 if (c i 1 n src.charAt(i 1) ) { tokens.add(); i 2; continue; } // 单字符 Token tokens.add(String.valueOf(c)); i; } return tokens; } }运行这段代码输出大概是int a 1_000 ; String name hello ; i j ;这个输出和真正 javac 的词法结果基本一致。注意ij被切成了i、、、j这就是最大匹配规则的效果。Character.isJavaIdentifierStart和Character.isJavaIdentifierPart这两个方法出自 JDK 标准库它们对标识符字符的判断就是依据 JLS 规则实现的。4.3 用API查询关键字状态如果你在写工具代码需要判断一个字符串是不是关键字可以直接用 JDK 自带的 APIimport javax.lang.model.SourceVersion; boolean isKeyword SourceVersion.isKeyword(var);SourceVersion.isKeyword会告诉你某个字符串在当前 JDK 版本里是不是关键字。它同样能判断受限关键字在不同上下文里的状态但要注意 API 的版本关联在低版本 JDK 上跑高版本语法检查结果不可靠最好在目标编译版本一致的环境中进行。5. 常见问题排查不可见字符、Unicode转义陷阱、换行符差异速查这一章我会把我在实际开发、代码评审、排查问题中反复遇到的“词法级”问题整理成速查表。多数情况下代码的“逻辑错误”并不真的是逻辑问题而是字符在词法层面就已经错了。5.1 高频问题对照表现象根源处理方案编译报illegal character: \u200b复制代码混入了零宽空格U200B在 IDE 中开启“显示空白字符”用正则清除不可见字符编译报illegal character: \ufeff文件开头带了 BOM 头UTF-8 with BOM文件另存为“UTF-8 无 BOM”字符串里写\u000a编译报错Unicode 转义先翻译成真实换行字符串想要真正换行用\n想展示文本用\\u000a注释里放\u000a导致注释提前结束Unicode 转义先于注释识别执行注释里不写裸的\u序列同事机器上编译通过你这里报乱码javac 用了不同默认编码编译参数统一添加-encoding UTF-8变量名_报错Java 9 起_被限制为关键字换成普通命名ab的计算结果和自己预期不一样最大匹配把 Token 切成了a b增加括号明确运算顺序从 HTML 预览复制的代码编译不过智能引号、全角符号混入源码在编辑器中统一转为半角再保存为纯文本5.2 排查思路从报错信息反推词法阶段遇到“非法字符”类报错第一步是找到报错字符的 Unicode 码点。主流 IDE 的报错面板通常会显示字符本身如果显示不出来把源码复制到一个支持十六进制查看的编辑器中定位到报错行。看到\u200b这类零宽字符直接用正则清理[\u200b-\u200d\u00a0\u2060\ufeff]这种字符大多是网页排版、富文本复制带上来的肉眼看不到但对编译器来说就是实打实的非法输入。如果报错是“需要括号”“需要分号”这类语法错误但你的代码结构看起来完全正常优先怀疑注释和字符串边界出了问题。打开 IDE 的语法着色看注释区域是不是在某一个单词后突然变成了代码高亮是的话十有八九是 Unicode 转义或者某种隐藏字符把注释切断了。还有一类很隐蔽的问题全角符号。某些输入法在中文模式下会把(打成、把;打成。这类错误编译器会直接报非法字符有时候报出来的位置还比较靠后看起来像“错误在行尾”其实原因在中间逐字符看一遍就能发现。5.3 团队协作中的几条约定从踩坑经验里总结几条硬性约定第一源文件统一 UTF-8 无 BOM。这是最基础也是最重要的要求比代码风格更重要。带上 BOM 的 UTF-8 文件在部分编译器下会报\ufeff非法字符而无 BOM 的 UTF-8 在主流平台下最保险。第二代码里尽量不写裸的\uXXXX转义。需要展示特殊字符时要么写\\uXXXX要么在注释里用全角字符描述。我在某些文档注释里见过一堆\u4e2d\u6587形式的编码纯属给自己添堵。第三从任何网页、文档复制代码后先在 IDE 里全选并“显示空白字符”快速扫一遍有没有隐藏符号。不用每次都很紧张但至少要知道这个操作能救命。6. 写在最后几个我踩过的坑与习惯我最初看 JLS 第 3 章时觉得太枯燥满屏幕的字符定义和规则和写业务代码八竿子打不着。直到有一次排查一个线上问题某段配置生成的 Java 源码里混入了特殊字符IDE 不报错但编译产物行为异常折腾了一整天才定位到是词法阶段的空白符号在捣乱。从那以后遇到奇怪的编译问题我会先回到词法层面对一下规则往往比在语法树上找半天快得多。一个我到现在还在用的小技巧判断某个字符能不能出现在标识符里不用翻标准文档直接用Character.isJavaIdentifierStart和Character.isJavaIdentifierPart这两个静态方法跑一下。标准库已经严格基于 JLS 实现比你自己记规则靠谱。还有一个习惯写介绍 Java 特性的技术文章时凡是提到\u样式的转义序列一律写成两个反斜杠的代码块形式。因为很多阅读平台会做文本处理裸的\u000a一旦被某个中间环节当作特殊转义处理整篇文章的格式就崩了。这和 JLS 的 Unicode 转义陷阱是同一个原理。JLS 第 3 章内容并不算多但它是整个 Java 编译链路的第一道关口。理解了词法结构你再看到那些 “非法字符”“未结束的字符串”报错就会明白编译器正在严格按照规范做它该做的事剩下的就是在源码里把这些隐藏的坑一个个清干净。