Java性能优化01:性能优化不是调参数,先建立目标、指标与优化闭环
Java 性能优化系列第 1 篇。本文先回答一个最基本的问题:怎样判断一次优化真的有效?CPU、内存、GC、多线程、JDK 21 虚拟线程和网络问题,会在后续文章中分别实践。
接口变慢后,很多人的第一反应是加线程、扩大堆内存、修改 GC 参数。这些办法有时有效,但也可能完全改错方向。
性能优化更像看病:先确认症状,再用检查结果找原因,最后用复查判断治疗是否有效。不能只因为改了参数、平均耗时下降,就宣布优化成功。错误率可能升高,少数请求可能变得更慢,代码甚至可能少做了业务工作。
开始优化前,先回答四个问题:
- 测的是哪个业务场景?
- 测试时有多少流量、多少数据?
- 哪个数字达到多少才算成功?
- 优化后业务结果是否仍然正确?
一、把“快一点”改成明确目标
“把报价接口优化一下”不是一个可以验收的目标。可以改成:
使用固定测试数据,持续压测 30 分钟。99% 的请求在 200 ms 内完成,也就是 p99 小于 200 ms。错误率小于 0.1%,单实例 CPU 不超过 70%。返回结果必须与优化前一致。
这句话说明了五件事:
| 要说明的内容 | 例子 |
|---|---|
| 测什么 | /api/quote报价接口 |
| 怎么测 | 固定数据,持续压测 30 分钟 |
| 多快算合格 | 99% 的请求在 200 ms 内完成(p99 小于 200 ms) |
| 能否出错 | 错误率小于 0.1% |
| 有什么限制 | CPU 不超过 70%,业务结果不能改变 |
这些目标不能凭感觉填写。它们应该来自用户能接受的等待时间、上游超时时间和机器成本。
二、性能至少要看四类数字
不能只看一个“平均响应时间”。一次测试至少要同时记录下面四类指标:
| 指标 | 说人话 | 常见单位或例子 |
|---|---|---|
| 吞吐量 | 每秒真正完成了多少工作 | req/s、条/秒 |
| 延迟 | 用户等了多久 | p50、p95、p99 |
| 错误率 | 有多少请求失败或超时 | 0.1%、1% |
| 资源 | 为这些结果花了多少机器资源 | CPU、内存、GC(垃圾回收)、线程、连接数 |
吞吐量:每秒完成多少请求
一个接口每秒返回 1,000 次,可以写成 1,000 req/s。但要注意,快速返回错误也会产生很高的 req/s,所以吞吐量必须和错误率一起看。
延迟:不要只看平均值
把 100 个请求按耗时从小到大排列:
- p50 可以理解为中间水平,约有一半请求比它快。
- p95 表示约 95% 的请求不超过这个耗时。
- p99 关注最慢的那一小部分请求,也就是常说的“长尾”。
例如,99 个请求都用了 10 ms,只有 1 个用了 1 秒,平均值仍然只有约 19.9 ms。平均值看起来很好,但那个等待 1 秒的用户并不会感受到“平均”。
错误率:不能用失败换速度
优化后延迟下降,但错误率从 0.1% 升到 2%,通常不能算成功。除了 HTTP 500,还应统计超时、限流和业务校验失败。
资源:解释为什么慢
CPU 高,可能是计算太多;CPU 不高但延迟很高,线程可能在等数据库或下游接口;p99 周期性升高,可能与 GC 停顿有关。GC 是 JVM 自动清理不再使用对象的过程,清理期间可能短暂停顿应用。
这里的“可能”很重要。监控只能帮助提出猜测,不能直接证明原因。真正下结论前,还要查看线程、GC 日志、JFR(JDK 自带的性能记录工具)或火焰图(显示程序时间主要花在哪些方法上的图),并做对照实验。
补充:SLI、SLO 和 SLA
理解了指标之后,再看三个常见缩写就简单多了:
- SLI 是实际成绩,例如本月 99.95% 的请求成功。
- SLO 是目标成绩,例如本月成功率要达到 99.9%。
- SLA 是对外承诺,没有达到时可能需要赔偿。
可以把它们记成:SLI 是成绩单上的分数,SLO 是目标线,SLA 是写进合同的承诺。
k6 压测通过,只能说明这次测试满足条件,不能证明整个月的生产服务都满足 SLO。
三、性能优化的正确顺序
一次完整优化可以概括成六步:定目标、测基线、找证据、改一处、重新测、再上线。
这里有三条硬规则:
- 业务结果不能变错:不能靠少查数据、少做计算换取低延迟。
- 前后测试条件要相同:改代码的同时换机器,就无法判断收益来自哪里。
- 测试要重复执行:第一次可能受 Java 预热、连接建立或缓存状态影响。
四、贯穿本系列的实验项目
本系列使用开源项目java-performance-lab。它提供三个接口和一个可控制延迟、失败的模拟下游服务。这里的“下游服务”,就是当前接口还要调用的另一个服务。
| 接口 | 做什么 | 后续用来测试什么 |
|---|---|---|
/api/search | 查询数据库并返回 JSON | 慢 SQL、连接池、分页、序列化 |
/api/quote | 读取缓存并调用下游接口 | 超时、缓存、线程阻塞、虚拟线程、网络 |
/api/report | 做计算并批量处理数据 | CPU、对象分配、锁竞争、GC |
后续文章都使用这三个接口。这样每次优化都能和前面的数据比较,不需要每篇重新编一个案例。
五、跑一次真实基线
“基线”就是修改代码之前的成绩。没有基线,优化后就没有可靠的比较对象。
测试环境
本次数据采集于 2026-10-14,应用代码版本为34f3562。
| 项目 | 配置 |
|---|---|
| 操作系统 | Windows 11,压测工具和服务在同一台机器 |
| CPU | Intel Core i5-13500,14 核 20 线程 |
| 内存 | 32 GB,应用容器没有单独限制 CPU 和内存 |
| Java | Eclipse Temurin 21.0.12.1 LTS |
| 应用 | Spring Boot 3.4、H2、无 Redis 缓存、普通平台线程(传统 Java 线程) |
| 压测工具 | k6 v2.1.0 |
压测端和服务端在同一台电脑,而且容器没有固定资源,因此这组数据只用于演示测试过程,不代表项目的最大容量。
怎么测
本次使用 k6 压测。k6 是一个开源压测工具,可以模拟多个用户反复访问接口,并统计吞吐量、响应时间和错误率。
基线脚本是load-tests/baseline.js。测试方式如下:
- 使用 5 个虚拟用户,也就是让 k6 模拟 5 个并发用户,持续 20 秒。
- 每轮同时请求三个接口,然后等待 200 ms。
- 三个接口都必须返回 HTTP 200,也就是请求成功。
- 整体错误率必须低于 1%,p95 必须低于 1 秒。
下面是一条完整命令,在 PowerShell、cmd 和 Bash 中都不需要处理换行符:
k6 run --summary-trend-stats "avg,min,med,p(90),p(95),p(99),max" --summary-export "blogs/assets/01/baseline-run-1.json" -e BASE_URL=http://localhost:8080 load-tests/baseline.js仓库中的标准 Compose 环境使用 8080 端口。本文采集数据时,开发容器映射到了 18080;两者只是端口映射不同。
连续测三次
代码和测试条件都不变,连续执行三次:
| 轮次 | 请求数 | 吞吐 | p50 | p95 | p99 | 错误率 |
|---|---|---|---|---|---|---|
| 第 1 次 | 1,440 | 71.69 req/s | 4.58 ms | 11.60 ms | 30.75 ms | 0% |
| 第 2 次 | 1,470 | 73.07 req/s | 3.45 ms | 5.55 ms | 7.24 ms | 0% |
| 第 3 次 | 1,470 | 73.23 req/s | 3.11 ms | 4.98 ms | 6.10 ms | 0% |
数据说明:数据来自仓库中的三份
baseline-run-*.json,图片由render-baseline-chart.ps1生成。三次测试的错误率都是 0%。
最明显的现象是:第一次的 p99 为 30.75 ms,第三次只有 6.10 ms,相差约 5 倍;但三次吞吐量很接近。
这只能证明一件事:这种短时间测试的长尾不够稳定,不能跑一次就挑一个好看的结果。
现在还不能断定是 JIT 预热造成的。JIT 会在程序运行期间,把经常执行的 Java 代码进一步编译成本机更容易执行的机器码,因此程序刚启动时和运行一段时间后,速度可能不同。连接建立、缓存和电脑上的其他任务也可能影响结果。要确定原因,需要延长测试时间,并结合 JFR(JDK 自带的性能记录工具)等证据,这会在后续文章中完成。
第 2 次测试中,三个接口的结果如下:
| 接口 | p50 | p95 | p99 |
|---|---|---|---|
/api/search | 3.01 ms | 4.15 ms | 6.78 ms |
/api/quote | 4.53 ms | 5.88 ms | 7.14 ms |
/api/report | 3.07 ms | 4.18 ms | 6.41 ms |
报价接口略慢,但差距很小,还不能说明下游 HTTP 就是瓶颈。下一步应该增加下游延迟做对照实验,而不是马上修改线程池或 HTTP 参数。
六、补充知识:用利特尔法则理解“系统里有多少请求”
Little’s Law 中文常叫“利特尔法则”。名字听起来有些陌生,它表达的意思其实很直观:
请求来得越快,或者每个请求停留得越久,系统里同时存在的请求通常就越多。
公式如下:
系统内平均请求数 = 每秒处理的请求数 × 每个请求的平均停留时间(秒) L = λ × WL是系统内的平均请求数,包括正在处理和排队等待的请求。λ读作“兰姆达”,表示平均吞吐量,例如每秒处理 500 个请求。W表示一个请求从进入到离开的平均时间,单位必须换算成秒。
先看一个容易理解的例子。一个接口平均每秒处理 500 个请求,每个请求平均停留 200 ms,也就是 0.2 秒:
500 × 0.2 = 100 个请求这表示在稳定运行期间,系统中平均大约有 100 个请求正在处理或排队。它不代表线程池必须设置为 100,因为请求还可能受 CPU、数据库连接池、下游接口和线程模型影响。
再看本文的实测数据。第 2 次测试的吞吐量为 73.07 req/s,平均响应时间为 3.67 ms,也就是 0.00367 秒:
73.07 × 0.00367 ≈ 0.27 个请求“0.27 个请求”是整个测试期间算出来的平均值,并不是说某个时刻真的存在四分之一个请求。这个数字小于 1,是因为接口响应很快,而且 5 个虚拟用户每请求一轮还会等待 200 ms;大部分时间里,应用内没有请求正在执行。
使用这个公式要满足三个条件:统计时间足够长、请求没有持续越积越多、吞吐量和响应时间使用同一个系统范围。它适合检查数据是否大致合理,但不能直接用来决定线程池大小,也不能把 p99 当成公式中的平均响应时间。
七、怎样提出一个可以验证的猜测
不要只说“虚拟线程更快”,而要把猜测写完整:
如果给下游增加 100 ms 延迟,普通平台线程(传统 Java 线程)会大量等待。启用 JDK 21 虚拟线程(一种可以大量创建的轻量线程)后,线程资源占用应该下降;但数据库连接数和下游处理能力没有变化,所以系统吞吐不会无限增加。
这句话说明了测试场景、准备修改的内容和预期结果。如果结果没有出现,这个猜测就可能是错的。
每次实验至少记录下面这些内容:
- 日期和代码版本: - JDK、机器或容器配置: - 测试数据和负载: - 优化目标: - 业务正确性检查: - 观察到的现象: - 本次只修改什么: - 优化前后数据: - 最终决定:保留、回滚还是继续实验?更完整的模板在docs/results/README.md。
八、最容易踩的六个坑
- 只看平均值:平均值会隐藏少量特别慢的请求,至少还要看 p95、p99 和错误率。
- 只测一次:Java 预热、缓存和连接状态都会影响短测试。
- 测试时间太短:20 秒看不到内存逐渐增长、缓存过期和周期性 GC。
- 压测工具和服务抢同一台机器:正式容量测试应把压测端分开,并检查压测机自己有没有到上限。
- 优化前后做的工作不一样:少返回字段、跳过校验会让数字变好,但这不是正常优化。
- 一次修改很多地方:同时改 GC、线程池和缓存,即使变快,也不知道究竟是哪一项有效。
小结
第一次做性能优化,可以先记住下面这套简单方法:
- 写清业务场景和合格标准。
- 同时记录吞吐、延迟、错误率和资源。
- 修改前先测基线,而且要重复测试。
- 根据证据猜原因,一次只改一个主要因素。
- 用相同条件重新测试,并检查业务结果。
- 小流量上线,指标异常时能够回滚。
下一篇《Java性能优化:线上 Java 性能问题排查总纲,覆盖 CPU、线程、内存与 GC》会回答:CPU 高、线程卡住、内存上涨或频繁 GC 时,第一步应该看什么。文章会使用jcmd、JFR/JDK Mission Control、async-profiler 和 Arthas,并配上真实工具图。
系列导航:上一篇:无 | 系列目录 | 下一篇:Java性能优化:线上 Java 性能问题排查总纲,覆盖 CPU、线程、内存与 GC