【 Java 性能调优 | 问题定位与测试验证 】
摘要:本文聚焦 Java 性能调优的问题定位与测试验证,先明确性能调优需解决的核心问题,接着介绍线程转储的获取方法,随后通过案例演示如何借助工具定位问题。

1. 性能调优
1.1 性能调优解决的问题
应用程序在运行过程中经常会出现性能问题,比较常见的性能问题现象是:
1. 通过top命令查看CPU占用率高,接近100甚至多核CPU下超过100都是有可能的。

2. 请求单个服务处理时间特别长,多服务使用skywalking等监控系统来判断哪一环节性能低下。

3. 程序启动之后运行正常,但是在运行一段时间之后无法处理任何的请求(内存和GC正常)。
1.2 性能调优的方法
线程转储(Thread Dump)提供了对所有运行中的线程当前状态的快照。线程转储可以通过jstack、visualvm等工具获取。其中包含了线程名、优先级、线程ID、线程状态、线程栈信息等等内容,可以用来解决CPU占用率高、死锁等问题。

1.2.1 通过jstack获取线程转储
1. 通过jps查看进程ID:

2. 通过jstack 进程ID查看线程栈信息:

3. 通过jstack 进程ID > 文件名导出线程栈文件

1.2.2 通过visualvm获取线程转储
1. 点击threads标签

2. 点击 Thread Dump 按钮

3. 生成线程转储文件

线程转储(Thread Dump)中的几个核心内容:

- 名称: 线程名称,通过给线程设置合适的名称更容易 "见名知意"
- 优先级(prio):线程的优先级
- Java ID(tid):JVM中线程的唯一ID
- 本地 ID (nid):操作系统分配给线程的唯一ID
- 线程状态:NEW,RUNNABLE,BLOCKED,WAITING,TIMED_WAITING
,TERMINATED - 栈追踪: 显示整个方法的栈帧信息
线程转储的可视化在线分析平台:
https://jstack.review/ https://fastthread.io/


1.3 案例实战
案例1:解决CPU占用率高问题

监控人员通过prometheus的告警发现CPU占用率一直处于很高的情况,通过top命令看到是由于Java程序引起的,希望能快速定位到是哪一部分代码导致了性能问题。
解决思路:
1. 通过 top –c 命令找到CPU占用率高的进程,获取它的进程ID。

2. 使用 top -p 进程ID 单独监控某个进程,按H可以查看到所有的线程以及线程对应的CPU使用率,找到CPU使用率特别高的线程。

3. 使用 jstack 进程ID 命令可以查看到所有线程正在执行的栈信息。使用 jstack 进程ID > 文件名 保存到文件中方便查看。

4. 找到nid线程ID相同的栈信息,需要将之前记录下的十进制线程号转换成16进制。通过 printf '%x\n' 线程ID 命令直接获得16进制下的线程ID。

5. 找到栈信息对应的源代码,并分析问题产生原因。
在定位CPU占用率高的问题时,需要关注的是状态为RUNNABLE的线程。但实际上有一些线程执行本地方法时并不会消耗CPU,而只是在等待。但 JVM 仍然会将它们标识成"RUNNABLE"状态。
案例2:接口响应时间长的问题

在程序运行过程中,发现有几个接口的响应时间特别长,需要快速定位到是哪一个方法的代码执行过程中出现了性能问题。
解决思路:
已经确定是某个接口性能出现了问题,但是由于方法嵌套比较深,需借助arthas定位具体的方法。
比如调用链是A方法 -> B方法 -> C方法 -> D方法,整体耗时较长。我们需要定位出来是C方法慢导致的问题。
trace命令监控

使用arthas的trace命令,可以展示出整个方法的调用路径以及每一个方法的执行耗时。
命令: trace 类名 方法名
添加 --skipJDKMethod false 参数可以输出JDK核心包中的方法及耗时。
添加 '#cost > 毫秒值' 参数,只会显示耗时超过该毫秒值的调用。
添加 –n 数值 参数,最多显示该数值条数的数据。
所有监控都结束之后,输入stop结束监控,重置arthas增强的对象。
测试方法:com.itheima.jvmoptimize.performance.PerformanceController.a()
1. 使用trace命令,监控方法的执行:

2. 发起一次请求调用:

3. 显示出了方法调用的耗时占比:

4. 添加 --skipJDKMethod false 参数可以输出JDK核心包中的方法及耗时:

5. 添加 ‘#cost > 1000’ 参数,只显示耗时超过1秒的调用:

6. 添加 –n 1 参数,最多显示1条数据,避免数据太多看起来不清晰:

7. 所有监控都结束之后,输入stop结束监控,重置arthas增强的对象。避免对性能产生影响。

watch命令监控
在使用trace定位到性能较低的方法后,使用watch命令监控方法,可以获得更为详细的方法信息。
命令:
watch 类名 方法名 '{params, returnObj}' '#cost>毫秒值' -x 2
'{params, returnObj}' 代表打印参数和返回值。
-x 代表打印的结果中如果有嵌套(比如对象里有属性),最多只展开2层。(最大值为4)
测试方法:com.itheima.jvmoptimize.performance.PerformanceController.a()
1. 执行命令,发起一笔接口调用:

2. cost = 1565ms代表方法执行时间是1.56秒,result = 后边是参数的内容,首先是一个集合(既可获取返回值,也可获取参数),第一个数组就是参数,里边只有一个元素是一个整数值为1。

总结:

1.通过arthas的trace命令,首先找到性能较差的具体方法,如果访问量比较大,建议设置最小的耗时,精确的找到耗时比较高的调用。
2.通过watch命令,查看此调用的参数和返回值,重点是参数,这样就可以在开发环境或者测试环境模拟类似的现象,通过debug找到具体的问题根源。
3.使用stop命令将所有增强的对象恢复。
案例3:定位偏底层的性能问题

有一个接口中使用了for循环向ArrayList中添加数据,但是最终发现执行时间比较长,需要定位是由于什么原因导致的性能低下。
解决思路:
Arthas提供了性能火焰图的功能,可以非常直观地显示所有方法中哪些方法执行时间比较长。
测试方法:com.itheima.jvmoptimize.performance.PerformanceController.test6()

使用arthas的profile命令,生成性能监控的火焰图。
命令1: profiler start 开始监控方法执行性能
命令2: profiler stop --format html 以HTML的方式生成火焰图
火焰图中一般找绿色部分Java中栈顶上比较平的部分,很可能就是性能的瓶颈。
实现步骤:
1.使用命令开始监控:

2.发送请求测试:

3.执行命令结束,并生成火焰图的HTML

4.观察火焰图的结果:

火焰图中重点关注左边部分,是我们自己编写的代码的执行性能,右边是Java虚拟机底层方法的性能。火焰图中会展示出Java虚拟机自身方法执行的时间。
火焰图中越宽的部分代表执行时间越长,比如:

很明显ArrayList类中的add方法调用花费了大量的时间,这其中可以发现一个copyOf方法,数组的拷贝占用时间较多。

观察源码可以知道,频繁的扩容需要多次将老数组中的元素复制到新数组,浪费了大量的时间。

在ArrayList的构造方法中,设置一下最大容量,一开始就让它具备这样的大小,避免频繁扩容带来的影响:

最终这部分开销就没有了,宽度变大是因为我放大了这张图:

总结:

偏底层的性能问题,特别是由于JDK中某些方法被大量调用导致的性能低下,可以使用火焰图非常直观的找到原因。
这个案例中是由于创建ArrayList时没有手动指定容量,导致使用默认的容量而在添加对象过程中发生了多次的扩容,扩容需要将原来数组中的元素复制到新的数组中,消耗了大量的时间。通过火焰图可以看到大量的调用,修复完之后节省了20% ~ 50%的时间。
案例4:线程被耗尽问题

问题:程序在启动运行一段时间之后,就无法接受任何请求了。将程序重启之后继续运行,依然会出现相同的情况。
解决思路:
线程耗尽问题,一般是由于执行时间过长,分析方法分成两步:
1.检测是否有死锁产生,无法自动解除的死锁会将线程永远阻塞。
2.如果没有死锁,再使用案例1的打印线程栈的方法检测线程正在执行哪个方法,一般这些大量出现的方法就是慢方法。
死锁:两个或以上的线程因为争夺资源而造成互相等待的现象。
解决方案:
线程死锁可以通过三种方法定位问题:
1. jstack -l 进程ID > 文件名 将线程栈保存到本地,在文件中搜索deadlock即可找到死锁位置:


2. 开发环境中使用visual vm或者Jconsole工具,都可以检测出死锁。使用线程快照生成工具就可以看到死锁的根源。生产环境的服务一般不会允许使用这两种工具连接。

3.使用fastthread自动检测线程问题。 https://fastthread.io/
Fastthread是一款在线的AI自动线程问题检测工具,可以提供线程分析报告。通过报告查看是否存在死锁问题。
在visualvm中保存线程栈,选择文件并点击分析:

死锁分析报告:

1.4 JMH基准测试框架
面试中容易问到性能测试问题:

Java程序在运行过程中,JIT即时编译器会实时对代码进行性能优化,所以仅凭少量的测试是无法真实反应运行系统最终给用户提供的性能。如下图,随着执行次数的增加,程序性能会逐渐优化。

所以简单地打印时间是不准确的,JIT有可能还没有对程序进行性能优化,我们拿到的测试数据和最终用户使用的数据是不一致的。

OpenJDK中提供了一款叫JMH的工具,可以准确地对Java代码进行基准测试,量化方法的执行性能。https://github.com/openjdk/jmhcJMH会首先执行预热过程,确保JIT对代码进行优化之后再进行真正的迭代测试,最后输出测试的结果。https://github.com/openjdk/jmhc

JMH环境搭建:

创建基准测试项目,在CMD窗口中,使用以下命令创建JMH环境项目:
mvn archetype:generate \ -DinteractiveMode=false \ -DarchetypeGroupId=org.openjdk.jmh \ -DarchetypeArtifactId=jmh-java-benchmark-archetype \ -DgroupId=org.sample \ -DartifactId=test \ -Dversion=1.0修改POM文件中的JDK版本号和JMH版本号,JMH最新版本号参考Github。
编写测试方法,几个需要注意的点:死代码问题 与 黑洞的用法
JMH 注解参数详细总结
1. @State(Scope.Benchmark)
| 核心要素 | 详细说明 |
|---|---|
| 注解作用 | 标记当前类为「状态类」,管理测试中共享资源(如 Redisson 客户端、测试用用户 ID)的生命周期 |
Scope.Benchmark | 状态对象的作用域为「整个基准测试全局唯一」:① 所有测试线程、所有迭代轮次共享同一实例;② 仅在测试开始前(@Setup)创建1次,测试结束后(@TearDown)销毁1次 |
| 配置原因 | 你的测试中需要复用 Redisson 客户端(重量级资源,创建 / 销毁成本高),全局共享能避免重复创建 Redis 连接,保证测试结果真实 |
| 业务适配性 | 签到查询场景中 Redis 客户端本身是线程安全的单例,Scope.Benchmark完全贴合生产环境的单例使用逻辑 |
2. @BenchmarkMode(Mode.AverageTime)
| 核心要素 | 详细说明 |
|---|---|
| 注解作用 | 指定 JMH 的性能测试模式,即统计哪些性能指标 |
Mode.AverageTime | 统计「每次调用测试方法的平均耗时」(单位由@OutputTimeUnit指定),是业务接口性能测试最常用的模式 |
| 可选模式对比 | - Mode.Throughput:统计每秒执行次数(吞吐量),适合看接口承载能力; - Mode.SampleTime:采样统计耗时分布(如 99 分位耗时); - Mode.SingleShotTime:单次调用耗时(适合冷启动场景) |
| 配置原因 | 你的核心诉求是对比两个签到查询方法的 “响应耗时”,AverageTime能直观体现方法间的耗时差异,符合接口性能优化的核心目标 |
3. @OutputTimeUnit(TimeUnit.MILLISECONDS)
| 核心要素 | 详细说明 |
|---|---|
| 注解作用 | 指定性能指标的时间单位,统一测试结果的展示维度 |
TimeUnit.MILLISECONDS | 结果以「毫秒」为单位展示,适配接口响应耗时的常规评估维度(接口耗时通常用 ms 衡量) |
| 可选单位 | - TimeUnit.NANOSECONDS(纳秒):适合极短耗时的方法 - TimeUnit.SECONDS(秒):适合耗时较长的方法(如批处理) |
| 配置原因 | 你的签到查询方法耗时在 “毫秒级”(方法 1 约 628ms,方法 2 约 1.2ms),用 ms 单位能清晰展示差异,避免纳秒数过大或秒数过小导致的可读性差 |
4. @Warmup(iterations = 3, time = 5, timeUnit = TimeUnit.SECONDS)
| 核心要素 | 详细说明 |
|---|---|
| 注解作用 | 配置测试预热阶段,让 JVM 对测试方法进行即时编译(JIT)优化,避免 “冷启动耗时” 干扰正式测试结果 |
| 参数解析 | - iterations = 3:预热轮数为 3 轮;- time = 5:每轮预热持续 5 秒;- timeUnit = TimeUnit.SECONDS:时间单位为秒 |
| 配置原因 | ① 签到查询包含 Redis 网络交互 + 循环计算,JVM 需要 3 轮 ×5 秒的预热才能完成热点代码编译;② 预热时间过短(如 1 轮 1 秒)会导致 JIT 优化不充分,正式测试结果失真;③ 预热阶段结果不计入最终统计,仅为 “热身” |
| 业务适配性 | 生产环境中接口会被反复调用,预热后的测试结果更贴近接口的 “运行时真实性能” |
5. @Measurement(iterations = 5, time = 10, timeUnit = TimeUnit.SECONDS)
| 核心要素 | 详细说明 |
|---|---|
| 注解作用 | 配置正式测试阶段,是收集性能数据的核心环节 |
| 参数解析 | - iterations = 5:正式测试轮数为 5 轮;- time = 10:每轮测试持续 10 秒;- timeUnit = TimeUnit.SECONDS:时间单位为秒 |
| 配置原因 | ① 5 轮测试取平均值能抵消机器临时状态(如 CPU 波动、Redis 短暂卡顿)的干扰,结果更稳定;② 每轮 10 秒能收集足够多的调用样本(方法 2 每秒可调用约 800 次),保证数据统计的可信度;③ 总测试时长(5×10=50 秒)在 “测试效率” 和 “数据准确性” 间达到平衡 |
6. @Fork(2)
| 核心要素 | 详细说明 |
|---|---|
| 注解作用 | 指定测试的 "分叉进程数",即 JMH 会启动多个独立的 JVM 进程执行测试 |
value = 2 | 启动 2 个独立 JVM 进程分别执行完整的 "预热 + 正式测试" 流程,最终取 2 次结果的平均值 |
| 配置原因 | ① 避免单个 JVM 进程的缓存(如方法缓存、内存页缓存)、JIT 优化残留等干扰测试结果; ② 2 个进程既能减少误差,又不会因进程过多导致测试耗时翻倍(如Fork(5)让测试时间过长) |
| 注意点 | 若测试机器性能较弱,可设Fork(1),但结果稳定性会略降 |
7. @Threads(10)
| 核心要素 | 详细说明 |
|---|---|
| 注解作用 | 指定测试的并发线程数,模拟生产环境的多用户并发调用场景 |
value = 10 | 启动 10 个线程同时调用测试方法,模拟 10 个用户并发查询签到记录 |
| 配置原因 | ① 签到场景属于 "低频并发",10 线程贴合 "同时查询签到的用户数不多" 的业务特征; ② 服务器 CPU 核心数的 1~2 倍,不会因线程过多导致切换开销掩盖方法本身的性能差异; ③ 对比单线程测试,10 并发能暴露 Redis 网络 IO 的瓶颈 |
| 业务适配性 | 基于你的业务峰值 QPS(约 66)推算,10 线程能覆盖 "峰值并发 + 冗余",测试结果能直接反映生产环境的性能表现 |
案例初始代码:

package org.sample; import org.openjdk.jmh.annotations.*; import org.openjdk.jmh.results.format.ResultFormatType; import org.openjdk.jmh.runner.Runner; import org.openjdk.jmh.runner.RunnerException; import org.openjdk.jmh.runner.options.Options; import org.openjdk.jmh.runner.options.OptionsBuilder; import java.text.SimpleDateFormat; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.Date; import java.util.concurrent.TimeUnit; //执行5轮预热,每次持续1秒 @Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS) //执行一次测试 @Fork(value = 1, jvmArgsAppend = {"-Xms1g", "-Xmx1g"}) //显示平均时间,单位纳秒 @BenchmarkMode(Mode.AverageTime) @OutputTimeUnit(TimeUnit.NANOSECONDS) @State(Scope.Benchmark) public class HelloWorldBench { @Benchmark public int test1() { int i = 0; i++; return i; } public static void main(String[] args) throws RunnerException { Options opt = new OptionsBuilder() .include(HelloWorldBench.class.getSimpleName()) .resultFormat(ResultFormatType.JSON) .forks(1) .build(); new Runner(opt).run(); } }直接run启动,返回结果:(不推荐)

如果不将 i 返回,JIT会直接将这段代码去掉,因为它认为你不会使用 i ,那么我们对i进行的任何处理都是没有意义的,这种代码无法执行的现象称之为死代码

我们可以将 i 返回,或者添加黑洞来消费这些变量,让JIT无法消除这些代码:


通过maven的verify命令,检测代码问题并打包成jar包。通过java -jar target/benchmarks.jar 命令执行基准测试。

添加这行参数,可以生成JSON文件,测试结果通过https://jmh.morethan.io/生成可视化的结果。



项目案例:方法性能优化及测试
@Override public Map<LocalDate, Boolean> getUserSignInRecord(long userId, Integer year) { if (year == null) { LocalDate date = LocalDate.now(); year = date.getYear(); } String key = RedisConstant.getUserSignInRedisKey(year, userId); RBitSet signInBitSet = redissonClient.getBitSet(key); // LinkedHashMap 保证有序 Map<LocalDate, Boolean> result = new LinkedHashMap<>(); // 获取当前年份的总天数 int totalDays = Year.of(year).length(); // 依次获取每一天的签到状态 for (int dayOfYear = 1; dayOfYear <= totalDays; dayOfYear++) { // 获取 key:当前日期 LocalDate currentDate = LocalDate.ofYearDay(year, dayOfYear); // 获取 value:当天是否有刷题 boolean hasRecord = signInBitSet.get(dayOfYear); // 将结果放入 map result.put(currentDate, hasRecord); } return result; }性能优化
1. 优化判断逻辑
循环内部需要判断当天是否有刷题,实际上每次判断都会去与 Redis 交互,一个循环需要交互 365 次 Redis,效率极低。要优化这段代码,核心是减少与 Redis 的交互次数(减少到 1 次),通过一次性获取所有需要的位数据到本地,再在本地循环处理。
// 依次获取每一天的签到状态 for (int dayOfYear = 1; dayOfYear <= totalDays; dayOfYear++) { // 获取 key:当前日期 LocalDate currentDate = LocalDate.ofYearDay(year, dayOfYear); // 获取 value:当天是否有刷题 boolean hasRecord = signInBitSet.get(dayOfYear); // 将结果放入 map result.put(currentDate, hasRecord); } 具体来说,signInBitSet 是通过 Redisson 客户端与 Redis 交互的 RBitSet 对象,而 RBitSet.get (int bitIndex) 这个方法会触发一次 Redis 请求来获取对应位的值,并没有在本地做缓存。
通过 Wirshark 等抓包工具可以看到,客户端发了一大堆请求给 redis 实例。仔细观察右下角的抓包数据,可以看到执行的操作:

因此,我们在循环外缓存一下 Bitmap 的数据,即可大大提升这个方法的效率:
// 加载 BitSet 到内存中,避免后续读取时发送多次请求 BitSet bitSet = signInBitSet.asBitSet();循环内部使用 bitSet.get 即可:
// 获取 value:当天是否有刷题 boolean hasRecord = bitSet.get(dayOfYear);2. 优化返回值
从示例结果我们可以看到 传输的数据较多、计算时间耗时、带宽占用多、效率低。
实际不必完全组装好数据传输给前端,仅需告诉前端哪天刷题(大部分同学不会每天都刷题),这样能大大减少传输的数据量以及后端服务的 CPU 占用,将部分计算压力均摊到用户的客户端。
修改代码如下:
@Override public List<Integer> getUserSignInRecord(long userId, Integer year) { if (year == null) { LocalDate date = LocalDate.now(); year = date.getYear(); } String key = RedisConstant.getUserSignInRedisKey(year, userId); RBitSet signInBitSet = redissonClient.getBitSet(key); // 加载 BitSet 到内存中,避免后续读取时发送多次请求 BitSet bitSet = signInBitSet.asBitSet(); // 统计签到的日期 List<Integer> dayList = new ArrayList<>(); // 获取当前年份的总天数 int totalDays = Year.of(year).length(); // 依次获取每一天的签到状态 for (int dayOfYear = 1; dayOfYear <= totalDays; dayOfYear++) { // 获取 value:当天是否有刷题 boolean hasRecord = bitSet.get(dayOfYear); if (hasRecord) { dayList.add(dayOfYear); } } return dayList; } 3. 计算优化
上述代码中,我们使用循环来遍历所有年份,而循环是需要消耗 CPU 计算资源的。
在 Java 中的 BitSet 类中,可以使用 nextSetBit 和 nextClearBit 方法来获取从指定索引开始的下一个 已设置(1) 或 未设置(0) 的位。
主要是 2 个方法:
nextSetBit (int fromIndex):从 fromIndex 开始(包括 fromIndex 本身)寻找下一个被设置为 1 的位。如果找到了,返回该位的索引;如果没有找到,返回 -1。
nextClearBit (int fromIndex):从 fromIndex 开始(包括 fromIndex 本身)寻找下一个为 0 的位。如果找到了,返回该位的索引;如果没有找到,返回一个大的整数值。
使用 nextSetBit,可跳过无意义的循环检查,通过位运算获取被设置为 1 的位置,性能更高
修改后的代码如下:
@Override public List<Integer> getUserSignInRecord(long userId, Integer year) { if (year == null) { LocalDate date = LocalDate.now(); year = date.getYear(); } String key = RedisConstant.getUserSignInRedisKey(year, userId); RBitSet signInBitSet = redissonClient.getBitSet(key); // 加载 BitSet 到内存中,避免后续读取时发送多次请求 BitSet bitSet = signInBitSet.asBitSet(); // 统计签到的日期 List<Integer> dayList = new ArrayList<>(); // 从索引 0 开始查找下一个被设置为 1 的位 int index = bitSet.nextSetBit(0); while (index >= 0) { dayList.add(index); // 查找下一个被设置为 1 的位 index = bitSet.nextSetBit(index + 1); } return dayList; } 性能对比测试
搭建JMH测试环境,编写JMH测试代码,进行测试,比对测试结果。
在SpringBoot项目中整合JMH:
1. pom文件中添加依赖:
<properties> <java.version>8</java.version> <jmh.version>1.37</jmh.version> </properties> <dependency> <groupId>org.openjdk.jmh</groupId> <artifactId>jmh-core</artifactId> <version>${jmh.version}</version> <scope>test</scope> </dependency> <dependency> <groupId>org.openjdk.jmh</groupId> <artifactId>jmh-generator-annprocess</artifactId> <version>${jmh.version}</version> <scope>test</scope> </dependency>2.编写测试代码:
package com.guochang.interviewpracticebackend; import com.guochang.interviewpracticebackend.constant.RedisConstant; import org.openjdk.jmh.annotations.*; import org.openjdk.jmh.runner.Runner; import org.openjdk.jmh.runner.RunnerException; import org.openjdk.jmh.runner.options.Options; import org.openjdk.jmh.runner.options.OptionsBuilder; import org.redisson.Redisson; import org.redisson.api.RBitSet; import org.redisson.api.RedissonClient; import org.redisson.config.Config; import java.time.LocalDate; import java.time.Year; import java.util.*; import java.util.concurrent.TimeUnit; /** * 两个用户签到查询方法的JMH性能对比测试 */ @State(Scope.Benchmark) @BenchmarkMode(Mode.AverageTime) // 测试平均耗时 @OutputTimeUnit(TimeUnit.MILLISECONDS) // 平均耗时单位:毫秒 @Warmup(iterations = 3, time = 5, timeUnit = TimeUnit.SECONDS) // 预热3轮,每轮5秒 @Measurement(iterations = 5, time = 10, timeUnit = TimeUnit.SECONDS) // 正式测试5轮,每轮10秒 @Fork(2) // 2个进程执行,避免JVM缓存干扰 @Threads(10) // 模拟10个并发线程 public class SignInQueryBenchmark { // 测试用常量 private static final long TEST_USER_ID = 10000L; private static final Integer TEST_YEAR = 2026; // Redisson客户端 private RedissonClient redissonClient; /** * 初始化:创建Redisson客户端 + 插入模拟签到数据 */ @Setup(Level.Trial) public void setup() { // 1. 初始化Redisson(替换为你项目的真实配置) Config config = new Config(); config.useSingleServer() .setAddress("redis://127.0.0.1:6379") .setDatabase(0); redissonClient = Redisson.create(config); // 2. 插入模拟数据:2026年随机签到100天 String key = RedisConstant.getUserSignInRedisKey(TEST_YEAR, TEST_USER_ID); RBitSet bitSet = redissonClient.getBitSet(key); bitSet.clear(); // 清空原有数据 // 随机设置100个签到位(注意:方法1用dayOfYear=1开始,方法2用index=0开始,需统一) Random random = new Random(); for (int i = 0; i < 100; i++) { int randomDay = random.nextInt(Year.of(TEST_YEAR).length()); // 0-364(对应全年天数) bitSet.set(randomDay); } } /** * 销毁:关闭Redisson客户端 */ @TearDown(Level.Trial) public void teardown() { if (redissonClient != null) { redissonClient.shutdown(); } } /** * 测试方法1:返回Map<LocalDate, Boolean>(全年每日签到状态) */ @Benchmark public Map<LocalDate, Boolean> testQuerySignInMap() { Integer year = TEST_YEAR; if (year == null) { LocalDate date = LocalDate.now(); year = date.getYear(); } String key = RedisConstant.getUserSignInRedisKey(year, TEST_USER_ID); RBitSet signInBitSet = redissonClient.getBitSet(key); Map<LocalDate, Boolean> result = new LinkedHashMap<>(); int totalDays = Year.of(year).length(); for (int dayOfYear = 1; dayOfYear <= totalDays; dayOfYear++) { LocalDate currentDate = LocalDate.ofYearDay(year, dayOfYear); // 注意:方法1用dayOfYear(1开始),Redis BitSet是0开始,这里有个潜在BUG! boolean hasRecord = signInBitSet.get(dayOfYear - 1); result.put(currentDate, hasRecord); } return result; } /** * 测试方法2:返回List<Integer>(仅签到日期的索引) */ @Benchmark public List<Integer> testQuerySignInList() { Integer year = TEST_YEAR; if (year == null) { year = LocalDate.now().getYear(); } String key = RedisConstant.getUserSignInRedisKey(year, TEST_USER_ID); RBitSet signInBitSet = redissonClient.getBitSet(key); BitSet bitSet = signInBitSet.asBitSet(); // 一次性加载到内存 List<Integer> dayList = new ArrayList<>(bitSet.cardinality()); // 预初始化容量 int index = bitSet.nextSetBit(0); while (index >= 0) { dayList.add(index); index = bitSet.nextSetBit(index + 1); } return dayList; } /** * 运行测试的主方法 */ public static void main(String[] args) throws RunnerException { Options options = new OptionsBuilder() .include(SignInQueryBenchmark.class.getSimpleName()) .build(); new Runner(options).run(); } } 3. 查看测试对比结果

4.得出结论
针对用户签到查询接口,通过 JMH 在 10 并发下进行性能测试,发现返回全年签到状态 Map 的方案平均耗时高达 628ms,而返回签到日期列表的方案仅需 1.24ms,性能提升超过 500 倍。核心优化在于将 Redis BitSet 的 365 次网络交互改为 1 次批量加载,并精简无效循环,同时将状态组装逻辑下放至客户端,有效分摊了服务器压力。
恭喜你学习完成!✿