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. 线程同步:最高级的优化往往是避免同步

多线程带来两个天然特征:

  • 多个执行流可以并发运行;
  • 多个线程可以共享同一个进程中的资源。

真正麻烦的是第二点。

如果多个线程只读取一个永远不变的值,通常没有协调问题;如果多个线程会观察并修改同一份共享状态,就进入了同步问题。

可以把“是否需要同步”拆成三个条件:

  1. 存在两个或更多线程;
  2. 多个线程关心同一个共享状态;
  3. 至少有线程会改变这个共享状态。

三个条件同时成立,才真正形成需要协调的共享可变状态。

因此,避免同步也有三个方向:

  • 不使用多个线程处理这段状态;
  • 不共享;
  • 不修改。

从工程角度看,第三条尤其重要:把共享可变状态变成共享不可变状态。

2.1 为什么同步会损失性能

同步本质上是在并发路径上建立顺序约束。

线程进入临界区之前可能要等待;锁的获取与释放本身也需要成本;竞争严重时还会出现线程挂起、唤醒、调度和缓存一致性开销。

所以性能优化不能只问:

这个锁能不能换成更快的锁?

更应该先问:

这里为什么一定需要共享可变状态?

如果状态根本不需要变化,整个同步问题都可以消失。


3. 不可变设计:同时降低同步成本和内存管理复杂度

下面这个类看起来非常普通:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class HelloWords {
private String language = "English";
private String greeting = "Hello";

void setLanguage(String language) {
this.language = language;
}

void setGreeting(String greeting) {
this.greeting = greeting;
}

String getLanguage() {
return language;
}

String getGreeting() {
return greeting;
}
}

在单线程中没有明显问题,但多个线程并发读写时可能观察到不一致状态。

例如:

  1. 线程 A 读取到 language = "English"
  2. 线程 B 把 greeting 改成 "你好"
  3. 线程 A 再读取 greeting,得到 "你好"

最终线程 A 观察到了逻辑上不一致的组合。

更好的设计是让对象在构造完成后不再变化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
final class HelloWords {
private final String language;
private final String greeting;

HelloWords(String language, String greeting) {
this.language = language;
this.greeting = greeting;
}

String getLanguage() {
return language;
}

String getGreeting() {
return greeting;
}
}

这段设计有几个连锁收益:

  • 状态在构造阶段一次性确定;
  • 后续没有 setter;
  • 多个线程可以安全共享同一个实例;
  • 不需要为读取操作增加锁;
  • 调用者更容易理解对象生命周期。

3.1 final 很重要,但不要把它等同于“深度不可变”

final 的含义是变量只能被赋值一次。对于引用类型,它保证的是引用不能重新指向另一个对象,并不意味着引用指向的对象内部不能变化。

例如:

1
2
final List<String> names = new ArrayList<>();
names.add("Alice"); // 合法

因此,一个真正的不可变类通常还需要:

  • 字段在构造后不再改变;
  • 不暴露修改内部状态的方法;
  • 对可变入参进行防御性复制;
  • 对可变字段不要直接返回内部引用;
  • 引用的对象本身也应该不可变,或者在边界上进行复制。

不可变是一种对象语义,不是单靠一个关键字自动获得的属性。

3.2 缩小临界区,比“给整个方法加锁”更重要

如果同步无法避免,第二条原则就是:

只同步真正修改共享状态的部分。

假设一个操作由“准备 -> 修改共享状态 -> 收尾”组成:

1
2
3
4
5
synchronized void bubble(Goldfish goldfish) {
goldfish.breathIn();
addBubbles(goldfish.bubble());
goldfish.breathOut();
}

如果只有 addBubbles() 会操作共享资源,那么更合理的写法是:

1
2
3
4
5
6
7
8
9
void bubble(Goldfish goldfish) {
goldfish.breathIn();

synchronized (this) {
addBubbles(goldfish.bubble());
}

goldfish.breathOut();
}

这里的核心不是语法,而是临界区设计

flowchart LR
    A[线程私有准备] --> B[最小临界区]
    B --> C[线程私有收尾]

进入锁之前能完成的工作,不要放进锁;离开锁之后能完成的工作,也不要留在锁里。

这会直接缩短其他线程的等待时间。


4. 内存优化只有两个基本方向:减少实例数量,减小实例尺寸

Java 有垃圾回收,并不意味着应用程序不需要关心内存。

对象越多,意味着:

  • 分配次数更多;
  • 初始化和填充更多;
  • 引用关系更复杂;
  • GC 扫描与回收压力更大;
  • CPU Cache 和内存带宽压力也可能更高。

从应用代码角度看,减少内存使用可以归结成两个方向:

  1. 减少实例数量;
  2. 减小实例尺寸。

很多所谓“内存优化技巧”,本质上都能放回这两个分类中。


5. 减少实例数量:先消灭不必要的对象

5.1 有限且固定的数据,优先静态化

如果语言和问候语的组合是固定的,而且数量有限,每次调用都 new 一个对象没有意义。

可以使用枚举:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
enum HelloWords {
ENGLISH("English", "Hello"),
SPANISH("Spanish", "Hola"),
GERMAN("German", "Hallo"),
MANDARIN("Mandarin", "Ni Hao");

private final String language;
private final String greeting;

HelloWords(String language, String greeting) {
this.language = language;
this.greeting = greeting;
}

public String language() {
return language;
}

public String greeting() {
return greeting;
}
}

枚举天然适合:

  • 有限状态;
  • 有限类别;
  • 固定映射;
  • 全局共享且不可变的值对象。

它既减少对象数量,也减少对象创建和 GC 压力。

5.2 避免没有意义的构造

经典反例:

1
String language = new String("Java");

如果只是需要字符串常量,直接写:

1
String language = "Java";

前者人为创建额外对象,后者可以复用字符串常量池中的对象。

5.3 高频计算优先使用 primitive,而不是包装类型

下面的代码把累加变量写成 Long

1
2
3
4
5
6
7
8
9
private static long sumUpTo(int upToNumber) {
Long sum = 0L;

for (long i = 0; i < upToNumber; i++) {
sum += i;
}

return sum;
}

更经济的写法是:

1
2
3
4
5
6
7
8
9
private static long sumUpTo(int upToNumber) {
long sum = 0L;

for (long i = 0; i < upToNumber; i++) {
sum += i;
}

return sum;
}

包装类型会引入自动装箱/拆箱、额外间接访问,并且还可能带来 null 语义。

需要注意,自动装箱并不等价于“每次一定 new 一个对象”,JDK 对部分包装值存在缓存和其他优化。但在高频数值计算中,使用 primitive 仍然通常更直接,也更容易获得稳定性能。

5.4 单实例不是目的,减少重复实例才是目的

如果一个对象在逻辑上全局唯一、不可变或生命周期与应用一致,可以使用单实例模式。

例如:

1
2
3
4
5
6
7
8
9
10
final class HelloWordsRegistry {
private static final HelloWordsRegistry INSTANCE = new HelloWordsRegistry();

private HelloWordsRegistry() {
}

static HelloWordsRegistry getInstance() {
return INSTANCE;
}
}

但不要为了“设计模式”而使用 Singleton。

更值得优先考虑的是:

  • 这个对象是否真的只有一个逻辑实例?
  • 它是否持有可变全局状态?
  • 它是否会影响测试隔离?
  • 能不能用依赖注入管理生命周期?
  • 对于有限常量集合,是否直接用 enum 更自然?

6. 减小实例尺寸:少独占,多共享

一个对象的成本不仅是字段本身,还包括它引用的其他对象。

因此,减小实例尺寸的常见思路是:

  • 少持有重复数据;
  • 少复制;
  • 复用安全的共享对象;
  • 对重复字符串、配置、元数据进行静态化;
  • 让共享资源尽量不可变。

可以把原则记成一句话:

能引用就不要复制,能复用就不要新建——前提是共享不会破坏正确性。

这里的前提非常重要。对于公共 API、可变入参和安全边界,防御性复制仍然是必要的。

6.1 immutable 与 unmodifiable 不是一回事

不可变对象(immutable)意味着对象状态本身不会改变。

不可修改视图(unmodifiable view)只意味着通过当前引用不能修改,底层对象仍可能被其他引用改变。

例如:

1
2
3
4
5
6
7
8
List<String> source = new ArrayList<>();
source.add("A");

List<String> view = Collections.unmodifiableList(source);

source.add("B");

// view 现在也能看到 B

如果需要一个更接近“快照”的不可修改集合,可以使用:

1
List<String> snapshot = List.copyOf(source);

List.copyOf()Collections.unmodifiableList() 的语义并不相同:

方案 语义
Collections.unmodifiableList(list) 对原列表的不可修改视图,底层列表变化可能反映到视图
List.copyOf(collection) 创建不可修改列表语义的快照;实现可在安全条件下复用已有不可修改实例

因此,“禁止修改”可以降低共享维护成本,但不能自动等同于“深度不可变”。


7. 大对象处理:复用固定缓冲区,而不是一次性把所有数据塞进内存

内存经济还体现在数据处理方式上。

例如给一个大文件做数字签名,并不需要一次性读取整个文件:

1
2
3
4
5
6
7
8
byte[] buffer = new byte[2048];

int n;
while ((n = input.read(buffer)) != -1) {
signature.update(buffer, 0, n);
}

byte[] result = signature.sign();

这里的关键不是 2048 这个数字本身,而是:

  • 分块读取;
  • 重复使用同一块缓冲区;
  • 避免按文件大小线性增长的内存占用。

缓冲区大小是 I/O 次数与内存占用之间的权衡。过小会增加系统调用和 I/O 次数,过大又会增加常驻内存。

因此,缓冲区大小要测试,不要迷信某个经验值。


8. 延迟分配:不是“越晚初始化越好”

延迟分配(lazy allocation)的核心是:

不到真正需要的时候,不分配资源。

这对高频创建、但多数实例不会真正使用某个重资源字段的类非常有效。

8.1 ArrayList 的历史例子

JDK 7 时代的 ArrayList() 默认构造会立刻创建一个默认容量数组。JDK 8 的实现改成先共享一个空数组,在第一次真正添加元素时再分配容量。

可以把思路简化成:

1
2
3
4
5
6
7
8
9
private static final Object[] EMPTY = {};
private Object[] elementData = EMPTY;

public void add(Object value) {
if (elementData == EMPTY) {
elementData = new Object[DEFAULT_CAPACITY];
}
// ...
}

资料记录的历史性能测试中,这项改动曾带来约 13% 的内存使用下降和 16% 的平均响应时间改善

这个数字不能泛化到其他程序,但它说明了一个非常重要的事实:

高频类中的微小分配行为,乘以巨大的实例数量之后,可能产生明显的系统级影响。

8.2 默认策略仍然应该是“简单优先”

延迟初始化会引入额外分支:

1
2
3
if (resource == null) {
resource = createResource();
}

如果进入多线程环境,事情会更复杂。

所以更合理的规则不是“能 lazy 就 lazy”,而是:

  • 初始化便宜:直接初始化;
  • 对象几乎一定会用到:直接初始化;
  • 初始化昂贵且经常不会用到:考虑延迟初始化;
  • 延迟初始化引入的同步复杂度超过收益:放弃。

复杂方案只有在收益可证明时才值得使用。

资料中还讨论了 HashMap 默认构造何时开始采用延迟内部数组分配。此类 JDK 内部版本细节容易变化,实际工作应直接查看目标 JDK 源码。真正应该记住的是设计原则,而不是版本号。


9. 双重检查延迟初始化:volatile、局部变量和同步分别解决什么问题

一个典型的延迟初始化代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class CodingExample {
private volatile Map<String, String> helloWordsMap;

private void setHelloWords(String language, String greeting) {
Map<String, String> temporaryMap = helloWordsMap;

if (temporaryMap == null) {
synchronized (this) {
temporaryMap = helloWordsMap;

if (temporaryMap == null) {
temporaryMap = new ConcurrentHashMap<>();
helloWordsMap = temporaryMap;
}
}
}

temporaryMap.put(language, greeting);
}
}

这段代码包含几个容易混淆的点。

9.1 为什么需要 volatile

延迟初始化的共享引用需要被其他线程正确观察。

volatile 解决的是这个共享引用的可见性与有序性问题,保证对象发布过程满足 Java Memory Model 的要求。

注意:

volatile 修饰的是 helloWordsMap 这个引用变量,不会自动把 Map 内部的所有操作变成线程安全。

所以代码后续还使用 ConcurrentHashMap 来处理 Map 自身的并发修改。

9.2 为什么第一次检查不加锁

对象初始化之后,大多数调用只需要读取已存在对象。

如果每次都进入 synchronized,延迟初始化就给所有后续调用永久增加了锁成本。

第一次检查用于快速路径:

1
2
3
if (temporaryMap != null) {
// 直接使用
}

9.3 为什么锁内还要检查一次

假设两个线程都在第一次检查时看到 null

线程 A 先进入锁并完成初始化。线程 B 随后拿到锁,如果不再检查,就会重复初始化。

因此锁内必须再次读取共享引用并判断。

9.4 为什么使用局部变量 temporaryMap

helloWordsMapvolatile 共享变量,对它的访问具有额外内存语义。

局部变量只属于当前调用线程,不需要跨线程同步。

把共享引用先保存到局部变量,可以减少对 volatile 字段的重复访问:

1
Map<String, String> temporaryMap = helloWordsMap;

这里还有一个重要前提:

这种优化适合“共享引用初始化后基本不再替换”的场景。

如果引用会反复切换到不同对象,长期缓存局部引用就可能产生语义问题。


10. 异步编程:优化的是等待期间的资源利用率

同步代码遵循:

1
完成 A -> 完成 B -> 完成 C

异步代码更接近:

1
2
3
发布 A -> 发布 B -> 发布 C
|
+--> 各自独立完成并触发后续事件

异步的价值不是让某个网络请求凭空从 100 ms 变成 10 ms,而是:

当一个任务正在等待网络、磁盘或其他外部事件时,不让昂贵的执行资源跟着一起闲置。

10.1 从“过程”切换到“事件”

同步程序更习惯描述过程:

连接服务器,读取响应,然后处理结果。

异步程序更习惯描述事件:

发起请求;当响应到达时,执行处理函数。

JDK 11 的 HttpClient 可以直接表达这种模型:

1
2
3
4
5
6
7
8
9
10
11
12
13
HttpClient client = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_2)
.build();

HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://www.example.com/"))
.build();

client.sendAsync(request, HttpResponse.BodyHandlers.ofString())
.thenApply(HttpResponse::body)
.thenAccept(System.out::println);

// 当前线程可以继续执行其他逻辑

真正的差异是:

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
2
ByteBuffer buffer = ByteBuffer.allocateDirect(1024);
channel.read(buffer, null, completionHandler);

Direct Buffer 可以让 JVM 之外的 native I/O 更直接地使用这块内存,减少某些 Heap Buffer 到 native buffer 的中间复制。

但需要特别区分:

ByteBuffer.allocateDirect() 不等于严格意义上的零拷贝

真正的 OS 级零拷贝通常还涉及 sendfileFileChannel.transferTo()、内核页缓存与网卡之间的数据路径优化。

Direct Buffer 的权衡也很明确:

  • 分配和释放通常比普通堆对象更昂贵;
  • 更适合生命周期较长、数据量较大的 I/O 缓冲区;
  • 应尽量复用,而不是每个小请求都频繁创建。

因此,优化目标仍然是同一条原则:

减少分配、减少复制、延长高成本缓冲区的有效复用周期。


12. 性能优化不能靠感觉:用 JMH 建立微基准

很多 Java 性能结论容易被以下因素干扰:

  • JIT 编译;
  • 常量折叠;
  • 死代码消除;
  • 逃逸分析;
  • GC;
  • CPU 预热;
  • 内联;
  • 锁消除;
  • 不同 JDK 实现。

因此,直接用 System.nanoTime() 包一层循环,往往得不到可靠结果。

JMH(Java Microbenchmark Harness)就是为 JVM 微基准设计的工具。

一个经典的 Maven 项目生成方式是:

1
2
3
4
5
6
7
mvn archetype:generate \
-DinteractiveMode=false \
-DarchetypeGroupId=org.openjdk.jmh \
-DarchetypeArtifactId=jmh-java-benchmark-archetype \
-DgroupId=com.example \
-DartifactId=my-jmh \
-Dversion=1.0

编译并运行:

1
2
3
cd my-jmh
mvn clean package
java -jar target/benchmarks.jar

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
2
3
4
5
String target = "";

for (int i = 0; i < 10_000; i++) {
target += "hello";
}

每次构造更长字符串,都可能产生新的对象和字符数据复制。

随着字符串越来越长,总拷贝量可能快速增长。

13.2 StringBuilder 复用内部缓冲区

1
2
3
4
5
6
7
StringBuilder builder = new StringBuilder();

for (int i = 0; i < 10_000; i++) {
builder.append("hello");
}

String result = builder.toString();

StringBuilder 通过可扩容缓冲区减少反复分配和复制,所以在大量动态拼接中通常更合适。

13.3 StringBuffer 多了一层同步语义

StringBuffer 的很多方法带 synchronized

如果对象本来就只在单线程或线程局部使用,额外同步没有价值。

因此一般规则是:

  • 常量拼接:直接使用 +
  • 少量、可读性优先的字符串拼接:+ 很正常;
  • 循环或大量动态拼接:优先 StringBuilder
  • 真正需要共享可变字符串缓冲区时,再考虑线程安全方案。

13.4 常量拼接为什么可能快得离谱

例如:

1
String s = "Hello, " + "world!";

编译器可以直接折叠成:

1
String s = "Hello, world!";

因此某些微基准会得到非常夸张的吞吐量。

这反过来也提醒我们:JMH 本身不能阻止你写出一个错误的 Benchmark。

做微基准时要特别注意:

  • 结果有没有被使用;
  • 是否发生常量折叠;
  • 是否被死代码消除;
  • 是否需要 Blackhole
  • 测试数据是否应该在 @State 中生成;
  • 是否需要 fork 多个 JVM。

14. Java 仍然会内存泄漏:GC 只能回收“不可达对象”

有垃圾回收,不代表没有内存泄漏。

Java 中更典型的泄漏不是“忘记 free”,而是:

对象已经没有业务价值,但仍然被一个长期存活的引用链持有。

最常见的危险区域之一就是生命周期很长的集合:

1
2
3
4
5
static final List<Object> CACHE = new LinkedList<>();

void add(Object value) {
CACHE.add(value);
}

如果只进不出,它的生命周期就和 ClassLoader 或进程一样长。

另一类是长寿命缓存:

1
final Map<String, Object> cache = new HashMap<>();

如果没有:

  • 最大容量;
  • TTL;
  • 淘汰策略;
  • 主动失效;
  • 生命周期边界;

它最终可能从“性能优化”变成“内存事故”。

14.1 WeakReference/SoftReference 不是通用缓存策略

弱引用和软引用可以改变对象被 GC 回收的条件,但不能代替缓存策略。

业务缓存更应该先回答:

  • 最多缓存多少?
  • 多久失效?
  • 淘汰依据是什么?
  • 热点如何保留?
  • 数据源不可用时怎么处理?
  • 缓存击穿/雪崩怎么办?

有界性和生命周期,通常比“引用类型技巧”更重要。


15. 系统资源必须显式释放:优先 try-with-resources

数据库连接、Socket、文件句柄、输入输出流等资源,并不只是 Java Heap 对象。

GC 即使回收 Java 对象,也不应该被当作这些资源的正常释放机制。

现代 Java 中,只要资源实现 AutoCloseable,优先使用 try-with-resources:

1
2
3
try (InputStream input = Files.newInputStream(path)) {
// use input
}

数据库场景:

1
2
3
4
5
6
7
8
try (Connection connection = dataSource.getConnection();
PreparedStatement statement = connection.prepareStatement(sql);
ResultSet resultSet = statement.executeQuery()) {

while (resultSet.next()) {
// ...
}
}

这比手工在多个异常分支中重复 close() 更可靠。

代码评审时看到任何“需要关闭”的对象,都应该立刻问:

正常路径、异常路径、提前 return 路径,都能释放吗?


16. equals() / hashCode():不仅是正确性问题,也是性能问题

所有基于 Hash 的集合都依赖两个层次:

  1. hashCode() 尽快定位桶;
  2. equals() 在候选对象中确认相等性。

基本契约是:

如果两个对象 equals()true,它们必须拥有相同的 hashCode()

如果只实现 equals() 而没有正确实现 hashCode(),逻辑上相等的 Key 可能进入不同桶,从而表现成不同对象。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
final class Key {
private final String value;

Key(String value) {
this.value = value;
}

@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (!(obj instanceof Key other)) {
return false;
}
return value.equals(other.value);
}

@Override
public int hashCode() {
return value.hashCode();
}
}

16.1 哈希碰撞过多会拖垮集合性能

资料中的教学测试构造了 10,000 个对象。

一个版本让 Key 的哈希值充分分散,吞吐量约为 5,000 ops/s;另一个版本把哈希压缩成只有 10 个可能值:

1
2
3
4
@Override
public int hashCode() {
return key % 10;
}

测试吞吐量下降到约 9.5 ops/s

这个数字同样不能泛化,但结论很清楚:

哈希值不是越“简单”越好。高碰撞会让哈希表退化成大量桶内比较。

现代 HashMap 对高碰撞场景还有树化等保护,但碰撞仍然有成本。

因此一个好的 hashCode() 应该:

  • 保持 equals 契约;
  • 尽可能让常见输入分布均匀;
  • 计算成本不要过高;
  • 对热点集合必须通过实际数据验证。

17. 可伸缩性:性能优化不能只盯单机

性能工程有两个不同层次:

  • 让一台机器更强;
  • 让系统可以通过增加节点承担更多负载。

这里先校正一个容易混淆的术语。

资料正文对“增强单机”和“增加节点”的中文含义描述是清楚的,但括号里的英文术语对应出现了反转。工程领域通常使用:

  • Scale up/down:垂直伸缩,提高或降低单个节点资源;
  • Scale out/in:水平伸缩,增加或减少节点数量。

17.1 垂直伸缩:提升单节点能力

典型方式包括:

  • 更多 CPU;
  • 更多内存;
  • 更快磁盘;
  • 更快网络;
  • 更优算法;
  • 更高效 JVM 与代码。

优点是直接,缺点是存在物理上限,而且成本通常不是线性增长。

17.2 水平伸缩:增加处理节点

典型方式包括:

  • 集群;
  • 负载均衡;
  • 分布式计算;
  • 服务副本;
  • 分片。

它通常更适合高可用和大规模系统,但前提是应用本身可以被复制。

影响水平扩展的最大障碍之一,就是本机状态


18. 状态是水平扩展最大的黏性

考虑:

1
2
private static final Map<SessionId, byte[]> sessionCache =
new HashMap<>();

如果用户会话只存在某个应用实例的内存里:

1
2
用户 -> 节点 A -> session 写入 A 内存
用户 -> 节点 B -> 查不到 session

此时增加应用副本并不能自然扩容,反而增加状态同步问题。

更好的架构是把“计算节点”和“共享状态”解耦:

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
2
3
4
5
服务端状态
↓ 加密/签名/封装
客户端保存
↓ 下次请求带回
服务端验证并恢复

它把部分状态存储成本从服务端转移到客户端。

但这种模式只适合:

  • 状态较小;
  • 状态可安全封装;
  • 服务端可以验证完整性;
  • 不需要频繁全局修改的状态。

复杂业务状态通常仍然需要可靠的服务端状态存储。


19. 代码复用也是性能工程:最经济的代码是不用重新写

代码数量本身也是一种成本。

“不要重复造轮子”的真正含义不是禁止创造,而是:

已经存在成熟实现时,优先复用;现有实现有问题时,优先推动它改进;只有需求确实不同,才创建新的轮子。

19.1 当相似代码出现第三次时,应该警觉

如果一段代码已经复制两次,第三次又要复制时,通常应该考虑抽象:

1
2
3
4
5
6
7
// 不是复制三份相似逻辑
generateReportA();
generateReportB();
generateReportC();

// 而是寻找稳定变化点
generateReport(config);

复用的收益不仅是少几行代码。

真正重要的是:

  • Bug 只修一次;
  • 性能优化只做一次;
  • 安全修复只做一次;
  • 行为保持一致;
  • 测试范围更集中。

19.2 一个功能不要同时存在多个“官方轮子”

重复能力会带来更大的认知成本:

1
2
3
4
同一个任务
├── API A
├── API B
└── API C

调用者必须判断:

  • 该用哪个?
  • 哪个新?
  • 哪个快?
  • 哪个已经废弃?
  • 三个行为是否一致?

所以复用不仅是“提炼代码”,还包括收敛接口

19.3 公共 API 的演进必须尊重兼容性

一个看起来没有问题的内部重构,放到公共 API 上可能完全不同。

尤其是:

  • 调整调用顺序;
  • 修改异常;
  • 收紧参数;
  • 改变默认值;
  • 改变状态机;
  • 删除旧 API。

即使接口文档没有要求调用顺序,外部实现也可能已经依赖了十几年。

因此修改旧代码可以按风险分层:

修改类型 风险
命名、格式、注释
拆方法、去重复、结构重排 中,需要测试
行为、顺序、状态机、公共协议

对于“运行多年、没有实际问题”的历史代码,重写欲望必须服从兼容性风险。

这不是鼓励技术债,而是提醒:

重构的收益必须覆盖回归风险。


20. 性能优化的四层工具链:微基准、回归、Profiler、APM

性能意识最终必须落到工具。

可以把工具分成四层。

20.1 JMH:回答“这两个实现哪个更快”

适合:

  • 小范围代码;
  • 算法;
  • 数据结构;
  • API;
  • 对比实现。

它解决的是微观问题。

20.2 性能回归测试:回答“这次改动有没有变慢”

普通单元测试检查正确性:

1
2
before -> pass
after -> pass

性能回归还要检查:

1
2
before -> 50 ms
after -> 120 ms // 功能没坏,但性能坏了

对关键路径建立自动性能基线,可以防止性能在大量“无害修改”中慢慢退化。

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
2
3
4
5
6
7
代码级微基准

服务级压测

持续性能回归

生产监控

连成一条链。


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
2
3
4
等待数据库 300 ms
网络 100 ms
序列化 20 ms
业务代码 3 ms

这时把 3 ms 优化成 1 ms 几乎没有意义。

第三步:先减少工作量,再提高执行速度

优化优先级通常应该是:

1
2
3
4
5
6
7
8
9
删除不必要工作
>
减少对象/复制/同步
>
改变数据结构与算法
>
并行化/异步化
>
微观语法优化

第四步:让代码更容易扩容

即使单机已经很快,也要问:

流量再增长十倍时,我能不能直接加节点?

如果答案是否定的,性能问题只是暂时被更强的机器掩盖。

第五步:用回归测试把收益固化

一次优化没有进入持续验证体系,几个月后可能被另一次改动悄悄抵消。

性能优化的终点不是“我今天跑快了”,而是:

系统能够持续证明自己没有越来越慢。


23. 最值得记住的十条原则

如果不想记住所有细节,可以留下这十条:

  1. 最经济的工作是不做不必要的工作。
  2. 共享可变状态是并发复杂度的主要来源。
  3. 能设计成不可变对象,就不要依赖锁维持一致性。
  4. 无法避免同步时,把临界区缩到最小。
  5. 内存优化的根本只有两个:少对象,小对象。
  6. 高成本资源延迟分配,但不要为了 lazy 引入不值得的复杂度。
  7. 异步优化的是等待期间的资源利用率,不保证单次任务更快。
  8. 缓存、集合和状态都必须有清晰的生命周期。
  9. 性能结论必须通过 Benchmark、Profiler、压测和生产监控验证。
  10. 真正能长期应对增长的代码,既要单机高效,也要容易水平扩展和复用。

总结

“经济代码”并不是一个孤立的 Java 性能主题,而是一种贯穿需求、设计、编码、运行和架构的工程思维。

线程同步告诉我们:不要无条件共享可变状态。

内存管理告诉我们:不要无条件创建、复制和持有对象。

延迟分配告诉我们:资源应该尽可能靠近真正使用它的时间点,但复杂度本身也是成本。

异步编程告诉我们:等待不应该自动等价于占用。

JMH 和性能工具告诉我们:性能不是意见,而是数据。

可伸缩性告诉我们:单机优化只是第一层,真正的系统要能随着负载增长继续演进。

代码复用则把这条链路再向前推进一步:如果一段正确、可靠、经过验证的代码根本不需要重新编写,那么它往往也是最便宜、最稳定的一种实现。

最终,经济代码追求的不是某个“最快写法”,而是在正确性、可读性、维护性、内存、CPU、并发、扩展性和兼容性之间找到一个可持续的平衡点。

这也是性能工程最容易被忽略、但最值得长期训练的能力。


Java 经济代码:从线程同步、内存管理到异步与可伸缩性的性能工程实践
https://allendericdalexander.github.io/2026/08/12/geeker/036/2java-economic-code-performance-engineering/
作者
AtLuoFu
发布于
2026年8月12日
许可协议