4. 性能测试工具:JMH、ab/wrk、gprof/perf

聊到并发瓶颈,最怕的就是凭感觉说话。我见过太多工程师,代码写得行云流水,一问性能数据,只能回一句「我觉得应该很快」—— 这不行。

性能诊断,一定得靠数据说话。而这章,我们就来聊聊我常用的三套工具:JMH、ab/wrk、gprof/perf。它们各自管一块,组合起来,几乎能覆盖所有并发瓶颈场景。

性能工具诊断流程 国密 SDK 应用 JMH 微基准 ab / wrk 压力 gprof / perf 剖析 单次调用耗时 / 方差 最大并发 / 吞吐 热点函数 / 调用链 定位并发瓶颈

4.1 JMH:写对基准测试

搞国密SDK,你肯定要测某个算法的单次执行耗时。很多新手直接写个 for 循环 打时间戳,结果 JIT 编译一介入,数据全乱套。我个人习惯,这种场景一定用 JMH。

核心要点:JMH 会处理好预热、JIT 编译、死代码消除、伪共享等问题。你只要关注业务逻辑就行。

举个例子,你要测 SM4 的 ECB 加密吞吐:

@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@State(Scope.Thread)
public class Sm4Benchmark {
    private Sm4Crypto sm4;
    private byte[] plaintext;

    @Setup
    public void init() {
        sm4 = new Sm4Crypto();
        plaintext = "国密SDK性能测试数据".getBytes();
    }

    @Benchmark
    public byte[] encrypt() {
        return sm4.encryptEcb(plaintext);
    }
}

跑起来之后,JMH 会输出类似这样的结果:

BenchmarkModeCntScoreErrorUnits
Sm4Benchmark.encryptthrpt10285.647± 3.218ops/ms

这个 285 ops/ms 就是你在单线程下的真实上限。我在优化一个客户端的国密库时,就是靠这个数据,发现某个实现比预期慢了一半 —— 原来是个无意义的字节拷贝。

注意:JMH 的结果只在 JVM 预热完毕后有效。你让刚启动的 JVM 跑出来的数据,根本不具备参考价值。我曾经就因为忽略了预热时间,误导了项目组一周。

4.2 ab / wrk:模拟真实并发压力

单次调用再快,放到高并发下可能直接崩盘。所以第二个工具,就是压测工具。Linux 下我常用 ab(ApacheBench),简单直接。如果你想要更精细的控制,wrk 支持 Lua 脚本,灵活性更高。

咱们先看 ab 的基本用法:

# 模拟 100 个并发,总共发送 10000 次请求
ab -n 10000 -c 100 http://127.0.0.1:8080/sm4/encrypt

输出里的几个核心指标:

  • Requests per second:系统每秒能处理的请求数。这个数值越大越好。
  • Time per request (mean):平均每个请求的耗时,包括网络传输。
  • Connection Times:连接建立、处理、等待的分布情况。
  • Failed requests:如果这个不为 0,说明有请求超时或出错,基本就是瓶颈信号。

wrk 的用法也类似,但支持更多自定义:

wrk -t12 -c400 -d30s http://127.0.0.1:8080/sm4/encrypt

-t12 用 12 个线程,-c400 保持 400 个并发连接,持续 30 秒。我个人更习惯用 wrk,因为它能打出更细的延迟分布,比如 Latency Distribution 里的 P50、P75、P99。

注意一个常见的误区:并发连接数不是越大越好。当并发数超过系统处理能力时,你会发现 RPS 反而下降,失败率飙升。我以前给一个国密网关做压测,并发从 200 调到了 800,RPS 从 3200 跌到了 1500 —— 就是因为线程争抢锁太严重。所以并发数要逐步增加,找到拐点。

这里分享我自己的一个经验:压测时,我会同时用 top 或 htop 观察 CPU 和内存。如果 CPU 没跑满,RPS 就上不去了,那说明瓶颈在 I/O 或锁上。

4.3 gprof / perf:定位热点函数

好了,前两步已经帮你确认了「系统确实有瓶颈」。但瓶颈到底在哪行代码里?这时候就需要性能剖析工具上阵了。

gprof 是 GNU 工具链里自带的,用法简单:

  1. 编译时加 -pg 选项。
  2. 正常运行程序,退出后会自动生成 gmon.out 文件。
  3. 执行 gprof ./your_program gmon.out 查看报告。

报告会以表格形式列出每个函数的调用次数、自身耗时、累计耗时。比如下面这个简化输出:

Flat profile:
Each sample counts as 0.01 seconds.
  %   cumulative   self              self     total
 time   seconds   seconds    calls  us/call  us/call  name
 35.2      1.52     1.52   50000    30.4     86.2     sm4_encrypt_block
 20.5      2.41     0.89   100000     8.9      8.9     memcpy
 12.1      2.93     0.52   50000    10.4     40.6     key_expansion
  ...

从这张表里,一眼就能看到 sm4_encrypt_block 占用了 35.2% 的 CPU 时间。那优化目标就很明确了。

但是,gprof 有一个很大的缺陷:它只能分析程序正常退出后的数据。对于服务器这种常年跑着的进程,你不好让它优雅退出。所以我现在更常用 perf。

perf 的优势:不需要重新编译,可以动态附加到正在运行的进程上,采样实时数据。而且 perf 基于硬件 PMU(性能监测单元),采样开销极低,几乎不影响程序运行。

常用的 perf 命令:

# 记录正在运行的进程(PID 1234)的 CPU 采样,持续 30 秒
perf record -g -p 1234 -- sleep 30

# 生成火焰图风格的调用链报告
perf report -g graph --stdio

输出里会列出各个函数的热度百分比,以及父子调用关系。我曾在某个项目中,用 perf 抓到一个只有 3 行代码的小函数,占用了超过 40% 的 CPU —— 源码里有个 memset 每次清空一块 4KB 的缓冲区,但其实根本不需要。去掉之后,整体吞吐提升了近 50%。

一次踩坑经历:我曾经用 perf 去折腾一个 Go 写的国密模块,结果发现采样出来全是运行时函数。后来才反应过来 perf 默认对 Go 的栈展开不够好。对于 Go 程序,建议使用 perf record --call-graph dwarf 来展开符号。这种东西,书上不会写,只能自己摔一跤才长记性。

4.4 三个工具的协作流

三套工具不是孤立的。我习惯按这个顺序走:

  • JMH 先把每个独立操作的性能基线定下来。比如 SM2 签名耗时多少,SM4 加密吞吐多少。这一步能发现明显的低效代码。
  • ab / wrk 把完整链路压起来,看看系统在真实并发下的表现。如果这里的 RPS 与 JMH 基线差太多,说明瓶颈不在单次操作,而在并发协调(锁、队列、线程模型)。
  • perf 再钻到进程内部,直接看 CPU 时间花在哪里。是加解密本身,还是上下文切换,还是内存拷贝?

搞懂这套流程,你基本就能应付 90% 的国密 SDK 并发性能问题了。剩下的 10%,可能需要深入系统内核或 JVM 调优——那些是后面章节的内容。