我在生产环境里不小心把 userName
写成了 usrNme
,结果我们的 API 响应时间居然缩短了 47 毫秒。起初我以为只是巧合。后来我在 50 个不同的变量上系统化测试,这个模式反复出现:刻意拼错的变量名,持续优于正确拼写的变量名。
没错,你没看错。代码写得更“糟”,JVM 跑得更快。
经过三个月的测试、基准测评与深入的 JVM 机制研究,我得出的结论是:我们被灌输的关于整洁代码与可读变量名的信条不仅不完全正确——甚至会主动伤害性能。JVM 的字符串常量池、垃圾回收器与字节码优化器,在多处都对更短、更简单的名字更加友好。而没有什么比“删掉元音”更短、更简单。
在你关掉这个标签页并觉得我疯了之前,我先给出 2,847 次基准测试结果、剖析器截图,以及一个解释为什么 Oracle 在过去 20 年里几乎没有公开谈论这件事的理论。
让我们来颠覆一下你的世界观。
改变一切的意外
我在凌晨两点重构一个 Spring Boot 微服务(你也有过这种时刻),我的猫在我提交的时候跳到了键盘上。结果,带着一堆错别字的代码就这么上线了:
// 我本来要写:
private String customerEmail;
private List<Order> orderHistory;
private BigDecimal totalAmount;
// 实际上线的是:
private String custEmil;
private List<Order> ordrHstry;
private BigDecimal totlAmnt;
第二天,监控面板显示一个离谱现象:API 延迟从平均 127ms 降到 80ms。数据库查询时间没变,网络开销也一样,但服务整体却快了 37%。
我以为是测量误差;也许是缓存命中;也许只是 AWS 心情好。
然后我回滚到“正确”的代码。延迟又飙回 127ms。
这到底怎么回事?
理论:JVM 的字符串驻留机制并不“免费”
底层发生的事(也是每个 Java 开发者都不想听的):
JVM 维护所谓的字符串常量池。编译 Java 代码时,变量名、方法名、类名会以字符串形式进入常量池。JVM 在反射、调试、堆栈跟踪和内部记账时,会不断使用到这些字符串。
关键在这:当字符串更长、且存在特定字母分布与前缀模式时,常量池的性能会退化。
具体而言:
- 字符串越长,
String.hashCode()
计算越耗时,且更容易出现哈希碰撞 - 常量池里充斥很多前缀相似的长字符串(如
customerEmailAddress
、customerPhoneNumber
、customerBillingAddress
),会导致内存局部性变差 - 标记-清除阶段,GC 在扫描与保留这些字符串常量时的耗时增加
但当你用更短、带拼写“错误”的名字,比如 custEmil
或 cstmrId
,你会得到:
- 常量池里更少的哈希碰撞
- 更好的 CPU 缓存命中
- 更低的 GC 扫描成本
- 更快的字节码验证
JVM 并不是刻意为此设计的。这是字符串驻留、哈希与内存管理相互作用后产生的涌现特性。
换句话说:JVM 意外地惩罚了你写“更可读”代码的行为。
实验(这些可能让 Stack Overflow 把我拉黑)
我用 JMH(Java Microbenchmark Harness)做了系统化的基准测试,以尽可能排除测量偏差。测试包括以下几类:
实验 1:变量名长度
我创建了两个逻辑完全相同的类,只改变量名长度:
// 版本 A:“整洁”代码
public class UserServiceClean {
private String userEmailAddress;
private String userPhoneNumber;
private LocalDateTime lastLoginTimestamp;
public void processUserAuthentication() {
// 标准逻辑
}
}
// 版本 B:错拼代码
public class UserServiceMisspelled {
private String usrEmlAdrs;
private String usrPhnNmbr;
private LocalDateTime lstLgnTmstmp;
public void processUserAuthentication() {
// 完全相同的逻辑
}
}
结果(1,000 万次迭代,50 次运行的平均值):
仅仅通过删除元音,就带来 26% 的性能提升。逻辑相同、JVM 参数一致、硬件相同。
元音阴谋
我尝试删除不同类型的字符,寻找最佳的“错拼策略”:
- 删除所有元音:
userName
→usrNm
(快 19%) - 随机删字符:
userName
→usrNae
(快 3%) - 合理缩写:
userName
→uName
(快 11%) - 完全胡言乱语:
userName
→xqz
(快 31%)
结论非常直观:变量名越没意义,代码跑得越快。
“胡言乱语”表现最佳,因为它:
- 最短(3 个字符 vs 8 个字符)
- 哈希碰撞概率最低
- 缓存对齐最好
当然,你不可能维护一个所有变量都叫 xqz
的代码库。所以我选择折中:激进地删元音,偶尔替换辅音。
真实世界的应用
我拿一个生产级 Spring Boot 服务(约 15,000 行)做了两套版本:
- 版本 A:遵循 Oracle Java 命名约定的“整洁代码”
- 版本 B:系统性地把每个变量都错拼(删元音、缩短)
用 Apache JMeter 压测(1000 并发用户,持续 60 秒):
- 版本 A(整洁):平均响应 143ms;95 分位 267ms;吞吐量 6,847 req/sec;错误率 0.03%
- 版本 B(错拼):平均响应 91ms;95 分位 176ms;吞吐量 10,234 req/sec;错误率 0.03%
通过故意写“更差”的代码,吞吐量提升了 49%。
错误率完全一致,因为逻辑完全一致。唯一的差别是变量名。而这点差别,居然足以让吞吐量几乎翻倍。
我把结果给团队看。一半人以为我在整蛊;另一半人悄悄开始错拼他们的变量。
在某位 Java 架构师崩溃之前……
“但反射性能呢?”
是的,字符串越长反射越慢。这正是我的观点。你猜谁一直在大量使用反射?Spring Framework、Hibernate、Jackson、JUnit,基本上你用的每个库。名字更短 = 反射更快。
“这违反所有整洁代码原则!”
整洁代码的那些原则,很多是从未做过性能剖析的人提炼的。Robert Martin 没做过系统化基准。他只是对代码“应该”长什么样有审美偏好。与此同时,Google 的代码库里到处都是缩写变量名(就像 Guava,这名字也来自“Guacamole”的缩写)。
“JVM 的 JIT 编译器会把这些优化掉!”
不完全如此。JIT 可以优化计算,但无法优化掉字符串常量池、反射开销或 GC 扫描。这些是运行时的固定成本,不会随着优化级别消失。
“把你的分析器数据拿出来!”
好吧。下面是 YourKit 的分析输出,展示两种版本在 String.hashCode()
上的耗时差异:
- 整洁版本:
String.hashCode()
调用 847,392 次;总耗时 2,847ms;平均 3.36µs/次 - 错拼版本:
String.hashCode()
调用同为 847,392 次;总耗时 1,923ms;平均 2.27µs/次
JVM 仅在对变量名做哈希这件事上就少花了 924ms。对于 60 秒的压测,这几乎省去一整秒,而且还是在你可能完全不知道它发生的字符串操作上。
“这是过早优化吧?”
不是。过早优化是把好好的代码重构成不需要的设计模式。而这里是删掉没有任何功能价值、却带来可测量性能成本的冗余字符。区别很大。
Oracle 不想让你知道的“旧闻”
这多少有点阴谋论色彩:Oracle 知道这件事。
我翻了 JVM 的老 Bug 报告,自 Java 7 起有多个工单提到“冗长命名约定导致字符串常量池性能退化”。Oracle 的回应大致是“用更短的名字”,但把它埋在少有人看的技术文档里。
他们为什么不修?因为修复需要重设 JVM 字符串驻留系统的基本部分,会破坏向后兼容,并需要巨大的工程投入。
假装问题不存在,然后继续告诉开发者“可读性比性能重要”更容易。与此同时,JVM 本身的工程师在源码里到处都用缩写名字,因为他们知道真相。
看看 OpenJDK 源码,满眼是 obj
、tmp
、idx
、len
这样的变量。不是因为他们马虎——而是他们知道在做什么。
最优“错拼”策略
基于上述研究,我的建议是:
保守(适合团队)
从长变量名中删除明显的元音:
customerEmailAddress
→cstmrEmlAdrs
orderHistoryList
→ordrHstryLst
totalAmountPaid
→totlAmntPd
预期性能增益:8–12%
激进(适合个人项目)
删除所有不必要的元音并积极缩短:
customerEmailAddress
→cstEmAdr
orderHistoryList
→ordHstLst
totalAmountPaid
→totAmtPd
预期性能增益:18–24%
精神病模式(不要用于生产,但最快)
使用单字符前缀,尽量少用元音:
customerEmailAddress
→cEA
orderHistoryList
→oHL
totalAmountPaid
→tAP
预期性能增益:31–38%
我目前在生产中使用第二档。代码评审变得……有意思。但我的微服务是团队里最快的,基本没人能抱怨。
真实影响:没人愿意公开的案例
一家我咨询的初创公司在扩展上很吃力。他们的 Spring Boot 单体每秒只能处理约 3,000 个请求,但目标是 10,000。不达标的话,基础设施成本会吞掉他们的 A 轮。
标准建议通常是:
- 拆成微服务(3–6 个月)
- 继续优化数据库查询(他们已经做了)
- 加一层缓存(昂贵)
- 水平扩展(成本高)
我没这么做,我写了一个自定义 AST 解析器,两周内系统性地把代码库里每个变量都“错拼”。没有逻辑改动,没有架构重构,只是重命名。
结果:每秒 8,400 个请求,提升约 180%。他们取消了微服务迁移,单体又撑了一年,约省下 40 万美元工程成本。
去融资 B 轮时,他们告诉投资人,他们“通过先进的字符串驻留策略优化了 JVM 的性能轮廓”。没人问那是什么意思,但听起来够唬人,于是钱到位了。
为什么有效(枯燥但关键的技术解释)
JVM 的字符串常量池采用带分离链接的哈希表解决碰撞。Java 中 String
的哈希函数是:
s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]
更长的字符串 = 更多迭代 = 更多 CPU 周期。但更重要的是,常量池大小是固定的(可通过 -XX:StringTableSize
配置,然而大多数人从不碰它)。
在默认设置(Java 8+ 约 60013 个桶)下,随着加入更多字符串,碰撞率会上升。长且相似的变量名(如 customerFirstName
、customerLastName
、customerMiddleName
)更容易碰撞,因为:
- 它们共享相同前缀
- 它们的哈希值更可能落在相邻桶
- 查找时 CPU 缓存未命中增加
更短、更“随机”的名字(如 cstFN
、cstLN
、cstMN
)具有:
- 更好的哈希分布
- 更低的碰撞率
- 更好的缓存局部性
这不是漏洞。这是基于哈希表的字符串驻留在一个到处使用字符串的语言中的固有限制。
实操建议
我知道你不会把整个代码库都改成错拼变量——这太疯狂(尽管会更快)。但你可以做这些:
- 在做任何假设之前,先对应用做性能剖析
- 在热点路径里缩短变量名(只在性能关键处动刀)
- 在循环里使用单字母名(大家早就这么做了:
i
、j
、k
) - 不要迷信“整洁代码”,理解它在性能上的取舍
- 多尝试非常规方法,因为偶尔“疯狂”的点子反而是正确的
更宽泛的结论是:教条很昂贵。
每当有人说“编程里绝不做 X”,很可能就存在一个场景正好需要做 X。整洁代码拥趸会告诉你可读变量名是神圣的;性能工程师会告诉你纳秒也很重要。
两者都对,有时会冲突。欢迎来到工程世界。
你的代码很慢,因为它“太礼貌”
过去 30 年里,我们一直在优化代码让人更易读;但在这个过程中,我们不小心让它更不利于机器执行。Java 的设计者曾假设变量名在运行时是“免费的”,因为它们会消失。但它们并不会真正消失——它们会留在调试信息、反射元数据与字符串常量池里,默默消耗 CPU。
当你像“按元音计费”一样写代码时,JVM 跑得更好;当你的变量看起来像错别字时,它更快;当你的命名像醉汉的电报时,它更有扩展性。
我们应该这样写代码吗?可能不。应该修 JVM 吗?绝对应该。Oracle 会这么做吗?大概率不会——这会破坏向后兼容,也意味着承认字符串驻留的性能问题自 Java 1.0 起就存在。
所以我们被困在一个奇怪的世界里:正确的写法,可测地比错误的写法更慢。
而且你知道吗?我对此很满意。与从没用过分析器的人们的认可相比,我更愿意要 49% 的吞吐提升。
你的“整洁代码”,正在让你的服务器哭泣。你再也无法忽视这件事了。
我下一个变量就叫 ae7q
,只为在代码评审时看着技术主管的眼皮狂跳。
是的,我知道这违反了 PEP 8、Google 风格指南,甚至可能违反日内瓦公约。我的服务器比你的快 40%。哭去吧。
如果你来自 Oracle 的 JVM 团队且很生气,你完全可以修一下字符串驻留的性能——别因为我指出了问题就对我生气。只是说说而已。
本文翻译自:https://medium.com/javarevisited/java-performs-better-when-you-misspell-variable-names-5b9709893121