Java 经济代码:从线程同步、内存管理到异步与可伸缩性的性能工程实践
Java 经济代码:从线程同步、内存管理到异步与可伸缩性的性能工程实践
所谓“经济的代码”,并不是把每一行代码都压榨到极致,也不是看到 synchronized、对象创建或集合就条件反射式地“优化”。它讨论的是一个更完整的问题:
在满足真实需求的前提下,让软件用尽可能少的代码复杂度、线程协调、内存、CPU、I/O、状态和维护成本,完成尽可能多的有效工作,并为未来的规模增长留下空间。
如果把这套方法进一步压缩,可以得到一组非常工程化的关键词:
少做、少等、少建、少拷贝、少持有状态、少重复编码;能共享就安全共享,能异步就避免无意义等待,能水平扩展就不要把状态锁死在单机上;所有性能判断最终都要回到测量。
这几条并不是彼此独立的“小技巧”。它们实际上构成了一条完整的性能成本链:
flowchart LR
A[需求与设计] --> B[代码复杂度]
B --> C[对象与内存]
B --> D[线程与同步]
B --> E[I/O 与等待]
C --> F[GC 与内存带宽]
D --> G[锁竞争与上下文切换]
E --> H[阻塞与资源闲置]
F --> I[吞吐量与延迟]
G --> I
H --> I
I --> J[单机容量]
J --> K[规模扩张成本]
A --> L[复用与维护成本]
L --> K
本文围绕这条链路,把线程同步、不可变设计、内存使用、延迟分配、异步 I/O、JMH、字符串与哈希性能、资源泄漏、水平扩展、代码复用以及性能评审清单组织成一套可以直接用于 Java 工程实践的知识体系。
版本说明:文中的不少例子来自 JDK 7、8、10、11 时代的实现与测试。JDK 内部实现会持续演进,所以涉及集合初始容量、锁优化、JIT 行为和绝对性能数字时,应以目标 JDK 的源码与实测结果为准。本文保留这些历史例子的价值,但不把某个版本的实现细节当成永恒规则。
1. 为什么性能问题要从需求和设计阶段开始
性能并不只是“代码跑得快”。
经济代码至少影响四类成本。
1.1 用户体验
一致、稳定的响应时间,本身就是产品体验的一部分。一个功能再丰富,如果用户经常面对卡顿、长尾延迟和不可预测的停顿,最终体验仍然会变差。
1.2 研发成本
复杂度越高,需要编写、测试、理解和维护的代码越多。过度设计、重复实现、过多状态、过度同步,最终都会转化成研发成本。
这意味着性能优化最早可以从一句很朴素的问题开始:
这个功能现在真的需要做吗?
需求能被删除,相关的代码、测试、对象、网络调用、状态维护和后续兼容成本都会一起消失。没有哪一种 JVM 参数能打败“不执行这段逻辑”。
1.3 运行成本
内存、CPU、线程、网络和磁盘最终都会转化成机器成本与云资源成本。对高频路径而言,一个看起来微不足道的对象分配或锁竞争,乘以每天数亿次调用之后,就不再微不足道。
1.4 可用性与安全
如果处理一个请求需要消耗大量 CPU、内存或线程,那么攻击者不需要发送非常多的请求,就可能耗尽系统资源。
因此,性能问题和安全问题并不是完全分离的。资源消耗越不可控,越容易形成 DoS 类的可用性风险。
2. 线程同步:最高级的优化往往是避免同步
多线程带来两个天然特征:
- 多个执行流可以并发运行;
- 多个线程可以共享同一个进程中的资源。
真正麻烦的是第二点。
如果多个线程只读取一个永远不变的值,通常没有协调问题;如果多个线程会观察并修改同一份共享状态,就进入了同步问题。
可以把“是否需要同步”拆成三个条件:
- 存在两个或更多线程;
- 多个线程关心同一个共享状态;
- 至少有线程会改变这个共享状态。
三个条件同时成立,才真正形成需要协调的共享可变状态。
因此,避免同步也有三个方向:
- 不使用多个线程处理这段状态;
- 不共享;
- 不修改。
从工程角度看,第三条尤其重要:把共享可变状态变成共享不可变状态。
2.1 为什么同步会损失性能
同步本质上是在并发路径上建立顺序约束。
线程进入临界区之前可能要等待;锁的获取与释放本身也需要成本;竞争严重时还会出现线程挂起、唤醒、调度和缓存一致性开销。
所以性能优化不能只问:
这个锁能不能换成更快的锁?
更应该先问:
这里为什么一定需要共享可变状态?
如果状态根本不需要变化,整个同步问题都可以消失。
3. 不可变设计:同时降低同步成本和内存管理复杂度
下面这个类看起来非常普通:
1 | |
在单线程中没有明显问题,但多个线程并发读写时可能观察到不一致状态。
例如:
- 线程 A 读取到
language = "English"; - 线程 B 把
greeting改成"你好"; - 线程 A 再读取
greeting,得到"你好"。
最终线程 A 观察到了逻辑上不一致的组合。
更好的设计是让对象在构造完成后不再变化:
1 | |
这段设计有几个连锁收益:
- 状态在构造阶段一次性确定;
- 后续没有 setter;
- 多个线程可以安全共享同一个实例;
- 不需要为读取操作增加锁;
- 调用者更容易理解对象生命周期。
3.1 final 很重要,但不要把它等同于“深度不可变”
final 的含义是变量只能被赋值一次。对于引用类型,它保证的是引用不能重新指向另一个对象,并不意味着引用指向的对象内部不能变化。
例如:
1 | |
因此,一个真正的不可变类通常还需要:
- 字段在构造后不再改变;
- 不暴露修改内部状态的方法;
- 对可变入参进行防御性复制;
- 对可变字段不要直接返回内部引用;
- 引用的对象本身也应该不可变,或者在边界上进行复制。
不可变是一种对象语义,不是单靠一个关键字自动获得的属性。
3.2 缩小临界区,比“给整个方法加锁”更重要
如果同步无法避免,第二条原则就是:
只同步真正修改共享状态的部分。
假设一个操作由“准备 -> 修改共享状态 -> 收尾”组成:
1 | |
如果只有 addBubbles() 会操作共享资源,那么更合理的写法是:
1 | |
这里的核心不是语法,而是临界区设计:
flowchart LR
A[线程私有准备] --> B[最小临界区]
B --> C[线程私有收尾]
进入锁之前能完成的工作,不要放进锁;离开锁之后能完成的工作,也不要留在锁里。
这会直接缩短其他线程的等待时间。
4. 内存优化只有两个基本方向:减少实例数量,减小实例尺寸
Java 有垃圾回收,并不意味着应用程序不需要关心内存。
对象越多,意味着:
- 分配次数更多;
- 初始化和填充更多;
- 引用关系更复杂;
- GC 扫描与回收压力更大;
- CPU Cache 和内存带宽压力也可能更高。
从应用代码角度看,减少内存使用可以归结成两个方向:
- 减少实例数量;
- 减小实例尺寸。
很多所谓“内存优化技巧”,本质上都能放回这两个分类中。
5. 减少实例数量:先消灭不必要的对象
5.1 有限且固定的数据,优先静态化
如果语言和问候语的组合是固定的,而且数量有限,每次调用都 new 一个对象没有意义。
可以使用枚举:
1 | |
枚举天然适合:
- 有限状态;
- 有限类别;
- 固定映射;
- 全局共享且不可变的值对象。
它既减少对象数量,也减少对象创建和 GC 压力。
5.2 避免没有意义的构造
经典反例:
1 | |
如果只是需要字符串常量,直接写:
1 | |
前者人为创建额外对象,后者可以复用字符串常量池中的对象。
5.3 高频计算优先使用 primitive,而不是包装类型
下面的代码把累加变量写成 Long:
1 | |
更经济的写法是:
1 | |
包装类型会引入自动装箱/拆箱、额外间接访问,并且还可能带来 null 语义。
需要注意,自动装箱并不等价于“每次一定 new 一个对象”,JDK 对部分包装值存在缓存和其他优化。但在高频数值计算中,使用 primitive 仍然通常更直接,也更容易获得稳定性能。
5.4 单实例不是目的,减少重复实例才是目的
如果一个对象在逻辑上全局唯一、不可变或生命周期与应用一致,可以使用单实例模式。
例如:
1 | |
但不要为了“设计模式”而使用 Singleton。
更值得优先考虑的是:
- 这个对象是否真的只有一个逻辑实例?
- 它是否持有可变全局状态?
- 它是否会影响测试隔离?
- 能不能用依赖注入管理生命周期?
- 对于有限常量集合,是否直接用
enum更自然?
6. 减小实例尺寸:少独占,多共享
一个对象的成本不仅是字段本身,还包括它引用的其他对象。
因此,减小实例尺寸的常见思路是:
- 少持有重复数据;
- 少复制;
- 复用安全的共享对象;
- 对重复字符串、配置、元数据进行静态化;
- 让共享资源尽量不可变。
可以把原则记成一句话:
能引用就不要复制,能复用就不要新建——前提是共享不会破坏正确性。
这里的前提非常重要。对于公共 API、可变入参和安全边界,防御性复制仍然是必要的。
6.1 immutable 与 unmodifiable 不是一回事
不可变对象(immutable)意味着对象状态本身不会改变。
不可修改视图(unmodifiable view)只意味着通过当前引用不能修改,底层对象仍可能被其他引用改变。
例如:
1 | |
如果需要一个更接近“快照”的不可修改集合,可以使用:
1 | |
List.copyOf() 与 Collections.unmodifiableList() 的语义并不相同:
| 方案 | 语义 |
|---|---|
Collections.unmodifiableList(list) |
对原列表的不可修改视图,底层列表变化可能反映到视图 |
List.copyOf(collection) |
创建不可修改列表语义的快照;实现可在安全条件下复用已有不可修改实例 |
因此,“禁止修改”可以降低共享维护成本,但不能自动等同于“深度不可变”。
7. 大对象处理:复用固定缓冲区,而不是一次性把所有数据塞进内存
内存经济还体现在数据处理方式上。
例如给一个大文件做数字签名,并不需要一次性读取整个文件:
1 | |
这里的关键不是 2048 这个数字本身,而是:
- 分块读取;
- 重复使用同一块缓冲区;
- 避免按文件大小线性增长的内存占用。
缓冲区大小是 I/O 次数与内存占用之间的权衡。过小会增加系统调用和 I/O 次数,过大又会增加常驻内存。
因此,缓冲区大小要测试,不要迷信某个经验值。
8. 延迟分配:不是“越晚初始化越好”
延迟分配(lazy allocation)的核心是:
不到真正需要的时候,不分配资源。
这对高频创建、但多数实例不会真正使用某个重资源字段的类非常有效。
8.1 ArrayList 的历史例子
JDK 7 时代的 ArrayList() 默认构造会立刻创建一个默认容量数组。JDK 8 的实现改成先共享一个空数组,在第一次真正添加元素时再分配容量。
可以把思路简化成:
1 | |
资料记录的历史性能测试中,这项改动曾带来约 13% 的内存使用下降和 16% 的平均响应时间改善。
这个数字不能泛化到其他程序,但它说明了一个非常重要的事实:
高频类中的微小分配行为,乘以巨大的实例数量之后,可能产生明显的系统级影响。
8.2 默认策略仍然应该是“简单优先”
延迟初始化会引入额外分支:
1 | |
如果进入多线程环境,事情会更复杂。
所以更合理的规则不是“能 lazy 就 lazy”,而是:
- 初始化便宜:直接初始化;
- 对象几乎一定会用到:直接初始化;
- 初始化昂贵且经常不会用到:考虑延迟初始化;
- 延迟初始化引入的同步复杂度超过收益:放弃。
复杂方案只有在收益可证明时才值得使用。
资料中还讨论了
HashMap默认构造何时开始采用延迟内部数组分配。此类 JDK 内部版本细节容易变化,实际工作应直接查看目标 JDK 源码。真正应该记住的是设计原则,而不是版本号。
9. 双重检查延迟初始化:volatile、局部变量和同步分别解决什么问题
一个典型的延迟初始化代码如下:
1 | |
这段代码包含几个容易混淆的点。
9.1 为什么需要 volatile
延迟初始化的共享引用需要被其他线程正确观察。
volatile 解决的是这个共享引用的可见性与有序性问题,保证对象发布过程满足 Java Memory Model 的要求。
注意:
volatile修饰的是helloWordsMap这个引用变量,不会自动把Map内部的所有操作变成线程安全。
所以代码后续还使用 ConcurrentHashMap 来处理 Map 自身的并发修改。
9.2 为什么第一次检查不加锁
对象初始化之后,大多数调用只需要读取已存在对象。
如果每次都进入 synchronized,延迟初始化就给所有后续调用永久增加了锁成本。
第一次检查用于快速路径:
1 | |
9.3 为什么锁内还要检查一次
假设两个线程都在第一次检查时看到 null。
线程 A 先进入锁并完成初始化。线程 B 随后拿到锁,如果不再检查,就会重复初始化。
因此锁内必须再次读取共享引用并判断。
9.4 为什么使用局部变量 temporaryMap
helloWordsMap 是 volatile 共享变量,对它的访问具有额外内存语义。
局部变量只属于当前调用线程,不需要跨线程同步。
把共享引用先保存到局部变量,可以减少对 volatile 字段的重复访问:
1 | |
这里还有一个重要前提:
这种优化适合“共享引用初始化后基本不再替换”的场景。
如果引用会反复切换到不同对象,长期缓存局部引用就可能产生语义问题。
10. 异步编程:优化的是等待期间的资源利用率
同步代码遵循:
1 | |
异步代码更接近:
1 | |
异步的价值不是让某个网络请求凭空从 100 ms 变成 10 ms,而是:
当一个任务正在等待网络、磁盘或其他外部事件时,不让昂贵的执行资源跟着一起闲置。
10.1 从“过程”切换到“事件”
同步程序更习惯描述过程:
连接服务器,读取响应,然后处理结果。
异步程序更习惯描述事件:
发起请求;当响应到达时,执行处理函数。
JDK 11 的 HttpClient 可以直接表达这种模型:
1 | |
真正的差异是:
sequenceDiagram
participant App as 应用线程
participant IO as I/O 系统
participant Callback as 后续阶段
App->>IO: 提交请求
IO-->>App: 立即返回控制权
App->>App: 继续其他工作
IO-->>Callback: 数据就绪
Callback->>Callback: 处理结果
10.2 异步并不等于“没有线程”
底层实现可能依赖:
- 操作系统异步 I/O;
- 事件通知机制;
- 线程池;
- Completion/Future;
- Reactor/Event Loop。
异步 API 的一个重要价值,是把部分线程调度细节封装起来,让业务代码围绕事件与结果组织。
10.3 异步 I/O 适合大量等待型连接
传统的“一连接一线程”模型中,连接数增长会直接推高线程数。
事件驱动模型可以让少量执行线程管理大量处于“等待数据”状态的连接,在高并发 I/O 场景下减少线程管理压力。
但“每个 CPU 一个线程就一定够”不能当作固定公式。实际线程数仍然取决于:
- 回调逻辑是否 CPU 密集;
- 是否存在阻塞调用;
- 连接数和消息速率;
- 后端依赖;
- GC;
- 操作系统和网络栈。
10.4 现代 Java 的补充:虚拟线程改变了选择空间
虚拟线程降低了“阻塞式代码占用平台线程”的成本,因此很多 I/O 型业务可以继续使用直观的同步写法,同时获得更好的并发能力。
这并不意味着事件驱动模型失效。
当系统需要:
- 明确背压;
- 极高 fan-out;
- 复杂事件流;
- 大量协议状态机;
- 已有 Reactive 生态;
异步/事件驱动仍然有价值。
真正应该选择的是更符合业务和运行模型的并发抽象,而不是追逐“异步”或“同步”标签。
11. Direct Buffer 与“零拷贝”:不要把两个概念混为一谈
异步网络代码中常见:
1 | |
Direct Buffer 可以让 JVM 之外的 native I/O 更直接地使用这块内存,减少某些 Heap Buffer 到 native buffer 的中间复制。
但需要特别区分:
ByteBuffer.allocateDirect()不等于严格意义上的零拷贝。
真正的 OS 级零拷贝通常还涉及 sendfile、FileChannel.transferTo()、内核页缓存与网卡之间的数据路径优化。
Direct Buffer 的权衡也很明确:
- 分配和释放通常比普通堆对象更昂贵;
- 更适合生命周期较长、数据量较大的 I/O 缓冲区;
- 应尽量复用,而不是每个小请求都频繁创建。
因此,优化目标仍然是同一条原则:
减少分配、减少复制、延长高成本缓冲区的有效复用周期。
12. 性能优化不能靠感觉:用 JMH 建立微基准
很多 Java 性能结论容易被以下因素干扰:
- JIT 编译;
- 常量折叠;
- 死代码消除;
- 逃逸分析;
- GC;
- CPU 预热;
- 内联;
- 锁消除;
- 不同 JDK 实现。
因此,直接用 System.nanoTime() 包一层循环,往往得不到可靠结果。
JMH(Java Microbenchmark Harness)就是为 JVM 微基准设计的工具。
一个经典的 Maven 项目生成方式是:
1 | |
编译并运行:
1 | |
12.1 JMH 的真正价值不是“跑一个数字”
更有价值的场景是做 A/B 对比:
StringBuilder与字符串循环拼接谁更快?- 引入同步后下降多少?
- 某种哈希函数碰撞严重时影响多大?
- 优化前后的对象分配率变化多少?
- 某次重构是否引入性能回退?
换句话说,JMH 的用途是把:
“我感觉这个写法更快”
变成:
“在可重复的测试条件下,这个写法在目标 JDK 和目标硬件上更快。”
13. 字符串拼接:不要背结论,要理解对象生命周期
资料中的 JMH 教学测试,对 10,000 次字符串拼接给出了大致结果:
| 写法 | 教学测试中的吞吐量 |
|---|---|
循环 String += |
约 32 ops/s |
StringBuffer.append() |
约 5,600 ops/s |
StringBuilder.append() |
约 21,000 ops/s |
StringBuilder + 人工同步 |
约 16,000 ops/s |
这些绝对数字与 JDK、JVM、硬件、测试代码高度相关,不能直接拿到生产环境做容量计算。
但背后的原因非常稳定。
13.1 循环里反复拼接 String
String 是不可变对象。
1 | |
每次构造更长字符串,都可能产生新的对象和字符数据复制。
随着字符串越来越长,总拷贝量可能快速增长。
13.2 StringBuilder 复用内部缓冲区
1 | |
StringBuilder 通过可扩容缓冲区减少反复分配和复制,所以在大量动态拼接中通常更合适。
13.3 StringBuffer 多了一层同步语义
StringBuffer 的很多方法带 synchronized。
如果对象本来就只在单线程或线程局部使用,额外同步没有价值。
因此一般规则是:
- 常量拼接:直接使用
+; - 少量、可读性优先的字符串拼接:
+很正常; - 循环或大量动态拼接:优先
StringBuilder; - 真正需要共享可变字符串缓冲区时,再考虑线程安全方案。
13.4 常量拼接为什么可能快得离谱
例如:
1 | |
编译器可以直接折叠成:
1 | |
因此某些微基准会得到非常夸张的吞吐量。
这反过来也提醒我们:JMH 本身不能阻止你写出一个错误的 Benchmark。
做微基准时要特别注意:
- 结果有没有被使用;
- 是否发生常量折叠;
- 是否被死代码消除;
- 是否需要
Blackhole; - 测试数据是否应该在
@State中生成; - 是否需要 fork 多个 JVM。
14. Java 仍然会内存泄漏:GC 只能回收“不可达对象”
有垃圾回收,不代表没有内存泄漏。
Java 中更典型的泄漏不是“忘记 free”,而是:
对象已经没有业务价值,但仍然被一个长期存活的引用链持有。
最常见的危险区域之一就是生命周期很长的集合:
1 | |
如果只进不出,它的生命周期就和 ClassLoader 或进程一样长。
另一类是长寿命缓存:
1 | |
如果没有:
- 最大容量;
- TTL;
- 淘汰策略;
- 主动失效;
- 生命周期边界;
它最终可能从“性能优化”变成“内存事故”。
14.1 WeakReference/SoftReference 不是通用缓存策略
弱引用和软引用可以改变对象被 GC 回收的条件,但不能代替缓存策略。
业务缓存更应该先回答:
- 最多缓存多少?
- 多久失效?
- 淘汰依据是什么?
- 热点如何保留?
- 数据源不可用时怎么处理?
- 缓存击穿/雪崩怎么办?
有界性和生命周期,通常比“引用类型技巧”更重要。
15. 系统资源必须显式释放:优先 try-with-resources
数据库连接、Socket、文件句柄、输入输出流等资源,并不只是 Java Heap 对象。
GC 即使回收 Java 对象,也不应该被当作这些资源的正常释放机制。
现代 Java 中,只要资源实现 AutoCloseable,优先使用 try-with-resources:
1 | |
数据库场景:
1 | |
这比手工在多个异常分支中重复 close() 更可靠。
代码评审时看到任何“需要关闭”的对象,都应该立刻问:
正常路径、异常路径、提前 return 路径,都能释放吗?
16. equals() / hashCode():不仅是正确性问题,也是性能问题
所有基于 Hash 的集合都依赖两个层次:
hashCode()尽快定位桶;equals()在候选对象中确认相等性。
基本契约是:
如果两个对象
equals()为true,它们必须拥有相同的hashCode()。
如果只实现 equals() 而没有正确实现 hashCode(),逻辑上相等的 Key 可能进入不同桶,从而表现成不同对象。
例如:
1 | |
16.1 哈希碰撞过多会拖垮集合性能
资料中的教学测试构造了 10,000 个对象。
一个版本让 Key 的哈希值充分分散,吞吐量约为 5,000 ops/s;另一个版本把哈希压缩成只有 10 个可能值:
1 | |
测试吞吐量下降到约 9.5 ops/s。
这个数字同样不能泛化,但结论很清楚:
哈希值不是越“简单”越好。高碰撞会让哈希表退化成大量桶内比较。
现代 HashMap 对高碰撞场景还有树化等保护,但碰撞仍然有成本。
因此一个好的 hashCode() 应该:
- 保持
equals契约; - 尽可能让常见输入分布均匀;
- 计算成本不要过高;
- 对热点集合必须通过实际数据验证。
17. 可伸缩性:性能优化不能只盯单机
性能工程有两个不同层次:
- 让一台机器更强;
- 让系统可以通过增加节点承担更多负载。
这里先校正一个容易混淆的术语。
资料正文对“增强单机”和“增加节点”的中文含义描述是清楚的,但括号里的英文术语对应出现了反转。工程领域通常使用:
- Scale up/down:垂直伸缩,提高或降低单个节点资源;
- Scale out/in:水平伸缩,增加或减少节点数量。
17.1 垂直伸缩:提升单节点能力
典型方式包括:
- 更多 CPU;
- 更多内存;
- 更快磁盘;
- 更快网络;
- 更优算法;
- 更高效 JVM 与代码。
优点是直接,缺点是存在物理上限,而且成本通常不是线性增长。
17.2 水平伸缩:增加处理节点
典型方式包括:
- 集群;
- 负载均衡;
- 分布式计算;
- 服务副本;
- 分片。
它通常更适合高可用和大规模系统,但前提是应用本身可以被复制。
影响水平扩展的最大障碍之一,就是本机状态。
18. 状态是水平扩展最大的黏性
考虑:
1 | |
如果用户会话只存在某个应用实例的内存里:
1 | |
此时增加应用副本并不能自然扩容,反而增加状态同步问题。
更好的架构是把“计算节点”和“共享状态”解耦:
flowchart LR
U[用户] --> LB[负载均衡]
LB --> A[应用节点 A]
LB --> B[应用节点 B]
LB --> C[应用节点 C]
A --> S[(共享状态存储)]
B --> S
C --> S
CDN[静态/无状态资源] --> U
这样应用节点本身越接近无状态,就越容易:
- 增加副本;
- 下线节点;
- 故障迁移;
- 滚动升级;
- 弹性扩容。
18.1 分离无状态数据
典型无状态资源包括:
- 静态 HTML;
- JavaScript;
- CSS;
- 图片;
- 静态商品描述;
- 公共配置元数据。
这些数据可以:
- 单独提供服务;
- 使用 CDN;
- 被浏览器缓存;
- 不需要会话同步。
无状态数据从核心业务节点剥离后,既减少单节点资源压力,也降低水平扩容复杂度。
18.2 “无状态服务”不等于“业务没有状态”
有些协议会把经过保护的少量状态交给客户端保存,客户端下一次请求再带回来。
这种模式的本质是:
1 | |
它把部分状态存储成本从服务端转移到客户端。
但这种模式只适合:
- 状态较小;
- 状态可安全封装;
- 服务端可以验证完整性;
- 不需要频繁全局修改的状态。
复杂业务状态通常仍然需要可靠的服务端状态存储。
19. 代码复用也是性能工程:最经济的代码是不用重新写
代码数量本身也是一种成本。
“不要重复造轮子”的真正含义不是禁止创造,而是:
已经存在成熟实现时,优先复用;现有实现有问题时,优先推动它改进;只有需求确实不同,才创建新的轮子。
19.1 当相似代码出现第三次时,应该警觉
如果一段代码已经复制两次,第三次又要复制时,通常应该考虑抽象:
1 | |
复用的收益不仅是少几行代码。
真正重要的是:
- Bug 只修一次;
- 性能优化只做一次;
- 安全修复只做一次;
- 行为保持一致;
- 测试范围更集中。
19.2 一个功能不要同时存在多个“官方轮子”
重复能力会带来更大的认知成本:
1 | |
调用者必须判断:
- 该用哪个?
- 哪个新?
- 哪个快?
- 哪个已经废弃?
- 三个行为是否一致?
所以复用不仅是“提炼代码”,还包括收敛接口。
19.3 公共 API 的演进必须尊重兼容性
一个看起来没有问题的内部重构,放到公共 API 上可能完全不同。
尤其是:
- 调整调用顺序;
- 修改异常;
- 收紧参数;
- 改变默认值;
- 改变状态机;
- 删除旧 API。
即使接口文档没有要求调用顺序,外部实现也可能已经依赖了十几年。
因此修改旧代码可以按风险分层:
| 修改类型 | 风险 |
|---|---|
| 命名、格式、注释 | 低 |
| 拆方法、去重复、结构重排 | 中,需要测试 |
| 行为、顺序、状态机、公共协议 | 高 |
对于“运行多年、没有实际问题”的历史代码,重写欲望必须服从兼容性风险。
这不是鼓励技术债,而是提醒:
重构的收益必须覆盖回归风险。
20. 性能优化的四层工具链:微基准、回归、Profiler、APM
性能意识最终必须落到工具。
可以把工具分成四层。
20.1 JMH:回答“这两个实现哪个更快”
适合:
- 小范围代码;
- 算法;
- 数据结构;
- API;
- 对比实现。
它解决的是微观问题。
20.2 性能回归测试:回答“这次改动有没有变慢”
普通单元测试检查正确性:
1 | |
性能回归还要检查:
1 | |
对关键路径建立自动性能基线,可以防止性能在大量“无害修改”中慢慢退化。
20.3 Profiler:回答“时间和内存花在哪里”
典型观察对象包括:
- CPU Hotspot;
- Allocation;
- GC;
- Thread;
- Lock;
- I/O;
- 方法调用树。
Profiler 用于定位原因,而不是证明整个系统容量。
20.4 APM/实时监控:回答“生产环境现在发生了什么”
生产性能需要关注:
- P50/P95/P99;
- QPS;
- 错误率;
- CPU;
- Heap;
- GC pause;
- 线程池;
- 数据库连接池;
- 下游 RPC;
- 慢 SQL;
- 外部依赖。
JMH 能证明一个方法快,却不能证明生产系统快。
真正成熟的性能工程必须把:
1 | |
连成一条链。
21. “经济代码”的完整评审清单
下面把零散知识收敛成一份可以用于需求评审、设计评审和 Code Review 的检查表。
21.1 需求评审
- 这是真实用户需求,还是想象出来的需求?
- 问题真的存在吗?
- 需求有足够普遍性吗?
- 它的重要程度有多高?
- 是否可以进一步拆解?
- 最小可交付范围是什么?
- 能否推迟到后续迭代?
- 是否已有成熟能力可以复用?
21.2 设计评审
- 方案是不是当前最简单、最直观的方案?
- 一个接口是否只表达一类职责?
- 接口之间的依赖是否明确?
- 调用方式是否容易正确使用?
- 对象是否可以设计成不可变?
- 共享对象是否真的需要可变?
- 接口是否需要线程安全?
- 能否通过无共享状态避免同步?
- 是否适合异步?
- 数据是否需要复制?
- 无状态数据和有状态数据是否应该分离?
- 状态是否影响水平扩容?
- 是否存在可以复用的已有模块?
- 新模块未来是否值得复用?
21.3 代码与性能评审
- 是否创建了不必要的对象?
- 高频计算是否不必要地使用包装类型?
- 对象是否持有重复数据?
- 是否可以共享不可变对象?
- 集合是否应该暴露为不可修改结构?
- 是否需要延迟初始化,还是直接初始化更简单?
synchronized是否真的必要?- 临界区还能不能缩小?
- 是否存在多锁顺序导致死锁的风险?
- 是否频繁分配、复制和释放大块数据?
- 是否存在循环字符串拼接?
- 长生命周期集合是否无界增长?
- 缓存是否有容量与过期策略?
AutoCloseable资源是否可靠释放?- Hash Key 是否正确实现
equals()/hashCode()? - 哈希分布是否可能严重碰撞?
- 热点路径是否有 Benchmark?
- 关键改动是否进入性能回归?
- 重构是否改变了旧逻辑或调用时序?
21.4 架构与容量评审
- 单机瓶颈到底是 CPU、内存、I/O、锁还是外部依赖?
- 增加 CPU 是否真的能提高吞吐量?
- 应用节点是否可以无状态复制?
- Session 是否绑定本机?
- 静态资源是否可以从核心应用剥离?
- 客户端缓存是否充分利用?
- 状态存储是否支持集群与故障切换?
- 性能指标是否有生产监控?
- 是否有容量上限和扩容预案?
22. 一套更实用的性能优化工作流
把所有原则落到日常工程里,可以采用下面的顺序。
第一步:确认问题是真实的
不要因为“有人说这个 API 慢”就优化。
先获取:
- 慢请求;
- CPU 火焰图;
- Allocation 数据;
- GC 日志;
- 锁竞争;
- I/O 延迟;
- SQL;
- QPS 与延迟曲线。
第二步:找到最大的资源成本
性能问题经常不是“Java 代码慢”,而是:
1 | |
这时把 3 ms 优化成 1 ms 几乎没有意义。
第三步:先减少工作量,再提高执行速度
优化优先级通常应该是:
1 | |
第四步:让代码更容易扩容
即使单机已经很快,也要问:
流量再增长十倍时,我能不能直接加节点?
如果答案是否定的,性能问题只是暂时被更强的机器掩盖。
第五步:用回归测试把收益固化
一次优化没有进入持续验证体系,几个月后可能被另一次改动悄悄抵消。
性能优化的终点不是“我今天跑快了”,而是:
系统能够持续证明自己没有越来越慢。
23. 最值得记住的十条原则
如果不想记住所有细节,可以留下这十条:
- 最经济的工作是不做不必要的工作。
- 共享可变状态是并发复杂度的主要来源。
- 能设计成不可变对象,就不要依赖锁维持一致性。
- 无法避免同步时,把临界区缩到最小。
- 内存优化的根本只有两个:少对象,小对象。
- 高成本资源延迟分配,但不要为了 lazy 引入不值得的复杂度。
- 异步优化的是等待期间的资源利用率,不保证单次任务更快。
- 缓存、集合和状态都必须有清晰的生命周期。
- 性能结论必须通过 Benchmark、Profiler、压测和生产监控验证。
- 真正能长期应对增长的代码,既要单机高效,也要容易水平扩展和复用。
总结
“经济代码”并不是一个孤立的 Java 性能主题,而是一种贯穿需求、设计、编码、运行和架构的工程思维。
线程同步告诉我们:不要无条件共享可变状态。
内存管理告诉我们:不要无条件创建、复制和持有对象。
延迟分配告诉我们:资源应该尽可能靠近真正使用它的时间点,但复杂度本身也是成本。
异步编程告诉我们:等待不应该自动等价于占用。
JMH 和性能工具告诉我们:性能不是意见,而是数据。
可伸缩性告诉我们:单机优化只是第一层,真正的系统要能随着负载增长继续演进。
代码复用则把这条链路再向前推进一步:如果一段正确、可靠、经过验证的代码根本不需要重新编写,那么它往往也是最便宜、最稳定的一种实现。
最终,经济代码追求的不是某个“最快写法”,而是在正确性、可读性、维护性、内存、CPU、并发、扩展性和兼容性之间找到一个可持续的平衡点。
这也是性能工程最容易被忽略、但最值得长期训练的能力。