背景
摘要:本文通过分析某合约交易系统批量下单接口的高频调用链,识别出三处关键堆分配优化点: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 步:
- 签名/入参:区分栈分配(原始类型)与堆分配(对象引用)
- 逐行分配点:标注每个
new、装箱、字符串拼接 - 7 类隐藏分配:日志装箱、Builder 临时对象、Lambda 捕获、Iterator、泛型装箱、Optional/Stream、异常对象
- 下层调用:递归向下分析
- 逃逸分析:对象是否逃逸方法边界——不逃逸的对象 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 字段的CommonOrderParamsrecord(~56 bytes),仅用于向下传参,随即成为垃圾:
// Before:每笔订单在循环内分配一个临时 recordpublicstaticPlaceOrderRequestbuildBatch(...){PlaceOrderRequestreq=buildCommon(CommonOrderParams.fromBatch(batchParam,simpleOrder,symbol,feeRate));// ↑ 56 bytes,生命周期 < 1 方法调用req.setOrderId(orderId);returnreq;}// After:内联消除,直接访问原始参数publicstaticPlaceOrderRequestbuildBatch(...){PlaceOrderRequestreq=newPlaceOrderRequest();byteorderType=batchParam.getType();// 直接读 batchParamreq.setSide(simpleOrder.getSide());// 直接读 simpleOrderreq.setPrice(simpleOrder.getPrice());// ... 其余字段同样直接填充,无中间对象returnreq;}为什么不依赖 JIT 标量替换?
JIT C2 的逃逸分析(EA)理论上可以对不逃逸的对象做标量替换(scalar replacement),消除堆分配。但以下任一条件都会阻止它:
buildCommon字节码超过内联限制(-XX:MaxInlineSize默认 35 字节),内联失败 → EA 无法跨方法传播- 冷启动阶段未达到 C2 编译阈值(默认 10,000 次),均为解释执行
- 调用图复杂度超出 EA 分析范围
手动消除让优化从「JIT 可能做」变为「编译期确定」,冷启动同样受益。
优化 2:集合按实际大小预分配
// Before:两个列表均懒分配(默认容量 10),且 orders 赋值顺序靠后List<String>duplicatedIds=newArrayList<>();List<Request>requestList=newArrayList<>();List<Order>orders=param.getOrders();// AfterList<Order>orders=param.getOrders();intn=orders.size();List<String>duplicatedIds=newArrayList<>();// ← 保持懒分配List<Request>requestList=newArrayList<>(n);// ← 预分配关键细节——两个列表策略不同:
requestList:预期承载 ~n 个元素 → 按 n 预分配,避免扩容duplicatedIds:重复订单是异常情况,正常路径为空。new ArrayList<>()的 backing array 是类级共享静态数组,零额外堆分配;若改为new ArrayList<>(n)反而在正常路径多分配16 + 4nbytes
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)){intcount=param.getOrders()==null?0:param.getOrders().size();log.info("entry uid={} count={}",uid,count);}// Afterif(logConfig.isEnabled(uid,contractId)){varorders=param.getOrders();intcount=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/sYoung 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]CommonOrderParamsrecord(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 短命垃圾」这个数字决定了此改动是否值得做。没有量化的优化讨论无法取得共识。