如果你平时只写业务代码,大概率不会翻开《Java语言规范(JLS)》这种书。但只要你啃过第3章“词法结构(Lexical Structure)”,再回头看那些“明明代码没问题却编译不通过”的怪事,就会有种破案的感觉。JLS 17对应的就是Java 17长期支持版本的规范,而这一章看起来全是枯燥的名词定义,实际上它是Java编译器读取源码时执行的第一步:把一串字符拆成编译器能识别的Token。可以说,无论是手写编译器、开发代码生成器,还是单纯想弄懂为什么注释会“逃跑”、标识符会“非法”、字符串会“跨行”,都得回到这一章来找答案。
这篇内容适合三类人:正在系统学Java、想搞清编译原理底层逻辑的人;被Unicode转义、注释、文本块折磨过的开发者;以及准备面试时想聊明白“JVM怎么读你的代码”的求职者。我会结合JLS 17的章节顺序,把词法结构的核心规则、易踩的坑和实际排查经验一起拆开讲。
1. 词法结构到底是什么?先弄清Java源码的“拆解第一站”
1.1 从源码到Token:编译器是如何“读”Java的
很多人以为编译器拿到.java文件后,第一件事就是做语法分析,其实不是。语法分析器拿到的是一个个Token,而把字符流变成Token流的工作,就是词法分析器在做。词法结构规定得越清楚,语法分析器就越省心。
可以这样类比:读一段英文文章,你的大脑先要做“分词”,把连续的字母串按空格和标点切成单词,然后才能去分析句子结构。Java编译器也是这样,先把源码文件里的字符流切成Token序列,再交给语法分析。这里的Token指的是标识符、关键字、字面量、运算符、分隔符这五种基本元素。
JLS 17第3章定义的就是这个过程:源码里的字符经过Unicode转义处理、行终止符识别、空白和注释过滤之后,成为一个一个合法的Token。编译器如果在这一步栽了跟头,后面所有阶段都会跟着崩,而且报错信息可能非常难懂。比如unmappable character for encoding这类错误,本质就是词法阶段没法把某个字符映射成合法输入元素。
1.2 JLS 17第3章为什么值得细读
官方规范不是教程,读起来会有一种“每个字都认识但连起来读不懂”的感觉。但它的价值在于精确:什么字符合法、什么转义会被处理、行终止符有几种、注释遇到*/会发生什么,这些都有唯一答案。网络上很多二手教程会把词法规则简化,简化之后就容易丢细节。
我建议把第3章当作“词典”用,而不是“小说”从头读到尾。先知道每一小节讲了什么,遇到相关编译错误或者写代码生成器时再回来查。比如后面要聊到的Unicode转义,它在词法分析之前生效,这个顺序一旦搞错,很多坑你永远解释不了。
2. Unicode与转义:源码里藏着一台“提前执行的机器”
2.1 Unicode转义发生在词法分析之前
JLS 3.2规定了词法翻译的步骤:源码先被转换成Unicode字符流,然后扫描器识别\uxxxx形式的Unicode转义,把它们替换成对应字符,之后才进行正式分词。这里的xxxx是四位十六进制数,比如\u0041就是大写字母A。
关键点在于:Unicode转义不是在字符串或者注释里才生效,它在源码的任何位置都会被识别。这就意味着,标识符里可以写Unicode转义,注释里也可以写Unicode转义,甚至一个行注释都可能因为转义出来的字符是换行符而提前终止。这个特性很反直觉,因为大多数人的直觉是“注释里的内容不重要,随便写”。
JLS的表述是:首先将输入字符序列翻译成Unicode字符序列;然后为了得到Token序列,会对Unicode字符序列进一步翻译,其中会识别Unicode转义。所以要记住一个顺序:先处理Unicode转义,再做词法分析。
2.2 经典陷阱:注释被Unicode转义“截断”
下面这段代码就是教科书级别的坑:
// 这是一行注释 \u000a System.out.println("Hello"); System.out.println("World");你期望的可能是注释一直到行尾,然后只输出“World”。但\u000a会被预处理成换行符,于是注释在\u000a处结束,后面的System.out.println("Hello");变成了真实代码。运行这段程序会依次输出Hello和World。
这个例子我第一次看到时浑身发凉,因为以往写注释时根本不会考虑这种边角情况。更隐蔽的变体是,在块注释里放\u000a,它同样会把注释切开。比如/* 注释 \u000a */,预处理后会变成两行,最终能否编译成功取决于切出来的内容是不是合法Java代码。
还有一种常见场景:从外部复制的文本里含有\u000d或\u000a这样的特殊字符,粘贴到Java注释中后,编译器直接报错。这不一定是你写了错误代码,而是不可见字符在搞鬼。
2.3 防止转义被处理的“双反斜杠”
既然Unicode转义这么霸道,那如果我真的想在字符串里表示一个中文的Unicode编码,该怎么办?答案是注意反斜杠的数量。JLS规定,连续反斜杠的数量如果为偶数,后面的u才会被当成Unicode转义开头;如果是奇数,则反斜杠会把自身转义,u不再被特殊处理。
举个例子:
String s = "\\u0041"; System.out.println(s); // 输出 \u0041,而不是 A这里源码里实际有两个反斜杠。词法翻译时前一个反斜杠把后一个转义成一个普通反斜杠,后面的u0041就不会被当作Unicode转义解释。如果你想真的输出\u0041,写成"\\u0041"是对的;如果只写"\u0041",预处理阶段就已经把它变成了"A"。
这个规则在生成代码时尤其重要。很多代码生成模板为了绕过Unicode转义,会在模板变量里拼上双反斜杠,否则生成出来的代码可能变成完全不同的样子。
3. 行终止符、空白和注释:区分Token的“分隔逻辑”
3.1 三种行终止符:LF、CR与CRLF
JLS 3.4规定,行终止符只有三种:LF(换行\u000A)、CR(回车\u000D)和CRLF(回车加换行\u000D\u000A)。在源码解析时,CRLF会被视为一个行终止符,而不是两个。这对行注释和字符串字面量的影响非常大。
跨平台场景下,Windows文件通常以CRLF换行,Linux和macOS以LF换行。Java编译器的词法阶段会统一处理这两种情况,所以你在Windows上写的Java文件拿到Linux上编译,行终止符不会成为编译错误。但如果文件里混用了CR和LF,编译器的表现可能不符合直觉:它先把\u000D和\u000A合并成CRLF,再按一个单位切分。
这里有一个冷门结论:在Token流中,行终止符本身也是一个输入元素,但它不是任何一个Token的一部分。这意味着字符串字面量里不能直接包含原始换行符。如果你写:
String s = "hello world";编译器会报错,因为词法分析发现字符串在换行处被截断。文本块是Java 15之后引入的例外,后面单独说。
3.2 注释的两种形式:行注释与块注释
Java有两类注释:行注释以//开始,到行终止符结束;传统注释以/*开始,以*/结束,可以跨行。规范里说得很清楚:传统注释不能被嵌套。也就是说,/* /* */这种写法,第一个*/出现后注释就结束了,剩下的*/会成为普通Token,大概率引发语法错误。
我实际踩过这种坑。早期在代码里做临时注释时,经常把一大段代码包在/* */里,结果这段代码里本身就有块注释,漏了一个*/,导致整个编译失败。后来我遇到要注释大段代码的情况,要么用行注释逐行加,要么用IDE的“注释块”功能。如果你依赖正则去匹配块注释边界,也要注意JLS里“不能嵌套”这个规则。
注释里的Unicode转义同样会被处理,因为Unicode转义发生在分词之前,而注释过滤是在分词过程中做的。所以//\u000a会结束行注释,/*\u000d*/可能导致块注释被意外切开。写注释时不要放肉眼不可见的Unicode字符进去,这是血泪教训。
3.3 空白的真实范围:不只是空格和Tab
JLS 3.6把空白字符定义为:ASCII空格(SP)、水平制表符(HT)、换页(FF)以及行终止符。你可能会自然觉得,不换行空格\u00A0应该也算空白吧?不算。在Java的词法规则里,\u00A0不是空白字符,所以它不能充当Token之间的分隔符。
这是什么意思?假设你写了:
int a\u00A0= 1;编译器分词时,a和=之间没有合法的空白分隔符,于是会把a\u00A0=当作一个奇怪的Token来处理,直接报错。这类问题常常出现在复制网页代码片段时,肉眼看到的是空格,实际却是不换行空格。IDE有时会自动修复,但如果你用命令行编译,就会看到莫名其妙的“illegal character”错误。
4. 标识符、关键字与字面量:构建Token的“身份系统”
4.1 标识符规则:Java字母不只是英文字母
JLS 3.8定义了标识符:它必须由Java字母开头,后跟Java字母或Java数字;Java字母包括A到Z、a到z、$、_以及Unicode中属于字母的字符。Java数字除了0到9,也包含Unicode数字字符。所以严格来说,中文变量名是合法的:
int 变量 = 10; System.out.println(变量);这段代码能编译通过。不过在实际团队协作中,我强烈不建议用中文变量名,不是规范不允许,而是很容易出现编码混乱、工具链支持差异、跨平台换行符等问题。规范说“允许”和工程上说“推荐”是两码事。
关于$和_要特别注意。$在标识符中是合法的,很多字节码生成工具依赖这个字符生成内部类名,但规范作者也提醒过不要在代码里主动使用$。从Java 9开始,单下划线_被保留,不能再作为标识符。如果你见过Java 8之前在代码里用_做变量名的写法,升级到Java 17后会直接编译失败。
4.2 Java的关键字与保留字
关键字是语言保留的Token,不能当作变量名、方法名或类名。JLS 17中有一张完整的关键字表。我按使用频率整理一下:
| 分类 | 关键字 |
|---|---|
| 访问控制 | private, protected, public |
| 类、接口、枚举 | class, interface, enum, extends, implements |
| 流程控制 | if, else, switch, case, default, while, do, for, break, continue, return |
| 异常处理 | try, catch, finally, throw, throws |
| 面向对象 | new, this, super, instanceof, abstract, final, static, native, synchronized, transient, volatile, strictfp |
| 基本类型与相对关键字 | boolean, byte, char, short, int, long, float, double, void |
| 模块和导入 | package, import, module, requires, exports, opens, uses, provides, with, to |
| 其他保留 | assert, const, goto, _ |
注意true、false和null并非常规关键字。它们是字面量:布尔字面量和空字面量。但由于语法地位特殊,你不能把变量命名为true、false或null。const和goto在Java中从来没有真正使用过,但作为保留字存在,同样不能作为标识符。
4.3 字面量细节:从数字下划线到文本块
JLS 3.10把字面量分成整数、浮点、字符、字符串、文本块、布尔和空字面量。业务开发中接触最多的可能是数字字面量。
整数可以用十进制、十六进制(0x或0X)、八进制(0开头,比如017代表十进制15)、二进制(0b或0B)。Java 7之后还允许在数字之间加下划线来提升可读性:
long creditCardNumber = 1234_5678_9012_3456L; int max = 0b1010_0000_1111;下划线只能放在数字中间,不能放在开头、结尾、小数点两侧或者进制的前缀后面。写_100、100_、0_x1、3._14都会报错。这个细节虽然小,但代码评审时偶尔能见到。
浮点字面量支持十进制和十六进制,十六进制浮点听起来冷门,但规范里确实有。浮点数后面可以加f、F、d、D后缀,不加默认是double。经典写法1.0f和1e10d都是合法的,而0x10.0p1这样的十六进制浮点则属于高级玩法。
字符字面量用单引号包起来,里面可以是一个字符,也可以是一个转义序列:
char tab = '\t'; char quote = '\''; char backslash = '\\';字符串字面量用双引号,支持同样的转义序列。这里再次提醒:Unicode转义在字符串字面量中也会被预处理器提前执行,所以"\u000a"会在分词前变成一个包含换行符的字符串,造成字符串跨行错误。如果确实要表示\u000a这六个字符,必须写成"\\u000a"。
文本块是Java 15正式引入的,JLS 17中当然也有。它用三个双引号开头和结尾:
String html = """ <html> <body>Hello</body> </html> """;文本块最核心的价值是:源码里的原始换行符可以被直接包含在字符串中,不需要再用\n转义。JLS规定文本块会先被规范化为LF换行符,再根据最左侧的非空白行的缩进量去除公共缩进。所以不同平台、不同缩进风格下,文本块的输出内容是稳定且可预测的。如果要在文本块中保留尾部空格,可以使用\s转义;如果要强制一个换行,用\n。
5. 运算符、分隔符与“最长匹配”规则:Token边界的最终裁决者
5.1 最长匹配规则:a+++b到底怎么解析
词法分析里有一条非常重要的规则叫“最长匹配”(maximal munch):当多个Token可以匹配同一个开头时,编译器会选择最长的那个Token。JLS 3.2很明确地规定了这一点,这是理解很多运算符歧义问题的钥匙。
看这个例子:
int a = 10; int b = 2; int c = a+++b;你猜c是多少?如果按a + ++b来解析,结果是10 + 3 = 13;但按最长匹配,+++会被拆成++和+两个Token,也就是说实际表达式是(a++) + b,所以结果是10 + 2 = 12,同时a变成11。
我当年第一次看到这种题时还很自信地按a + ++b解释,结果被编译器的行为打脸。这告诉我们一个事实:词法分析器不考虑运算符的优先级,它只负责切Token,切的时候选择最长的可识别Token。优先级是语法分析阶段的事。
类似的情况还有x---y,按最长匹配应该是x -- - y,也就是(x--) - y。如果写成x -- > y,容易被误读成箭头,但编译器会老老实实拆成--和>。表达式的意图应该用括号写清楚,不要考验同事和编译器的理解能力。
5.2 分隔符与运算符清单
JLS 3.11列出了几个分隔符:用小括号()、花括号{}和中括号[]、分号;、逗号,、句点.、省略号...以及钻石运算符<>和注解@。特别注意,...是一个单独的Token,只能出现在可变参数声明中:
void printAll(String... args) { }如果你在参数列表里写String ... args,中间有空格也可以,这是因为空白会被忽略,...依然是一个Token。方法引用符::和Lambda箭头->也是独立的Token,它们的引入改变了很多语法场景,比如List::size要整体看成一个方法引用,而不是冒号串联。
关于<>,它在JLS里被称为钻石运算符,通常出现在new ArrayList<>()中。注意<>不是两个单独的比较运算符,而是一个Token。这跟后边要说的泛型>>问题完全不同。
5.3 泛型中的>>到底需不需要加空格
很多老开发者保留了在泛型嵌套时加空格的习惯:
List<List<String>> list1; // 现代Java写法 List<List<String> > list2; // 老式写法Java 7以前,词法分析器会把>>识别成右移运算符,导致泛型嵌套的关闭语法出问题,所以当时大家被迫在两个>之间加空格。Java 7之后,规范在语法层面做了改进,编译器能识别出>>在泛型上下文里应该被当作两个右尖括号处理。所以现在List<List<String>>完全合法,不需要加空格。
但是,有两种情况还是要小心:第一,如果你写的是移位运算,比如int x = a >> b;,这里的>>就是一个运算符,不能拆开;第二,如果你真的很在意可读性,统一写>>没问题,不要混着写,比如List<List<String>>>这种三个>连在一起,编译器也会根据上下文做判断,但读代码的人大概率会误解。规范允许不代表推荐,代码的可读性更多靠的是统一风格。
6. 我在实际项目里遇到过的词法结构“冷坑”与排查方法
6.1 自动生成代码中的Unicode转义和注释
有一次我写一个小工具,根据数据库表结构生成Java实体类。生成模板里包含一段版权注释,注释里有公司的备案号、生成时间等字段。某天同事报告生成的代码无法编译,报错信息指向注释区域。
排查后发现,数据库字段描述里有一个包含\u000d的字符串,我把它直接拼到了注释里。词法预处理时\u000d变成了回车,注释被截断,后面的内容掉进了代码区,自然编译失败。解决办法很简单:在写入注释前对所有\开头的字符做转义,或者干脆过滤掉控制字符。
这件事给我的教训是:凡是动态生成Java源码的工具,都要把词法规则当成一个攻击面来对待。字符串拼接模板时,不仅要考虑语法正确性,还要考虑注释、字符串、标识符之间微妙的交互。
6.2 文本块的缩进坑:别把IDE里的空格当回事
文本块看着方便,但缩进处理有一套自己的逻辑。JLS规定文本块会删除所有行末尾的空白,同时找到所有非空白行的最小缩进量,再把这个公共缩进去掉。如果你在IDE里看到的是对齐的,但代码里混用了Tab和空格,编译器会按字符宽度逐字比较,最终删掉的缩进可能不是你想要的结果。
我遇到过这样一个案例:同事在文本块里写一段HTML,整体缩进是4个空格,但其中一行的头部多了一个Tab字符,导致公共缩进变成0,输出的HTML前面多了一堆空格。排查时肉眼很难看出区别,最后用十六进制查看器才发现是Tab和空格混用。解决办法是写文本块时统一用空格,不要用Tab;如果需要精确定制缩进,用\s和文本块的行尾标记来配合。
6.3 常见问题速查表:代码“发疯”先查词法
排查词法相关问题时,我建议按“现象 -> 原因 -> 解决”的思路快速定位。下面这个表格可以当速查工具:
| 现象 | 可能原因 | 解决方式 |
|---|---|---|
| 注释突然截断,后面代码报错 | 注释中出现了\u000a或\u000d等Unicode转义 | 删除注释里的不可见字符,或对反斜杠做转义 |
| 字符串无法编译,报“unclosed string literal” | 字符串里有未转义的行终止符,或\u000a被提前处理 | 使用文本块,或用\\u000a表示字符串内容 |
_作为变量名时报错 | 从Java 9开始_是保留字 | 换用其他变量名 |
| 注释掉大段代码后编译仍报错 | 块注释不能嵌套,内部含有*/提前结束 | 改用行注释,或用IDE的注释功能 |
| 复制代码后报“illegal character” | 代码中混入了不换行空格\u00A0 | 检查并用普通空格替换不可见字符 |
| 文本块输出多余缩进 | Tab和空格混用,或行尾空白未处理 | 统一空格,避免Tab,使用\s控制空白 |
| 生成代码注释区域报错 | 动态模板把Unicode控制字符拼进了注释 | 在生成前过滤转义字符 |
这张表我经常放在团队的编码规范文档里。虽然词法问题占日常开发的比例不高,但一旦出现,排查成本往往比业务逻辑问题高得多,因为它藏在字符层面,肉眼看不出来。我的经验是:遇到编译错误后先别急着改语法,花30秒用十六进制视图看看争议代码附近的字符,很多谜底当场揭晓。
最后再分享一个小习惯:除非你明确要在字符串里表达Unicode语义,否则尽量直接写实际字符,不要用\uXXXX。代码里的转义越多,被JLS预处理机制反噬的概率越大。该用文本块就用文本块,该用普通转义序列就用普通转义序列,只有这样才能少跟词法结构打交道。