背景

摘要:本文通过分析某合约交易系统批量下单接口的高频调用链,识别出三处关键堆分配优化点:1)消除中间参数载体 record(节省 560 bytes/调用);2)集合按实际大小预分配;3)Log Guard 内消除重复 Getter 调用。优化后,该路径 Young Gen 短命垃圾从 6.33 MB/s 降至 0.99 MB/s,每秒减少 5.34 MB 分配压力。文章详细介绍了分析方法、优化实现、量化收益及对象大小估算方法,强调编译期确定性优化优于依赖 JIT 标量替换。

某合约交易系统的机器人批量下单接口在高峰期承受 1 万次调用/秒,每次携带 10 笔订单。通过对调用链逐层做堆分配分析,发现多处可消除的冗余分配,优化后将此路径的 Young Gen 短命垃圾从 6.3 MB/s 降至 1.0 MB/s


分析方法

对调用链每一层方法重复 5 步:

  1. 签名/入参:区分栈分配(原始类型)与堆分配(对象引用)
  2. 逐行分配点:标注每个 new、装箱、字符串拼接
  3. 7 类隐藏分配:日志装箱、Builder 临时对象、Lambda 捕获、Iterator、泛型装箱、Optional/Stream、异常对象
  4. 下层调用:递归向下分析
  5. 逃逸分析:对象是否逃逸方法边界——不逃逸的对象 JIT 有机会栈分配

调用链全景(简化)

BatchOrderBiz.batchCreate(param)                  ← RPC 入口
└── OrderValidator.validate(param)                ← 校验通过返回 null(零分配)
└── OrderService.batchCreate(param)               ← 主流程
    ├── ContractConfig.get(contractId)            ← 外部缓存
    ├── prepareContext(uid, ...)                  ← 准备上下文
    └── for each SimpleOrder in param:            ← 热循环
        ├── IdempotentService.check(clientOrderId)
        └── RequestBuilder.buildBatch(...)        ← 构建撮合请求
            ├── CommonOrderParams.fromBatch(...)  ← ⚠️ 中间 record,每笔分配一次
            └── buildCommon(params)              ← 填充最终请求对象

热点分配源 TOP 5

排名 分配源 估算/笔 是否必要
1 CommonOrderParams 中间 record ~56 bytes 否,可消除
2 最终请求对象(传给撮合引擎) ~300 bytes
3 ArrayList 扩容中间数组 视批量大小 否,可预分配
4 日志参数 Object[] + int 装箱 ~32 bytes/条 否(guard 可覆盖)
5 RPC 返回值包装对象 ~48 bytes

三项优化

优化 1:消除中间参数载体 record(核心)

问题:每次 buildBatch 调用都先构造一个 15 字段的 CommonOrderParams record(~56 bytes),仅用于向下传参,随即成为垃圾:

// Before:每笔订单在循环内分配一个临时 record
public static PlaceOrderRequest buildBatch(...) {
    PlaceOrderRequest req = buildCommon(
        CommonOrderParams.fromBatch(batchParam, simpleOrder, symbol, feeRate));
    //                  ↑ 56 bytes,生命周期 < 1 方法调用
    req.setOrderId(orderId);
    return req;
}
// After:内联消除,直接访问原始参数
public static PlaceOrderRequest buildBatch(...) {
    PlaceOrderRequest req = new PlaceOrderRequest();
    byte orderType = batchParam.getType();          // 直接读 batchParam
    req.setSide(simpleOrder.getSide());              // 直接读 simpleOrder
    req.setPrice(simpleOrder.getPrice());
    // ... 其余字段同样直接填充,无中间对象
    return req;
}

为什么不依赖 JIT 标量替换?

JIT C2 的逃逸分析(EA)理论上可以对不逃逸的对象做标量替换(scalar replacement),消除堆分配。但以下任一条件都会阻止它:

  • buildCommon 字节码超过内联限制(-XX:MaxInlineSize 默认 35 字节),内联失败 → EA 无法跨方法传播
  • 冷启动阶段未达到 C2 编译阈值(默认 10,000 次),均为解释执行
  • 调用图复杂度超出 EA 分析范围

手动消除让优化从「JIT 可能做」变为「编译期确定」,冷启动同样受益。


优化 2:集合按实际大小预分配

// Before:两个列表均懒分配(默认容量 10),且 orders 赋值顺序靠后
List<String>  duplicatedIds = new ArrayList<>();
List<Request> requestList   = new ArrayList<>();
List<Order>   orders        = param.getOrders();
// After
List<Order>   orders        = param.getOrders();
int n = orders.size();
List<String>  duplicatedIds = new ArrayList<>();     // ← 保持懒分配
List<Request> requestList   = new ArrayList<>(n);    // ← 预分配

关键细节——两个列表策略不同:

  • requestList:预期承载 ~n 个元素 → 按 n 预分配,避免扩容
  • duplicatedIds:重复订单是异常情况,正常路径为空。new ArrayList<>() 的 backing array 是类级共享静态数组,零额外堆分配;若改为 new ArrayList<>(n) 反而在正常路径多分配 16 + 4n bytes

N=20 时 requestList 不预分配的扩容代价:

Object[10]=56B → Object[15]=76B → Object[22]=104B
累计分配 236B / 132B 成垃圾 / 2 次 arraycopy

预分配后:Object[20]=96B,一次到位。


优化 3:Log Guard 内消除重复 Getter 调用

// Before:getOrders() 调用两次
if (logConfig.isEnabled(uid, contractId)) {
    int count = param.getOrders() == null ? 0 : param.getOrders().size();
    log.info("entry uid={} count={}", uid, count);
}
// After
if (logConfig.isEnabled(uid, contractId)) {
    var orders = param.getOrders();
    int count = orders == null ? 0 : orders.size();
    log.info("entry uid={} count={}", uid, count);
}

无内存影响,消除一次冗余方法调用,属代码质量修复。


量化收益

场景:1 万次调用/秒,每批 10 笔订单

每次调用 每秒
优化前(改动涉及部分) 664 bytes 6.33 MB/s
优化后(改动涉及部分) 104 bytes 0.99 MB/s
节省 560 bytes 5.34 MB/s

节省来源拆解(N=10):

改动 节省/调用 占比
消除 CommonOrderParams × 10 560 bytes 100%
requestList 预分配 0 bytes(N=10 恰等于默认容量 10) 0%
重复 Getter 修复 0 bytes 0%

requestList 预分配对 N=10 无收益;N > 10 开始产生收益,N=50 时可额外节省 664 bytes/调用。

Young Gen 压力:

每秒消除 10 万个 CommonOrderParams 短命对象(56 bytes 级别),对应 5.34 MB/s Young Gen 短命垃圾。以 Eden 200 MB 估算,仅此路径的填充速率从 5.34 MB/s 降至接近 0,minor GC 频率相应降低。


对象大小估算方法

以 HotSpot JDK 21、compressed oops(堆 < 32 GB)为基准:

Object 大小公式:

对象大小 = 12 bytes(header)+ 字段大小(按类型对齐)→ 上取整到 8 bytes
字段类型 大小(compressed oops)
引用(Object ref) 4 bytes
int / float 4 bytes
long / double 8 bytes
byte / boolean 1 byte
Object header 12 bytes

ArrayList 内存布局:

ArrayList 对象:    24 bytes(header 12 + size 4 + modCount 4 + ref 4)
Object[] 数组:     16 bytes(header)+ 4 bytes × capacity
new ArrayList<>(): backing array 懒分配(首次 add 才分配 Object[10])
new ArrayList<>(n):立即分配 Object[n]

CommonOrderParams record(15 字段):

header:           12 bytes
6 个引用字段:     24 bytes
2 个 int 字段:     8 bytes
7 个 byte 字段:    8 bytes(含对齐 padding)
合计:             52 → 对齐到 56 bytes

经验总结

1. 中间参数载体是热路径隐性成本

Builder、VO、record 用作参数传递时,若在循环内调用且生命周期极短,优先考虑内联消除,不依赖 JIT。

2. 集合预分配要区分语义

集合语义 策略
预期有 N 个元素(正常路径) new ArrayList<>(n)
通常为空(异常路径) new ArrayList<>() 懒分配

盲目预分配所有集合可能在正常路径反而多分配。

3. JIT 标量替换不是底线

代码优化应在编译期确定,JIT 是锦上添花。内联限制、冷启动、EA 传播失败都会让「JIT 应该能优化」的假设落空。

4. 热路径日志 guard 要彻底

isEnabled() guard 内部的重复计算同样需要消除,即便是看似廉价的 getter,在高频路径下也值得审查。

5. 量化是排优先级的前提

「5.34 MB/s 短命垃圾」这个数字决定了此改动是否值得做。没有量化的优化讨论无法取得共识。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐