
1. 从一次代码评审说起这三样东西决定了代码的下限前阵子帮一个朋友的团队看代码项目不大一个内部用的数据整理小工具几百行 Java。翻到第三个类我就停下来了类名叫data方法叫Get_List变量叫int 数量 0;中间还夹着三段被/* */包起来、明显是调试遗留的旧逻辑。我把这几个文件拉了个清单给他们看问题不在算法也不在框架全卡在 Java 最基础的三件事上——标识符、关键字、注释。这三样东西几乎是每个零基础学 Java 的人第一天就会碰到的东西却也是写了半年代码之后依然反复出错的地方。标识符说白了就是起名字变量、方法、类、接口、包、常量、泛型参数只要能起名字的地方用的都是标识符关键字是 Java 语言自己占用的那批词五十来个你一个都不能拿来当名字注释则是给人看的、编译器会直接跳过的那部分文本。听起来都是小儿科可实际项目里命名随意会让三个月后的自己看不懂代码关键字用错会直接编译失败注释写歪了会误导接手的人甚至把整段逻辑注释掉之后忘了恢复生产环境直接出问题。这篇东西写给三类人完全没摸过 Java、正准备写第一个HelloWorld的新手写过一阵子但命名全靠感觉、注释全靠随手的人还有准备带新人、需要一套可讲清楚的说法的人。我会把语法规则、背后的设计考量、真实踩坑场景和可以直接抄的规范都摊开讲。1.1 为什么第一课偏偏是它们很多教程把这三块内容塞在第一章讲得飞快二十分钟带过然后立刻进入数据类型和流程控制。这给人一种错觉这些东西不重要考试考两道选择题就完了。我的看法完全相反——这三样东西是 Java 代码能不能被别人接住的地基。语法不会编译器会当场骂你报错信息写得清清楚楚你改就是了命名和注释不会编译器一句话都不说代码照样跑但三个月后接手的人很可能就是你自己要在那儿一行一行猜。前者是硬伤会立刻暴露后者是软伤会慢慢积累。我在实际项目里见过一个更典型的场景某个模块里有两个变量一个叫list一个叫list2前者是用户列表后者是过滤后的用户列表。写的时候都懂改需求的时候加了第三个list3半年后要修 bug谁也不敢确定list2到底是不是过滤后的。这种问题靠单元测试救不了只能靠当初起名字的那一刻多花五秒钟。所以我在带新人的时候第一章会花整整一次分享的时间讲命名和注释讲完当场让他们把之前写的练习代码全部重命名一遍效果比讲十遍语法都强。1.2 新手最常见的三个误解第一个误解是标识符只要不报错就行。编译器确实很宽容int a1 1;和int 用户数量 1;都能过但能编译和能维护之间差了十万八千里。第二个误解是关键字就那么几个用到了自然会知道。实际上 Java 的关键字列表这些年一直在动var、record、sealed、yield这些后来加入的成员很多人的印象还停留在它们只是普通单词结果在特定位置一用就报错。第三个误解是注释越多越好。我见过一个类方法体十二行上面的注释写了二十行其中一半在复述代码干了什么剩下一半已经和代码对不上了因为改了逻辑没改注释。注释的价值不在数量在于它解释了代码本身说不出来的那部分东西。1.3 这一趟要走完的路接下来的内容按规则—规范—实操—排错四层推进。规则层讲清楚编译器到底认什么这是底线规范层讲团队里普遍接受的写法这是默契实操层用一个完整的记账小工具把三块内容串起来你能直接跑起来看效果排错层整理我这些年攒下来的报错对照表遇到红字直接查。你不需要提前装什么复杂环境一个 JDK 加一个文本编辑器就够后面给命令。至于版本我下面的示例默认按 JDK 17 来写这也是目前长期支持版本里用得比较多的一个用 JDK 8 或 21 也基本能跑个别差异我会标出来。2. 标识符给变量、方法、类起名字的全部规矩2.1 编译器认可的硬规则Java 语言规范对标识符的定义其实挺宽松的首字符必须是Java 字母后续字符可以是Java 字母或Java 数字长度不限区分大小写不能是关键字、布尔字面量true/false和空字面量null也不能是 Java 9 之后单独使用的下划线_。这里的Java 字母不只是 A 到 Z还包括下划线_、美元符$以及任何 Unicode 中被认定为字母的字符——中文、日文、希腊字母、阿拉伯字母都算。判断标准落在Character.isJavaIdentifierStart()和Character.isJavaIdentifierPart()这两个方法上你甚至可以在代码里直接调用它们来验证某个字符能不能用。public class IdentifierCheck { public static void main(String[] args) { char[] samples {a, _, $, 中, 1, -, }; for (char c : samples) { System.out.printf(%c - start:%s, part:%s%n, c, Character.isJavaIdentifierStart(c), Character.isJavaIdentifierPart(c)); } } }跑一遍你会发现数字1不能做首字符但可以做后续字符减号-和空格两个位置都不行。这就是为什么user-name这种写法在 Java 里是非法标识符——它会被当成user减name的表达式编译器随后报cannot find symbol报错位置还挺靠后新手很容易找不到病根。同理2ndValue不行但value2、value_2、value$2都行。2.2 一条条拆解字符、数字、下划线、美元符下划线_是个特殊存在。在 Java 7 引入数字字面量下划线分隔符1_000_000之后语言设计者觉得单独一个_作标识符太容易和这个新特性混淆于是从 Java 9 开始把单独的_列为关键字你写int _ 1;会直接得到一条明确的错误as of release 9, _ is a keyword, and may not be used as an identifier。注意是单独一个下划线不行_count、user_name、_1这些都是合法的。很多老教程还写着_可以用照着敲会卡住这不是你的问题是教程过时了。美元符$是另一个容易引发争议的字符。编译器完全允许$value、value$、$$都是合法标识符。但 Java 编译器和很多工具链自己会生成以$开头的名字比如内部类的Outer$Inner.class、Lambda 表达式的合成方法、某些框架的字节码增强产物。如果你自己手写$开头的变量排查栈信息的时候会分不清哪个是源码里的、哪个是编译器生成的。所以业界惯例很一致除了代码生成器人手写的代码不要用$。int count 0; // 好 int userCount 0; // 好 int user_count 0; // 能编译但不符合 Java 惯例这是 C 风格 int $cache 0; // 能编译实践中避免 int 用户数量 0; // 能编译中文环境下偶尔见到团队要统一 // int 2count 0; // 编译错误数字不能开头 // int user-count 0; // 编译错误会被解析成减法表达式 // int class 0; // 编译错误class 是关键字2.3 团队协作层面的软规范硬规则之上还有一层软规范也就是大家都这么写的约定。这层东西编译器不管但它是代码能不能被顺畅阅读的关键。我把最常见的几类整理成表你可以直接对着改元素类型命名风格示例说明包名全小写点分层级com.example.ledger用公司或组织的域名倒置避免撞名类 / 接口 / 枚举 / 注解大驼峰UpperCamelCaseUserAccount、OrderService每个单词首字母大写方法小驼峰lowerCamelCasecalculateTotal、findById动词开头表达做什么局部变量 / 成员变量小驼峰totalAmount、userName名词表达是什么常量static final全大写 下划线MAX_RETRY_COUNT单词间用下划线连接泛型类型参数单个大写字母T、E、K、VT类型、E元素、K键、V值抽象类大驼峰常用Abstract前缀AbstractHandler可选看团队习惯这份表看着简单但每一条背后都有理由。包名全小写是为了避免在 Windows 和 Linux 之间切换时因为大小写敏感问题找不到类类名大驼峰是为了和变量名一眼区分开UserAccount userAccount new UserAccount();你扫一眼就知道哪个是类型哪个是实例常量全大写是因为它在整个程序生命周期内不变视觉上要足够跳出来。至于方法名为什么用动词、变量名为什么用名词是因为读代码时人脑会自动把动词理解成动作、把名词理解成数据顺着这个直觉写阅读成本最低。2.4 三组实操变量、常量、类的命名演练光讲规则没感觉我拿一段真实会遇到的改造给你看。假设有人写了这么一段代码public class data { public static int a 10; public static int b 3; public String s; public int getv() { return a / b; } }这段代码能编译但每个名字都在给阅读者添麻烦。改造的思路不是换个好看的名字而是把每个名字背后的含义补回去data到底装的是什么数据a和b是什么关系s是什么字符串改完之后大概长这样public class ScoreStatistics { /** 参与统计的样本总数 */ private static final int TOTAL_SAMPLES 10; /** 每组样本数用于计算平均值 */ private static final int GROUP_SIZE 3; /** 统计对象的名称 */ private String subjectName; /** 返回平均每组样本数结果向下取整 */ public int averagePerGroup() { return TOTAL_SAMPLES / GROUP_SIZE; } }改完之后信息量大了很多类名告诉你这是分数统计常量名告诉你 10 是总数、3 是分组大小方法名告诉你返回的是每组平均数而不是别的什么。注意这里常量从public改成了private因为外部没有理由直接读这些数字暴露出去反而增加耦合。这个改动和命名规范没关系但顺手一起做了实际项目里这类联动很常见。再补一个常量命名的细节。很多人会把常量写成maxCount小驼峰。这在小项目里没人管但大型代码库里会出现一个尴尬情况maxCount看起来像普通变量有人以为能改写了一句maxCount 100;编译器报cannot assign a value to final variable然后他一脸茫然。全大写的MAX_COUNT从视觉上就宣告了别碰我这种零成本的防御很值。2.5 中文命名到底行不行这个问题我被问过不止十次。答案是技术上完全可行工程上要极其谨慎。前面说过Java 的标识符判定走 Unicode 字符分类int 用户数量 0;在 JDK 17 上编译运行完全正常。但为什么不建议呢第一是输入效率。切输入法、敲中文、再切回来一整天下来会消耗大量注意力。第二是编码依赖。源文件必须是 UTF-8编译时还得带-encoding UTF-8跨平台、跨工具链的时候容易被搞乱我在 6.2 节会详细讲这个坑。第三是工具链兼容。某些静态分析工具、代码生成器、老版本的构建插件对非 ASCII 标识符的处理并不完美会出现莫名其妙的乱码或解析失败。第四是协作。团队里有人用中文注释有人用英文命名混着来最后谁都看不懂。我的建议很明确标识符一律用英文注释可以用中文。英文不好就查反正变量名翻来覆去就那么几百个词。userName、totalAmount、isValid、hasPermission这些用熟了根本不用想。真遇到实在翻译不出来的领域词用拼音也比硬翻译强dingdan总比orderThing好理解。中文只在一种情况下值得用教学演示、写给别人看的极简示例讲清楚概念立刻改回英文。3. 关键字与保留字那些一个字都不能碰的词3.1 五十个关键字的分组记忆法Java 的关键字一共五十个不同版本的教材统计口径略有出入true/false/null严格说是字面量有些资料也把它们算进关键字表。五十个词硬背很痛苦按用途分组就好记多了分组关键字用途访问控制publicprotectedprivate控制可见性类与接口classinterfaceenumextendsimplementsabstractfinalstatic定义类型与继承关系包与导入packageimport组织代码结构基本类型byteshortintlongfloatdoublecharbooleanvoid声明数据类型流程控制ifelseswitchcasedefaultforwhiledobreakcontinuereturn控制执行顺序异常处理trycatchfinallythrowthrowsassert处理异常与断言对象与实例newthissuperinstanceof创建与引用对象修饰符synchronizedvolatiletransientnativestrictfp并发、序列化、本地方法等保留未用constgoto预留Java 中不使用strictfp在 JDK 17 之后基本被废弃了JEP 306 让浮点运算在所有平台上都严格一致这个关键字就失去了意义你会在老代码里看到它新代码基本不用写。assert需要加-ea参数才生效默认是关的很多人没见过它真正干活的样子。transient只在序列化时起作用普通业务代码里出现频率很低。这些细节不影响你把它们记成关键字但知道它们各自在哪儿生效能帮你在看别人的代码时不至于发懵。3.2 保留字 const 与 gotoconst和goto这两个词有点特殊它们在 Java 里没有任何语义但你依然不能拿它们当标识符。语言设计者的考虑是万一以后要加这两个特性现在就把名字占住避免将来出现升级 JDK 之后老代码批量编译失败的灾难。这个做法在语言设计里叫保留字const和goto就是 Java 的保留字它们不在关键字的功能列表里但同样禁止用作标识符。实际影响是什么如果你从 C 或者 Go 转过来手很容易顺写一个const int MAX 10;编译器甩你一句not a statement或者; expected报错位置还特别迷惑。Java 里要写常量只有一条路static final。同理goto在 Java 里没有对应语法循环里的跳转只能靠break、continue加标签实现outer: for (int i 0; i 3; i) { for (int j 0; j 3; j) { if (i 1 j 1) { break outer; // 跳出外层循环代替其他语言里的 goto } } }这里的outer是标签不是关键字你可以叫它done、exit都行只要后面跟一个冒号然后被break或continue引用。这个语法在实际项目里用得不多但面试和考试里很常见值得知道。3.3 true、false、null 算不算关键字严格按 Java 语言规范来说这三个是字面量literal不是关键字。规范把它们和关键字分开列。但它们的约束效果是一样的你不能写int true 1;也不能写String null x;。区别在于报错信息的措辞编译器的提示会略有不同但对你来说处理方式一样——改名。有个小细节值得一提True、FALSE、Null这些大写变体是合法标识符因为 Java 区分大小写。所以你写boolean True false;编译器不拦你。但这是纯粹的自我折磨团队里谁看到都会想打人别干。3.4 上下文关键字与受限标识符var、record、sealed、yield这部分是新手教程最容易漏掉、也最容易在实际编码时踩雷的地方。Java 近些年加了一些新特性为了不破坏老代码的兼容性没有把它们设成绝对关键字而是设成了上下文关键字或受限标识符——只有在特定语法位置才有特殊含义离开那个位置就是普通单词。这就导致了一些很怪的现象同一个词在这里能当变量名在那里不能。var是典型例子。Java 10 引入了局部变量类型推断var list new ArrayListString();让编译器自己推类型。var严格来说是保留类型名reserved type name意思是你不能用它当类名、接口名、枚举名但是可以用它当变量名、方法名、包名。所以int var 1;在 JDK 10 之后依然合法只是看起来极其诡异。实际项目里没人这么干但你在读老代码时可能会碰到叫var的变量别以为是语法新特性。record是 Java 16 正式引入的记录类关键字sealed/permits是 Java 17 的密封类关键字yield是 Java 14 的 switch 表达式关键字。它们的共同特点是作为类型名或变量名时在大多数位置仍然合法只在特定上下文才被当成关键字解析。比如Thread.yield()这个老方法在 Java 14 之后依然能正常调用编译器能分清那是方法调用而不是 switch 的 yield 语句。// var 作为变量名合法但不推荐 int var 10; System.out.println(var); // var 作为类型推断关键字JDK 10 var names java.util.List.of(a, b, c); // record 作为普通标识符合法 int record 1; // record 作为记录类声明JDK 16 record Point(int x, int y) { }看到这里你应该能感觉到这套设计的核心目标是向后兼容。Java 生态里有海量的老代码如果把var直接设成关键字所有拿var当变量名的代码全部编译不过那是灾难性的。所以语言设计者宁愿让语法规则复杂一点也要保证老代码能继续跑。理解这个设计取舍比死记哪些词能用哪些不能用更有价值。3.5 踩坑实录把 String 当关键字、把 var 当关键字我见过两种相反方向的错误都挺有意思。第一种是把String当成关键字。有新人问为什么String能当类型用它是不是关键字其实String是java.lang包下的一个普通类只是编译器对它做了特殊照顾字符串字面量自动创建对象、运算符重载它本身不在关键字表里。所以理论上你能写class String {}覆盖掉它在自己包里当然这是自找麻烦。第二种是把var当成彻底不能用的关键字。有人看到新语法之后把所有变量名里的var全改掉了包括variance、variable这种压根不冲突的词纯属自己吓自己。判断标准很简单看它出现的位置。如果它在语句开头、后面跟变量名和等号那就是类型推断如果在其他位置编译器会按普通标识符处理。还有一种更隐蔽的情况用了 Lombok 之类的代码生成工具或者用了某些 ORM 框架它们生成的代码里可能出现$开头的字段名或者奇怪的命名你在 IDE 里跳转过去会看到一堆波浪线。那不是你的代码写错了是工具链生成的别去改。4. 注释写给人看的那部分代码4.1 三种注释的边界Java 只有三种注释形式规则很清晰// 单行注释从 // 到行尾 /* * 多行注释从 /* 开始到 */ 结束 * 注意多行注释不能嵌套 */ /** * 文档注释Javadoc只写在类、方法、字段、构造器的声明之前 * 会被 javadoc 工具提取成 HTML 文档 * param 参数说明 * return 返回值说明 */选择哪一种判断标准不是长短而是这条注释给谁看、在哪儿看。单行注释用在方法体内部解释某一行或某几行的意图多行注释用在方法体内部解释一段逻辑或者临时注释掉一段代码文档注释只出现在声明之前服务于调用方——它描述的是这个方法是干什么的、参数什么意思、会抛什么异常而不是这个方法内部怎么实现的。这个区分很多人搞不清导致所有注释都写成//最后 API 文档生成出来一片空白。有个硬性限制必须记住多行注释不能嵌套。因为/*之后编译器一路找最近的*/就结束了。你写/* * 外层注释开始 * /* 内层注释 */ ← 这里就把外层注释结束了 * 剩下的内容变成代码编译器立刻报错 */这个坑在调试时特别容易踩——你把一段本来就带/* */的代码用/* */包起来想临时禁用结果编译报一堆莫名其妙的错。解法是改用单行注释逐行注释或者用 IDE 的快捷键IDEA 里是Ctrl /它能正确处理嵌套情况。4.2 Javadoc 的标签体系与真实产出文档注释之所以单独存在是因为它能被工具解析成结构化文档。写好了别人不用看源码就知道怎么用你的类写砸了生成的文档比没有还糟糕。常用的标签不多记住这几个够用标签用在哪说明param方法、构造器描述每个参数的含义参数名要对得上return有返回值的方法描述返回值的含义void方法不写throws方法、构造器描述什么情况下抛什么异常since类、方法从哪个版本开始有这个 APIdeprecated类、方法、字段标记已废弃配合deprecated注解一起用see任意指向相关的内容{code xxx}行内把内容渲染成等宽代码样式不解析 HTML{link 类名}行内生成可点击的链接写一个完整的例子/** * 计算订单的应付金额包含折扣与运费。 * * p折扣规则订单金额满 100 元减 10 元满 200 元减 30 元。 * 运费规则订单金额满 99 元免运费否则加收 8 元。/p * * param items 订单中的商品列表不能为 {code null} * param couponId 优惠券 ID传 {code null} 表示不使用优惠券 * return 应付金额单位为分永不返回负数 * throws IllegalArgumentException 当 {code items} 为 null 或为空时抛出 * since 1.2 */ public long calculatePayable(ListItem items, String couponId) { // ... }然后是生成命令。假设源文件在src/com/example/ledger/目录下javadoc -d docs -encoding UTF-8 -charset UTF-8 -docencoding UTF-8 \ -sourcepath src \ com.example.ledger三个编码参数都不能省少一个中文就可能变乱码具体原因见 6.2 节。跑完之后打开docs/index.html你会看到一个完整的 API 文档站点左侧是包和类列表右侧是类的说明、方法签名、参数表格。这就是 JDK 官方文档的生成方式你平时查的java.util.List那个页面就是同一套工具产出的。4.3 注释的四个反模式反模式一复述代码。// 把 a 和 b 相加下面跟着int c a b;这种注释的价值是零。注释应该解释为什么代码本身已经说明了是什么。比如int c a b; // 加上运费因为前端不负责计算就有价值它告诉读者这个加法不是随便加的。反模式二注释和代码不同步。改了逻辑没改注释比没注释更危险因为读者会相信注释。我的经验是注释里凡是出现具体数字、具体条件的地方改代码时必须同步搜一遍。IDE 有查找引用功能但注释不在引用图里只能靠约定和 review 兜住。反模式三把整段代码注释掉留在那儿。先注释掉万一以后要用呢——这句话是技术债的开端。代码库有版本控制删掉的东西永远找得回来留在源码里只会让后来的人怀疑这段是不是还要恢复。真有需要保留的要么开分支要么写进文档。反模式四文档注释写成实现说明。在param里写这个参数会先做 trim 再做长度校验这是实现细节调用方不需要知道而且实现一变注释就错。文档注释应该描述契约接受什么、返回什么、抛什么。4.4 调试题外话注释掉代码的坑有个场景值得单独说调试的时候把一段代码用/* */注释掉忘了恢复提交上去了。这在 git 的历史里几乎每个项目都能挖出来。防的办法很简单不要用块注释临时禁用代码用 IDE 的行注释快捷键每行前面都有//恢复的时候一看就知道哪些行被动过。另外调试完立刻恢复不要等会儿一起弄那个等会儿大概率不会到来。还有个更阴的if (flag) /* 注释 */ doSomething();这种写法看着没毛病但如果注释里有个*/之外的意外结构或者有人在if和语句之间插了行注释导致语句被注释掉逻辑就悄悄变了。所以我的习惯是if、for、while的关键字后面永远跟大括号不管里面有几行。这个习惯和注释本身无关但能防住一大类由注释引起的问题。5. 串起来做一遍一个记账小工具的完整落地5.1 需求拆解与命名规划光讲规则记不住我们用一个小工具把三块内容全用上。需求很简单记录几笔收入和支出打印明细算出余额对异常输入给出提示。规模控制在两个类正好够展示命名、关键字、注释的配合。命名规划先做这一步很多人跳过直接开写写到一半发现名字不够用了才开始返工。我的做法是先把领域里的名词列出来账户Account、流水条目Entry、金额amount、类型type、备注memo。然后定类名和成员名类LedgerEntry流水条目、LedgerApp入口与主流程字段amountCents金额单位分用long避免浮点误差、type收入或支出、memo备注方法addEntry、balanceCents、printReport金额用分作为整数单位是个老经验了浮点数double做金额运算会出现0.1 0.2 0.30000000000000004这种结果账目场景绝对不能接受。这个和标识符没关系但既然要写示例就写个不会带坏人的版本。5.2 完整代码与逐块讲解先看条目类package com.example.ledger; /** * 一条记账流水表示一次收入或一次支出。 * * p金额统一以分为单位存储避免浮点精度问题。 * 实例创建后不可变所有字段均为 {code final}。/p * * since 1.0 */ public final class LedgerEntry { /** 收入类型标识 */ public static final String TYPE_INCOME INCOME; /** 支出类型标识 */ public static final String TYPE_EXPENSE EXPENSE; /** 金额单位分恒为正数 */ private final long amountCents; /** 流水类型取值为 {link #TYPE_INCOME} 或 {link #TYPE_EXPENSE} */ private final String type; /** 备注可为空字符串但不可为 null */ private final String memo; /** * 构造一条流水。 * * param amountCents 金额单位分必须大于 0 * param type 类型只能是 INCOME 或 EXPENSE * param memo 备注传 null 时按空字符串处理 * throws IllegalArgumentException 当金额非正或类型非法时抛出 */ public LedgerEntry(long amountCents, String type, String memo) { if (amountCents 0) { throw new IllegalArgumentException(金额必须大于 0当前值 amountCents); } if (!TYPE_INCOME.equals(type) !TYPE_EXPENSE.equals(type)) { throw new IllegalArgumentException(非法类型 type); } this.amountCents amountCents; this.type type; this.memo memo null ? : memo; } /** 返回带符号的金额收入为正支出为负 */ public long signedAmountCents() { return TYPE_INCOME.equals(type) ? amountCents : -amountCents; } /** 以元为单位格式化金额保留两位小数 */ public String formattedAmount() { return String.format(%.2f, amountCents / 100.0); } Override public String toString() { return String.format(%-8s %10s %s, type, formattedAmount(), memo); } }这段代码里能看出的东西不少。TYPE_INCOME和TYPE_EXPENSE是全大写常量告诉读代码的人这两个值不会变amountCents用long而不是int因为int最大约 21 亿分也就是 2100 万元大额场景会溢出long能撑到 9 后面 18 个零字段全是final配合final class构成不可变对象多线程环境下不用加锁这是 Java 里非常推荐的一种写法。构造器里的参数校验属于防御式编程非法数据在入口就挡掉比后面某处莫名其妙算出负数要好排查得多。再看主类package com.example.ledger; import java.util.ArrayList; import java.util.List; /** * 记账小工具的命令行入口。 * * p演示标识符命名、关键字使用与注释规范的配合。/p */ public class LedgerApp { /** 单笔金额上限单位分防止误输入超大数字 */ private static final long MAX_AMOUNT_CENTS 1_000_000_00L; public static void main(String[] args) { ListLedgerEntry entries new ArrayList(); entries.add(new LedgerEntry(3500L, LedgerEntry.TYPE_INCOME, 稿费)); entries.add(new LedgerEntry(1280L, LedgerEntry.TYPE_EXPENSE, 午饭)); entries.add(new LedgerEntry(99_00L, LedgerEntry.TYPE_EXPENSE, 打车)); long balance 0L; System.out.println(类型 金额 备注); System.out.println(------------------------------); for (LedgerEntry entry : entries) { System.out.println(entry); balance entry.signedAmountCents(); } System.out.println(------------------------------); System.out.printf(余额%.2f 元%n, balance / 100.0); // 演示上限校验超过上限直接拦下 long tooLarge MAX_AMOUNT_CENTS 1; try { new LedgerEntry(tooLarge, LedgerEntry.TYPE_INCOME, 测试); } catch (IllegalArgumentException e) { System.out.println(拦截到非法数据 e.getMessage()); } } }这里有几个刻意设计的点。1_000_000_00L用了下划线分隔读的时候能快速看出是 100 万零 0 分也就是 100 万元比一长串零好认99_00L同理表示 9900 分。数字字面量下划线是 Java 7 的特性规则是只能出现在数字之间_100、100_、1__0都不合法。balance / 100.0里的100.0是double字面量除以long会提升成double这是安全的因为只是展示用不参与后续计算。如果要把结果再存回金额字段就必须转回long且处理舍入不能直接赋值。5.3 编译、运行、生成文档目录结构按包名来这是 Java 的硬要求——package com.example.ledger;的源文件必须放在com/example/ledger/目录下编译产物才能被正确加载ledger-demo/ ├── src/ │ └── com/example/ledger/ │ ├── LedgerApp.java │ └── LedgerEntry.java └── out/编译命令javac -encoding UTF-8 -d out src/com/example/ledger/*.java-d out指定输出目录javac会自动按包名生成子目录结构。-encoding UTF-8指定源码编码这一条极其重要6.2 节会讲不写会发生什么。运行java -cp out com.example.ledger.LedgerApp注意运行时要写全限定类名包名 类名不能只写LedgerApp因为它不在默认包里。直接java -cp out LedgerApp会得到Could not find or load main class LedgerApp。生成文档javadoc -d docs -encoding UTF-8 -charset UTF-8 -docencoding UTF-8 \ -sourcepath src com.example.ledger跑完之后docs/目录下会生成一堆 HTML打开index.html就能看到两个类的完整文档包括since、throws、{link}渲染出来的效果。把鼠标悬停在TYPE_INCOME的链接上能看到它跳转到常量声明处这就是{link}的作用。这个体验和查 JDK 文档一模一样你自己写的代码也能有。5.4 故意写错读懂五类典型报错学语法最快的办法是主动撞墙。下面五个错误你手动敲一遍看看编译器怎么说比看十遍规则印象深。错误一用关键字当变量名。int class 1; // error: identifier expected // error: ; expected报错信息看起来很怪因为编译器在int后面期待一个标识符看到class就懵了它以为你要声明一个类。这种报错位置看不懂的情况第一反应应该是检查有没有撞关键字。错误二数字开头。int 2count 1; // error: ; expected // error: identifier expected编译器把2当成一个独立的字面量后面跟count就不知道该怎么解析了。看到这个报错先看标识符首字符。错误三标识符里有连字符。String user-name x; // error: ; expected // error: cannot find symbol减号被理解成运算符user - name是合法表达式所以编译器会走到找不到 user 和 name 这两个变量那一步报错信息指向的位置和真实原因隔了一层。错误四中文全角符号。int count 1 // 注意最后是中文分号 // error: illegal character: \uff1b\uff1b就是中文全角分号的 Unicode 编码。这个错误信息其实很友好直接告诉你非法字符是什么但新手往往盯着代码看半天看不出区别——全角分号和半角分号在有些字体下几乎一样。看到illegal character先去检查标点。错误五注释嵌套。/* * 说明一 * /* 说明二 */ * 说明三 */编译器在第一个*/处结束注释说明三变成代码*/变成非法符号报一串莫名其妙的错。这个错误的特征是报错行号和你以为的问题位置完全对不上。6. 排错速查与经验清单6.1 编译错误对照表这张表建议存下来遇到红字先扫一眼报错信息关键词常见原因处理方式illegal character: \uff1b用了中文全角分号、逗号、括号切换到半角输入as of release 9, _ is a keyword单独用下划线作标识符加前缀或后缀如_tmpidentifier expected撞了关键字、数字开头、缺标识符检查名字首字符和关键字表cannot find symbol变量名拼错、作用域不对、没导入逐层检查声明位置和拼写class X is public, should be declared in a file named X.java公共类名和文件名不一致改文件名或改类名unclosed string literal字符串引号没成对检查双引号和转义reached end of file while parsing大括号不配对数括号看 IDE 高亮variable x might not have been initialized局部变量没用先赋值声明时初始化non-static variable x cannot be referenced from a static context在main里直接访问实例字段先new出对象再访问incompatible types: possible lossy conversion from long to int大范围赋给小范围显式强转或改类型cannot find symbol是新手遇到最多的一种因为它太笼统了。我的排查顺序是先看报错指的那个名字拼写对不对lenght和length这种再看它的声明是不是在同一个作用域里最后看是不是忘了import。IDE 一般会给出找不到的符号具体是变量、方法还是类按提示分情况处理。6.2 编码与全角符号的隐蔽问题编码问题值得单独拎出来因为它表现形式太诡异了。假设你源文件是 UTF-8但编译时没加-encoding UTF-8javac会按平台默认编码Windows 中文环境下常见是 GBK去读中文字符串就会变成乱码中文注释可能导致语法错误。更麻烦的是如果乱码恰好把/*或*/吃掉了注释的边界就变了报错位置和你看到的代码完全对不上。# 错误示范不指定编码 javac -d out src/com/example/ledger/*.java # 正确写法 javac -encoding UTF-8 -d out src/com/example/ledger/*.java同理javadoc的三个编码参数-encoding、-charset、-docencoding都不能省否则生成出来的 HTML 里中文是一片问号。运行时的控制台输出也可能乱码Windows 下可以临时切一下代码页chcp 65001 java -cp out com.example.ledger.LedgerApp这些设置不涉及任何特殊配置就是标准的字符集指定跨平台开发时记得在构建脚本里固定下来别依赖机器默认值。6.3 我踩过的几个坑坑一以为 IDE 不报错就是对的。有些代码在 IDE 里从没标红因为 IDE 用的是自己的编译器配置和命令行javac可能不一致。我建议新人至少用命令行完整编译运行一次自己的项目把-encoding、-d、-cp这些参数亲手敲一遍出了错你能知道是哪一层的问题。坑二用拼音缩写当变量名。int yh 0;谁也不知道是用户还是银行还是优惠。拼音可以全拼但别缩写缩写的信息量还不如英文单词。userId三个字符就解决的问题不要搞成猜谜。坑三注释里的TODO越积越多。我在一个老项目里见过一百多条// TODO最早的一条是五年前的。TODO本身没问题问题是它没有负责人和时间。我的做法是临时性的标记一律写成// TODO(日期)具体要做什么超过一个迭代没处理的要么转成任务单要么直接删掉。坑四把常量写成public static但忘了final。这不算语法错误编译器不会拦但语义上它就是个全局可变变量任何地方都能改出了问题极难追。写常量时static final要连着敲形成肌肉记忆。坑五过度依赖自动补全。IDE 补全能帮你写出getUserInfoByUserIdAndStatus但你未必想清楚了这么多限定条件是不是真需要。名字越长往往说明这个方法的职责越杂。起名字卡住的时候先想想是不是该拆方法而不是继续加限定词。坑六_的兼容性。如果你的代码要在一个老 JDK 上编译比如某些旧系统的 JDK 8_单独作标识符是合法的但在 JDK 9 及以上会直接报错。同一份代码在两个环境里表现不同这类问题在容器化部署时特别容易暴露因为镜像里的 JDK 版本可能和你本地不一样。写到最后分享一个自己的习惯每次写完一个类我会强制自己读一遍所有标识符问三个问题——这个名字不看我写的代码能猜出含义吗三个月后我还能猜出来吗换个完全不懂业务的人来看最可能误解成什么三个问题过一遍一半的烂名字会被自己揪出来。注释同理我会假装自己是要调用这个方法的人只看文档注释能不能把参数传对。这两步加起来不到五分钟但省下的是后面无数次的沟通和返工。