本节目标:把 JFR 和 JMH 的数据折算成「改不改、改哪里、怎么回归」的决策,而不是看到热点就重写。三个候选是
Loan.state的分配、计数器的锁竞争、批量罚金计算的循环形态。
适用版本:JDK 25(Temurin 25.0.5)、JMH 1.37。机器为 Apple Silicon(arm64)、macOS 26.3,单 Fork。
承接的问题
9.2 的 JFR 给出三个候选:
Loan.state贡献了 95.32% 的分配压力,LoanState.Overdue占 79.39%,10 秒约 272 MB。CounterBenchmark里synchronized计数器在 4 线程下明显低于LongAdder,但这是微基准,不是生产。batchLoop和streamPerLoan的 JMH 分数接近,批量循环没有想象中快。
这三个候选的「改法」完全不同:
- 分配问题可以改数据结构,也可以只改热路径,还可以不改,因为 272 MB/10 秒不一定导致 GC 停顿。
- 锁问题可以换
LongAdder,但LongAdder的sum()不是原子快照,业务语义可能不接受。 - 批处理问题可能根本不该改,因为 stream 的可读性更好,性能差距在误差范围内。
优化决策的核心不是「哪个最快」,而是「收益是否大于维护成本,回归是否能证明」。这一节按「测量 → 改动 → 回归」的顺序,把三个候选各走一遍。
核心机制一:分配优化的收益要用同一负载 A/B
先看基线。9.2 的 LibraryLoad 每轮对 2000 个 loan 调 fineOf,fineOf 调 loan.state(today)。JFR 的分配视图:
com.plume.library.domain.LoanState$Overdue 79.39%
com.plume.library.domain.LoanState$Active 15.93%
com.plume.library.domain.Loan 4.65%
java.util.ArrayList$Itr 0.03%
10 秒记录里 jdk.ObjectAllocationSample 有 316 个事件。改动方案是给 Loan 增加一个直接返回罚金的标量方法,绕开 LoanState 对象:
public long fineCents(LocalDate today) {
if (returnedOn != null) {
long overdue = Math.max(0, ChronoUnit.DAYS.between(dueOn, returnedOn));
return overdue * FINE_CENTS_PER_DAY;
}
if (dueOn.isBefore(today)) {
return ChronoUnit.DAYS.between(dueOn, today) * FINE_CENTS_PER_DAY;
}
return 0L;
}
把 LibraryLoad 里的 fineOf(loan) 换成 loan.fineCents(TODAY),其余不变,再跑一次 10 秒 JFR。本机对照结果:
| 指标 | state 版本 | fineCents 版本 |
|---|---|---|
jdk.ExecutionSample | 156 | 126 |
jdk.ObjectAllocationSample | 316 | 1 |
| 分配压力第一名 | LoanState$Overdue 79.39% | java.lang.String 100%(1 个样本) |
| 循环轮次 | 9142 | 7336 |
分配采样从 316 降到 1,说明 LoanState 的分配确实是这个负载的主要分配来源。改动有效,而且效果可以用同一份 jfr view allocation-by-class 直接对比。
但「有效」不等于「现在就该改」。先算成本和收益:
- 收益:减少 27 MB/s 左右的分配压力。基线 10 秒里
jdk.G1GarbageCollection只有 2 次,年轻代回收没有造成明显停顿。如果服务的堆足够大、GC 停顿在 SLA 内,这个分配量可以接受。 - 成本:
fineCents与Loan.state的罚金规则重复。两份代码必须在同一处修改,否则列表页和详情页会算出不同的罚金。需要单元测试同时覆盖两个方法,或者让state内部调用fineCents。 - 回归:用 JFR 的
allocation-by-class比较改动前后,用 JMH 的-prof gc确认分配率,用单元测试确认金额一致。
一个更稳的写法是让 Loan.state 复用 fineCents,避免规则漂移:
public LoanState state(LocalDate today) {
if (returnedOn != null) {
return new LoanState.Returned(returnedOn, fineCents(today));
}
if (dueOn.isBefore(today)) {
long days = ChronoUnit.DAYS.between(dueOn, today);
return new LoanState.Overdue(dueOn, days);
}
return new LoanState.Active(dueOn);
}
这样 state 仍然分配 LoanState,但罚金规则只有一份;热路径用 fineCents 时不需要构造对象,冷路径仍然拿得到完整状态。代价是 state 的 Returned 分支多一次方法调用,JIT 会内联,影响可以忽略。
还有一个反直觉的测量结果。JMH 的 AllocationBenchmark 里,stateRecord 和 directFine 都是:
AllocationBenchmark.directFine avgt 5 6.421 ± 0.435 ns/op
AllocationBenchmark.stateRecord avgt 5 6.954 ± 0.103 ns/op
gc.alloc.rate.norm: ≈ 10⁻⁴ B/op
两个方法的分配率都接近 0。原因是 JMH 的紧循环里,Loan.state 被内联,LoanState 没有逃逸,C2 做了标量替换。JFR 的长循环里,state 返回值经过 switch 和 fineOf 调用,逃逸分析没有完全消除分配。同一段代码,基准形态不同,分配结果不同。这也是为什么 9.3 要用 JFR 的真实负载做 A/B,而不能只信 JMH 的 0 分配。
核心机制二:锁的收益要区分「计数器」与「业务状态」
LoanCounters 是 libraryd 的借阅/归还计数器:
package com.plume.library.domain;
import java.util.concurrent.atomic.LongAdder;
public final class LoanCounters {
private final LongAdder borrowed = new LongAdder();
private final LongAdder returned = new LongAdder();
public void recordBorrowed() {
borrowed.increment();
}
public void recordReturned() {
returned.increment();
}
public long borrowed() {
return borrowed.sum();
}
public long returned() {
return returned.sum();
}
}
LongAdder 把计数分散到多个 cell,线程写各自的 cell,sum() 时再汇总,减少缓存行争用。JMH 在 4 线程下的对照:
java -jar target/benchmarks.jar CounterBenchmark -wi 5 -i 8 -f 1 -w 1s -r 1s
本机结果:
Benchmark Mode Cnt Score Error Units
CounterBenchmark.longAdder thrpt 8 516454.527 ± 10528.575 ops/ms
CounterBenchmark.synchronizedCounter thrpt 8 13330.965 ± 7520.064 ops/ms
LongAdder 约 51.6 万次/毫秒,synchronized 约 1.33 万次/毫秒,差距约 39 倍。synchronized 的误差区间很宽(±7520),因为 4 个线程争同一把锁,测量本身就不稳定;但即使取下界,差距仍然明显。
这个结果能直接推广到业务状态吗?不能。LongAdder 适合「只增不减、允许最终一致」的计数器;它不提供「读取并重置」的原子操作,sum() 返回的是弱一致快照,调用期间可能有其他线程在写。libraryd 的借阅限额判断需要「当前活跃借阅数」,这个数要准确,不能用 LongAdder.sum() 做「小于 5 就借」的判断,否则并发下可能超限。
因此 LoanCounters 只用于指标和展示,不用于业务约束。业务约束仍然走数据库的唯一索引、行锁或 AtomicInteger 的 CAS。把「计数器快」误当成「所有共享状态都可以无锁」,是这类优化的常见事故。
核心机制三:批处理的收益要按「每次操作」和「每次元素」两个口径算
9.1 的 FinePolicyBenchmark 里,batchLoop 和 streamPerLoan 都处理 1000 个 loan:
FinePolicyBenchmark.batchLoop avgt 5 8525.297 ± 1129.897 ns/op
FinePolicyBenchmark.streamPerLoan avgt 5 8760.297 ± 664.681 ns/op
op 是「处理 1000 个 loan」,所以每个 loan 约 8.5 ns 和 8.76 ns。差距 235 ns/1000,约 2.8%,而且误差区间重叠。结论是:在这个数据规模下,批量 for 循环和 stream 没有显著差异。
如果业务需要「一次请求算 10 万个 loan 的罚金」,可以把批处理做成显式的领域方法:
package com.plume.library.domain;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
public final class FineBatch {
private FineBatch() {
}
public static long totalCents(List<Loan> loans, LocalDate today) {
long total = 0;
for (Loan loan : loans) {
total += loan.fineCents(today);
}
return total;
}
public static List<Long> perLoanCents(List<Loan> loans, LocalDate today) {
List<Long> result = new ArrayList<>(loans.size());
for (Loan loan : loans) {
result.add(loan.fineCents(today));
}
return result;
}
}
totalCents 只保留一个 long 累加器,不分配中间列表,适合「只要总额」的场景。perLoanCents 预分配 ArrayList,避免扩容,但会为每个结果分配一个 Long(如果返回 List<Long>)。要避免装箱,可以返回 long[]:
public static long[] perLoanCentsArray(List<Loan> loans, LocalDate today) {
long[] result = new long[loans.size()];
for (int i = 0; i < loans.size(); i++) {
result[i] = loans.get(i).fineCents(today);
}
return result;
}
批处理真正的收益不是「循环比 stream 快」,而是:
- 减少重复的日期计算、上下文查找、数据库往返。
- 把 N 次小查询合并成 1 次批量查询,减少网络和事务开销。
- 让 JIT 有更长的循环可以优化,减少方法调用边界。
这些收益在 JMH 的纯 CPU 循环里看不出来,因为它们发生在数据库和网络层。用 JMH 证明「批量循环比 stream 快 2.8%」,然后据此重写业务代码,是把测量用错了地方。
批处理也有成本:延迟。把 100 个请求合并成一批,最后一个请求要等整批完成;批量越大,p99 延迟越高。libraryd 的借阅请求是交互式的,不适合为了吞吐牺牲单请求延迟。批量罚金计算适合对账、报表、离线任务,不适合在线借阅。
在 libraryd 里落地:一张决策表和一条回归流程
把三个候选折算成决策表:
| 候选 | 证据 | 改动 | 收益 | 成本 | 决策 |
|---|---|---|---|---|---|
Loan.state 分配 | JFR 316 → 1 个分配样本 | 增加 fineCents,热路径使用 | 分配压力显著下降 | 规则重复,需要测试 | 先加方法,按场景切换 |
| 计数器锁 | JMH 39 倍差距 | LongAdder | 计数吞吐提升 | 弱一致,不适合业务约束 | 已用于指标,不扩大到限额 |
| 批量 vs stream | JMH 差异 2.8%,误差重叠 | 不改 | 无可证明收益 | 重写和评审成本 | 保持现状,按可读性选择 |
这张表的关键列不是「收益」,而是「成本」和「决策」。一个优化如果收益很大但成本更高,或者无法回归,就不应该做。
回归流程固定成三步:
- 先测量:用 JFR 记录改动前的负载,保存
summary、hot-methods、allocation-by-class三份输出。没有基线,后面的数字没有意义。 - 再改:一次只改一个变量。加
fineCents就不要同时换LongAdder,否则无法判断收益来自哪一处。 - 再回归:用同一台机器、同一个 JDK、同一份负载重跑 JFR 和 JMH;对比分配样本数、热点方法、p99 延迟。如果指标没有显著变化,回滚改动。
可以用一个轻量的 OptimizationLog record 把决策留下来:
public record OptimizationLog(
String candidate,
String evidence,
String change,
String before,
String after,
String decision) {
}
真实项目里把它写进 ADR 或性能台账,比写在提交信息里更容易被后来的人找到。libraryd 的台账至少记录:日期、JDK 版本、机器型号、JFR 文件路径、JMH 命令、结论。没有这些,半年后没人能复现「当时为什么改」。
常见错误与边界
错误一:看到热点就改,不看热点是否在关键路径。 Loan.state 是 CPU 热点,但 libraryd 的借阅请求大部分时间在 JDBC 和网络;优化 2 ns 的方法对端到端延迟没有感知。先看请求耗时分布,再决定是否优化 CPU 热点。
错误二:把 JMH 的 0 分配当成生产结论。 AllocationBenchmark 的 gc.alloc.rate.norm 接近 0,但 JFR 的真实负载分配了 272 MB。基准形态、内联决策、逃逸分析都会改变结果。分配优化必须用真实负载回归。
错误三:用 LongAdder 做限额判断。 sum() 是弱一致快照,不能保证「当前活跃借阅数小于 5」。并发下两个线程可能同时读到 4,然后都借出第 5 本。限额要用数据库约束、行锁或 CAS,计数器只用于指标。
错误四:为了 2.8% 的差距重写 stream。 JMH 的误差区间重叠时,差异不显著。把 stream 改成 for 循环,损失的是可读性和声明式表达,换来的是测量噪声内的「提升」。这种改动通常不值得评审时间。
错误五:批量越大越好。 批量减少的是每次操作的开销,增加的是单请求延迟和内存占用。在线借阅请求不适合大批量;离线对账可以用,但要给批次大小设上限,并监控 p99。
边界一:优化要看 p99,不只看平均。 平均 2 ns 的方法可能偶尔触发 GC 或锁升级,p99 高得多。JFR 的 jdk.GCPhasePause、jdk.JavaMonitorEnter 和压测的 p99 一起看,不要用一个平均数做决定。
边界二:JFR 的分配压力不是堆占用。 272 MB 是 10 秒内的分配估计,不是堆里常驻的对象。G1 的年轻代回收很快,短命对象不一定造成停顿。判断要不要优化,先看 GC 停顿和分配速率是否超过阈值,不要只看一个总量。
小结
- 优化决策的顺序是「测量 → 改动 → 回归」,不是「看到热点 → 重写」。
Loan.state的分配在 JFR 里从 316 个样本降到 1 个,证明fineCents有效;但基线 GC 只有 2 次,是否值得改取决于 SLA 和维护成本。- JMH 的紧循环会把
LoanState标量替换,gc.alloc.rate.norm接近 0;真实负载才会暴露分配,分配结论必须用 JFR 回归。 LongAdder在 4 线程下约 51.6 万 ops/ms,synchronized约 1.33 万 ops/ms,但LongAdder的弱一致语义不适合限额判断。batchLoop与streamPerLoan的差距约 2.8%,误差区间重叠;批处理真正的收益在网络和数据库往返,不在循环语法。- 决策表要写清证据、改动、收益、成本和决策;没有基线、没有回归的「优化」不应进入主线。
性能工程这一章到此结束。从 JMH 的受控测量,到 JFR 的真实采样,再到收益核算,这三节构成一个闭环:先知道一次调用多快,再知道时间花在哪,最后决定值不值得改。
阅读导航:上一节:9.2 性能剖析:JFR、火焰图与热点定位 · 下一节:《Java 高级编程》目录 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。