☰
JLS 17第3章:从词法结构看透Java编译报错的根源
2026/10/17 4:47:11 网站建设 项目流程

先请教一个问题:你从网页、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。其中空白和注释在大多数情况下都会被丢弃,不生成 Token,Token 才是语法分析真正要用的东西。

空白包括空格、制表符、换页等。注释有三种://行注释到行尾结束;/* ... */传统注释;/** ... */文档注释。注意传统注释不嵌套:/* /* */里的第一个*/就结束了整个注释,后面的内容会重新进入代码解析。

这一阶段的另一个核心规则叫“最大匹配”(Maximal Munch):编译器总是一口气吞下最长、合法的 Token。比如a+++b会被切分为a、++、+、b,而不是a、+、++、b。把这个规则落到表达式里,a+++b等价于(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;是标准错误。

受限关键字有意思。它们只在特定语法位置才被当作关键字,其他位置仍然可以当标识符。比如局部变量类型推断里的var,var 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-9f,JLS 还支持十六进制浮点,比如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 = a+++b; // 实际上是 (a++) + b

这种写法正式代码里不该出现,但代码压缩、字符串拼接生成源文件时可能会“制造”出来。理解了最大匹配,看到这类代码就不会一头雾水。

分隔符相对简单:( ) { } [ ] ; , . ... @ ::这些。注意...是可变参数语法的一部分,::是方法引用运算符,都不属于分隔符范畴。@是注解起始符,也单独算一个 Token。

4. 实操现场:手写一个极简词法器验证JLS规则

规范读再多,不如动动手跑一遍。这里我做一个小实验:手写一个简化版的词法分析器,把 JLS 第 3 章里的部分规则落地。

4.1 先给源码编码定调:所有编译器参数统一UTF-8

现实中的.java文件在磁盘上是字节序列,JLS 规范假设输入已经是字符序列,但 javac 实际读取时会使用某种字符编码。在 JDK 17 时代,javac 默认使用平台默认编码——中文 Windows 上可能是 GBK,Linux/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" + "i+++j;"; for (String token : tokenize(src)) { System.out.println(token); } } static List<String> tokenize(String src) { List<String> 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 的词法结果基本一致。注意i+++j被切成了i、++、+、j,这就是最大匹配规则的效果。Character.isJavaIdentifierStart和Character.isJavaIdentifierPart这两个方法出自 JDK 标准库,它们对标识符字符的判断就是依据 JLS 规则实现的。

4.3 用API查询关键字状态

如果你在写工具代码,需要判断一个字符串是不是关键字,可以直接用 JDK 自带的 API:

import javax.lang.model.SourceVersion; boolean isKeyword = SourceVersion.isKeyword("var");

SourceVersion.isKeyword会告诉你某个字符串在当前 JDK 版本里是不是关键字。它同样能判断受限关键字在不同上下文里的状态,但要注意 API 的版本关联:在低版本 JDK 上跑高版本语法检查,结果不可靠,最好在目标编译版本一致的环境中进行。

5. 常见问题排查:不可见字符、Unicode转义陷阱、换行符差异速查

这一章我会把我在实际开发、代码评审、排查问题中反复遇到的“词法级”问题整理成速查表。多数情况下,代码的“逻辑错误”并不真的是逻辑问题,而是字符在词法层面就已经错了。

5.1 高频问题对照表

现象根源处理方案
编译报illegal character: '\u200b'复制代码混入了零宽空格(U+200B)在 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 起_被限制为关键字换成普通命名
a+++b的计算结果和自己预期不一样最大匹配把 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 编译链路的第一道关口。理解了词法结构,你再看到那些 “非法字符”“未结束的字符串”报错,就会明白编译器正在严格按照规范做它该做的事,剩下的,就是在源码里把这些隐藏的坑一个个清干净。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询