《Java 高级编程》9.3 优化决策:分配、锁与批处理的收益核算

JFR 找到热点之后,下一步不是立刻改代码,而是算收益。本节用同一台机器对比 Loan.state 与直接罚金计算的分配采样,用 JMH 量化 synchronized 与 LongAdder 的差距,并比较批量循环与 stream,最后给出 libraryd 的「先测量、再改、再回归」决策表。

本节目标:把 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.ExecutionSample156126
jdk.ObjectAllocationSample3161
分配压力第一名LoanState$Overdue 79.39%java.lang.String 100%(1 个样本)
循环轮次91427336

分配采样从 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 streamJMH 差异 2.8%,误差重叠不改无可证明收益重写和评审成本保持现状,按可读性选择

这张表的关键列不是「收益」,而是「成本」和「决策」。一个优化如果收益很大但成本更高,或者无法回归,就不应该做。

回归流程固定成三步:

  1. 先测量:用 JFR 记录改动前的负载,保存 summary、hot-methods、allocation-by-class 三份输出。没有基线,后面的数字没有意义。
  2. 再改:一次只改一个变量。加 fineCents 就不要同时换 LongAdder,否则无法判断收益来自哪一处。
  3. 再回归:用同一台机器、同一个 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 高级编程》目录 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发