大多数Java项目的混乱,不是因为程序员技术差,而是因为规范清单太懂事,总想讨好所有人。我在日常项目的git历史里翻来覆去,见过太多用良好意图堆出来的烂代码。没有经过线上教训的规范都是纸面优雅——真正生效的规约,不是从《Java开发手册》抄来的条文,而是从降级、告警、事故补偿里熬出来的刺。下面这份实践清单,来自生产环境的深坑、评审总吵不赢的对话,以及深夜调试时想砸电脑的瞬间。
有次评审会上,一个同事反驳我:“我们代码里有规范,只是没人遵守。”我说,问题恰恰出在这里。如果规范只靠人自觉,那它还只是一个愿望。当一条编码规范无法被工具检查出来的时候,它就只能依赖少数人的记忆和所有人的心情。所以这里总结的每一条,都是能在代码评审中当判断依据、能在日常项目中落地的可操作实践。那就从最不起眼的命名说起。
命名:越短越危险
tmp、data、s、ret,这些名字在项目里极常见。写的人觉得理所当然,读的人却要在代码上下文里做侦探。我曾在一个Bug里追了半小时,发现元凶是一个result变量在循环中被错误复用。一个叫result的变量毫无信誉可言,它谁的表现都代表不了。变量名里藏着对读者最基本的诚实:customerName不要缩写为cn,pendingOrderIds不要叫ids。单词长一点编译器不会嫌弃,但下一个维护者会因此少骂你一次。
命名还有一条隐性法则:不要让布尔变量自带否定。Boolean notFound、Boolean disableFlag这类名字,会让if(!notFound)变成脑筋急转弯。如果命名里出现了否定,请把值的含义也改成肯定。只有isFound和enabled,代码才能读作人话。命名规范不是洁癖,而是降低整个团队的认知负荷。
方法签名里的布尔陷阱
比命名更容易引爆项目现场的,是参数列表里的裸布尔值。processOrder(order, true),看到这一行的人都会疑惑:这个true是强制跳过校验,还是要求后台静默执行?作者本人也许记得,但三个月后他也会忘。一个携带布尔参数的方法,往往已经偷偷违反了单一职责原则。它试图把两条业务路径压在同一根水管里。
解决方式不是写注释,而是拆方法。把processOrder(order, true)拆成processOrder(order)和processOrderIgnoringValidation(order),调用点立刻变成句子而不是谜语。更不要提new Shipment(1, true, false)这种多个布尔参数并列的构造器。除非你希望每个维护者都去排列组合并祈祷顺序正确,否则请用枚举或枚举集合来表达配置项。方法签名的可读性,比作用域的微优化重要一万倍。
异常处理:吞掉异常是最贵的甩锅
稍微有点经验的人都知道不要写空catch块,但项目里仍然遍布着一种“伪处理”。最常见的写法是捕获异常后打印一行日志,继续往下走,业务却停在错误状态。程序虽然没有崩,但退款状态没更新、消息没重发、事务没补偿,最终用户投诉涌向客服,而日志里只有一句孤零零的error。捕获一个异常却没有任何状态恢复与流程切换,等于给系统埋下一颗随时间引爆的地雷。处理异常必须做出决策:要么向上抛出让调用方兜底,要么执行补偿逻辑,把业务修改为“失败”状态。没有后续动作的catch,都是把麻烦甩给下一个值班人的甩锅现场。
还有一个更隐蔽的问题是捕获面过宽。catch (Exception e)会同时接住NullPointerException、SQLException、InterruptedException,而你的处理逻辑通常只能正确应对一种。如果无法处理某种异常,就不要捕获它;如果无法区分异常场景,说明方法边界根本没有设计过。我给自己定的规矩是,在异常链路的每一层都写明它处理什么、处理后做什么动作;凡写不出动作的catch,直接删掉。
返回空集合,而不是null
日常项目里最频繁的NPE来源,不是外部接口,而是自己写的查询方法返回了null。为了防它,调用方套上层层判空,代码看起来像洋葱。返回一个null,等于把处理NPE的刑期转交给下一个调用者。方法声明是getOrderList(),结果却可能返回null,这不仅违背直觉,也是在把隐性契约强加给每一个下游开发。
正确做法很简单:没有数据,就返回Collections.emptyList()或List.of();不存在的最多是一个Optional,而不是null。我在一次库表查询清理中统计过,所有返回null的地方,最终调用方都把它们当空集合处理,也就是说,那些null从未表达过特殊语义,只生产了防御分支。凡是能用空集合、空字符串表达的场景,都没有资格使用null。就这一条,足以删除整条调用链上一大半无效判断。
日志:别用噪音掩盖信号
很多开发者以为打印了日志就等于有了可观测性。真实项目里常见的是log.info("用户点击订单"),没有用户ID、没有订单号、没有操作结果。一旦出问题,日志平台里躺着的全是这种没有坐标的信息。一条没有上下文的日志,和一张没有坐标的地图一样没有用处。记录日志要有“谁、对哪个对象、做了什么、结果如何”,最好再带上耗时。而如果对象的toString()没有打印关键业务字段,那么打一个对象往往等于打了一句废话。
另一面是日志过多。有人把循环里的中间状态全部打为info,一个接口跑十分钟就能刷出数百MB日志,真正的错误被淹死在噪声里。日志级别配错本身就是代码缺陷:需要人注意的进warn/error,常规业务轨迹留在debug。对核心路径统一加关键词,以后在日志平台里按关键词搜,才能更快看到那根刺。
集合与循环:别让复杂度偷偷升到平方级
Java对集合的误用,最典型的是循环内list.contains(target)。list越长,循环越臃肿,本来以为O(n)的算法,瞬间变成了O(n²)。我曾经把一个定时任务里某段循环的contains改到基于HashSet的判断,耗时从5分钟降到了3秒,而代码逻辑几乎没动。循环体内调用contains,是和团队一起为平方级时间复杂度做贡献。选择集合不是顺手牵羊的事。有序就找List,去重就找Set,映射就找Map;用List来承担一切,是热情有余但思考不足。
与它配套的还有一个防不胜防的坑:对外暴露不可变集合。有人用Collections.unmodifiableList包装后返回,但没有在接口类型或文档里体现,调用方忍不住add一下,然后收获一个意外异常。如果要向外暴露集合,要么返回拷贝,要么用类型明确它是只读的。别让调用方猜,也别让异常在毫无准备的时候跳出来。
并发:共享可变状态都是定时炸弹
日常项目的并发bug,百分之八十不是来自复杂算法,而是来自简单的复合操作。ConcurrentHashMap看起来线程安全,但if (!cache.containsKey(key)) { cache.put(key, load(key)); }仍然会被并发撕裂,因为两步操作之间没有任何原子性保障。ConcurrentHashMap解决的是单操作线程安全,不是多操作业务流程的原子问题。遇到这种场景,请使用computeIfAbsent。这种建议说了几百次,可代码里依然有无数个重复执行加载的漏洞在等待真正的并发高峰。
另一个低级但真实存在的情况是,把锁加在一个局部变量上。局部变量每次调用都不同,于是synchronized形同虚设。锁对象的身份必须全局唯一,否则临界区就只是一句礼貌的祝福。要想规范它,最简单的办法是明确锁属于哪个业务实体,比如静态锁对象、客户端id分片的ConcurrentHashMap锁,或直接使用显式的ReentrantLock。不要自以为给代码加了锁,就等着它安全运转。
线程池:别把异步任务当柴火
“不要手动new Thread”这句规矩已经刻在很多人的键盘上了,但仍有人图省事,在方法里new Thread(() -> doSomething()).start()。这种代码像是一次性打火机:用完就丢,一旦请求量增大,系统会在内存里点起一堆无法管理的线程。线程池是所有异步任务的公共财产,不是随便丢的一次性筷子。即使要用Executors.newFixedThreadPool,也务必想清楚队列长度、线程名与拒绝策略。
更关键的是拒绝之后的兜底。线程池满了以后,默认的AbortPolicy会抛RejectedExecutionException,如果你没有在提交入口catch住,这个任务就莫名消失。一个没有兜底拒绝策略的线程池,是系统里最沉默的数据黑洞。项目里至少要有一个统一的提交入口,把拒绝异常转为告警或降级信号,而不是让它闷声沉底。没有重试策略的丢任务,行为上跟吞钱没有区别。
测试:先骗过自己,再谈覆盖
有些项目的测试类长得像一份购物清单:先调方法,然后没有断言,也不校验行为,只是期待它“不崩溃”。一个不加断言的测试,只是给代码穿了一件皇帝的新衣。测试需要说清楚:输入什么、输出什么、状态怎么变。尤其是订单状态机这类核心逻辑,边界、重复操作、非法流转都必须断言。否则一到重构,测试全绿,功能全崩,你甚至不知道该信谁。
Mock也不可过度。当你把几乎所有协作对象都mock掉,测试实际测的是自己的模拟器,而非系统的真实接线。依赖注入带来的可测试性,不应该成为回避集成问题的借口。对关键链路,至少保留一个接入内存数据库的测试,真正跑一遍支付、回调、入库的完整流程,才能拦住“日志显示成功,数据库没反应”的恐怖情况。
依赖管理:升级有成本,冲动有代价
Java生态里最无争议的真相是:依赖的每一次升级,都伴随着隐式的行为变更。版本从1.2.3跳到1.2.4,看似补丁,但它可能悄悄改变了某个序列化行为或线程调度的默认参数。没有看release note就升级依赖,相当于在不知道副作用的前提下改了一颗心脏用药的剂量。项目里应该有规矩:升级第三方库的commit必须附上“升级原因+影响范围+回归测试结论”,否则评审直接打回。
同时,对于提供相似功能的工具包,不要多套混用。你用commons-lang3写字符串工具,他用guava写集合判断,功能没冲突,但依赖树膨胀以后,安全漏洞的责任范围也跟着模糊。同一个功能家族只保留一个直接依赖,不是代码洁癖,是升级与漏洞管理的基本前提。
评审与格式:把意志力留给业务逻辑
很多团队把代码格式当成评审专项,人肉检查该不该加空格,导致评审中一半的评论都像在跟编译器较劲。用自动格式化工具与静态检查的团队,才有资格讨论代码风格;手动统一的团队,永远在激烈争吵。在CI里加入Spotless或Checkstyle,让机器决定什么叫做“整齐”,评审者就能把注意力献给真正重要的语义和架构。跑不过格式检查的分支不允许合并,这不是苛刻,是帮每个人减少无意义的成本。
同样,评审不是为了证明自己比写代码的人聪明。最高效的评审往往是一句话:“这个分支真的存在吗?” 如果一个改动需要作者在旁边解释五分钟别人才能看懂,那这段代码应该换一条更清晰的路。让每次代码修改只暴露真正要解决的问题,评审就不再是一场辩论赛。因为未来接手的那个同事,不仅看不到你的解释,还要在一个没有你的凌晨替你把Bug修好。
编码规范不是静态的教条
还有一件必须认清的事:上面这些实践清单,也只是当前技术阶段的一个切片。当团队从单体演进到微服务,从同步RPC变成异步消息,旧有的规范会陆续失效。比如原来强调“方法不要超过50行”,在复杂状态机里可能行得通,但在一个数据密集型流水线里,也许更重要是“别在事务里做外部调用”。编码规范的本质,是把团队踩过的坑重新标注成地图,而不是约束灵感的栅栏。地图要随地形更新。
所以,我建议每个团队都有自己的“事故驱动清单”:每出现一次线上故障,就把根因缩短成一条可执行的编码规则;每过半年,删掉那些已经成为肌肉记忆的条目。在漫长的软件生命周期里,好代码不依赖某位大神的英明,而依赖这条清单不断更新。规范不是镣铐,而是帮你挡下明枪暗箭的盾牌。下一次当你在代码评审里看到一团雾一样的实现时,把它写进这份清单,然后笑着对下一个提交者说:这里有一条我们付出过代价的规矩。