一、问题回顾
原代码用字符串拼接构建 Map Key:
java
private String buildKeyFromLock(AnaplanPartnerLevelLockDo lockDo) {
return String.join("-",
lockDo.getFiscalYear(), // "2025"
lockDo.getFiscalQuarter(), // "Q1"
lockDo.getProgramGroupCode(), // "groupA"
lockDo.getProgramLevelCode(), // "level1"
lockDo.getIncentiveId() // "id001"
);
// 每次调用 → 堆上产生 ~232 字节新对象,其中 ~160 字节是立即变垃圾的中间产物
}
二、优化方案:LockKey 值对象
java
/**
* 复合 Map Key 值对象。
* Lombok @Value 自动生成:
* - 所有字段 private final(不可变)
* - 全参构造器
* - equals() 基于所有字段逐一比较
* - hashCode() 基于所有字段组合计算
* - getter 方法
*/
@Value
private static class LockKey {
String fiscalYear;
String fiscalQuarter;
String programGroupCode;
String programLevelCode;
String incentiveId;
}
private LockKey buildKeyFromLock(AnaplanPartnerLevelLockDo lockDo) {
return new LockKey(
lockDo.getFiscalYear(),
lockDo.getFiscalQuarter(),
lockDo.getProgramGroupCode(),
lockDo.getProgramLevelCode(),
lockDo.getIncentiveId()
);
// 每次调用 → 只分配 ~36 字节,零字符复制
}
三、内存层面:到底省了什么
旧方案内存图(String.join)
调用 buildKeyFromLock(lockDo) 时,堆上新增:
lockDo.fiscalYear = "2025" ← 已存在,不新建
lockDo.fiscalQuarter = "Q1" ← 已存在,不新建
lockDo.programGroupCode = "groupA" ← 已存在,不新建
lockDo.programLevelCode = "level1" ← 已存在,不新建
lockDo.incentiveId = "id001" ← 已存在,不新建
String.join 额外创建:
┌────────────────────────────────────┐
│ StringJoiner 对象 ~32 字节 │ → 立即变垃圾
│ StringBuilder 对象 ~32 字节 │ → 立即变垃圾
│ StringBuilder char[] ~64 字节 │ → 立即变垃圾
│ 新 String 对象 ~16 字节 │ → 作为 Map Key 使用
│ 新 String 内部 char[] ~56 字节 │ → 把5个字段内容全复制进来
└────────────────────────────────────┘
合计新分配:~200 字节
其中垃圾: ~128 字节(64%)
字段内容被复制了一遍(56 字节的 char[] 是原始数据的副本)
新方案内存图(LockKey)
调用 buildKeyFromLock(lockDo) 时,堆上新增:
lockDo.fiscalYear = "2025" ← 已存在,不新建
lockDo.fiscalQuarter = "Q1" ← 已存在,不新建
...(5个字段都已存在)
new LockKey(...) 只创建:
┌────────────────────────────────────────────────────┐
│ LockKey 对象头 ~16 字节 │
│ 引用1 ──────────────────────→ "2025"(已有对象) │ 4 字节
│ 引用2 ──────────────────────→ "Q1" (已有对象) │ 4 字节
│ 引用3 ──────────────────────→ "groupA"(已有对象) │ 4 字节
│ 引用4 ──────────────────────→ "level1"(已有对象) │ 4 字节
│ 引用5 ──────────────────────→ "id001"(已有对象) │ 4 字节
└────────────────────────────────────────────────────┘
合计新分配:~36 字节
其中垃圾: 0 字节(0%)
字符内容: 一个字节都没有复制
四、数字对比
String.join LockKey 节省比例
────────────────────────────────────────────────────────────────
单次调用堆分配 ~200 字节 ~36 字节 82% ↓
其中纯垃圾 ~128 字节 0 字节 100% ↓
字符内容复制 是(全量) 否(零复制) -
GC 回收压力 高 极低 -
1000 次调用堆分配 ~200 KB ~36 KB 82% ↓
1000 次调用产生垃圾 ~128 KB 0 KB 100% ↓
五、hashCode 计算也更快
Map 的 get / containsKey 操作需要计算 Key 的 hashCode。
旧方案(String key)
java
// String.hashCode() 源码:
public int hashCode() {
if (hash == 0 && value.length > 0) {
// 第一次调用时,遍历所有字符计算
// "2025-Q1-groupA-level1-id001" = 28 个字符,全部参与运算
for (char c : value) {
hash = 31 * hash + c;
}
}
return hash; // 之后缓存起来
}
// 第一次调用:O(N),N = 拼接后字符串的总长度 = 28
新方案(LockKey)
java
// Lombok @Value 生成的 hashCode():
public int hashCode() {
// Objects.hash 内部:
return Objects.hash(fiscalYear, fiscalQuarter, programGroupCode, programLevelCode, incentiveId);
// 实际上是:
// 31 * (31 * (31 * (31 * (31 + f1.hashCode()) + f2.hashCode()) + f3.hashCode()) + f4.hashCode()) + f5.hashCode()
}
// 每个 String.hashCode() 已被 JVM 缓存,直接读取:O(1)
// 5次乘加运算,极快
hashCode 计算对比:
String key → 遍历 28 个字符,每次都是 O(28) 的运算
LockKey → 读取 5 个已缓存的 int,5 次乘加,O(1)
Map 每次 get/containsKey 都要调用 hashCode
1000 次查询:
String key → 28,000 次字符运算
LockKey → 5,000 次整数乘加(快 ~5 倍)
六、equals 比较同样正确
Map 发生 hash 碰撞时,需要调用 equals 确认是否真正相同。
旧方案
java
// String.equals() 逐字符比较:
"2025-Q1-groupA-level1-id001".equals("2025-Q1-groupA-level1-id001")
// 比较 28 个字符,O(28)
// 但如果前缀相同只是最后一位不同,要比较到最后才能判断不等
新方案
java
// Lombok 生成的 LockKey.equals():
public boolean equals(Object o) {
if (this == o) return true; // 同一个对象,直接返回 true
if (!(o instanceof LockKey)) return false;
LockKey other = (LockKey) o;
return Objects.equals(fiscalYear, other.fiscalYear) // 先比最容易区分的字段
&& Objects.equals(fiscalQuarter, other.fiscalQuarter) // 不等立即短路返回
&& Objects.equals(programGroupCode, other.programGroupCode)
&& Objects.equals(programLevelCode, other.programLevelCode)
&& Objects.equals(incentiveId, other.incentiveId);
}
// 任何一个字段不等,立即短路,不再比后续字段
七、为什么 LockKey 可以安全用作 Map Key
HashMap 对 Key 的要求只有两条:
要求 1:equals() 相等的对象,hashCode() 必须相同
要求 2:作为 Key 的对象,在 Map 生命周期内不能改变 hashCode
LockKey 满足情况:
✅ 要求1:Lombok @Value 生成的 equals/hashCode 基于相同字段,天然满足
✅ 要求2:@Value 让所有字段 final,对象创建后不可变,hashCode 永远稳定
✅ 额外:String 本身也是不可变的,引用不会指向变化的内容
八、改动前后代码对比
java
// ========== 改动前 ==========
// Map Key 是 String
Map<String, AnaplanPartnerLevelLockDo> batchLockMap = batch.stream()
.collect(Collectors.toMap(this::buildKeyFromLock, e -> e));
// 查 Map 时也要拼接 String
String sourceKey = buildKeyFromLock(source);
if (!batchLockMap.containsKey(sourceKey)) { ... }
// 每次都 new 一堆中间垃圾对象
private String buildKeyFromLock(AnaplanPartnerLevelLockDo lockDo) {
return String.join("-",
lockDo.getFiscalYear(),
lockDo.getFiscalQuarter(),
lockDo.getProgramGroupCode(),
lockDo.getProgramLevelCode(),
lockDo.getIncentiveId()
);
}
java
// ========== 改动后 ==========
// Map Key 是 LockKey 值对象
Map<LockKey, AnaplanPartnerLevelLockDo> batchLockMap = batch.stream()
.collect(Collectors.toMap(this::buildKeyFromLock, e -> e));
// 查 Map 时也用 LockKey
LockKey sourceKey = buildKeyFromLock(source);
if (!batchLockMap.containsKey(sourceKey)) { ... }
// 只持有引用,零字符复制
private LockKey buildKeyFromLock(AnaplanPartnerLevelLockDo lockDo) {
return new LockKey(
lockDo.getFiscalYear(),
lockDo.getFiscalQuarter(),
lockDo.getProgramGroupCode(),
lockDo.getProgramLevelCode(),
lockDo.getIncentiveId()
);
}
// 不可变值对象,安全用作 Map Key
@Value
private static class LockKey {
String fiscalYear;
String fiscalQuarter;
String programGroupCode;
String programLevelCode;
String incentiveId;
}
代码改动量极小(只改了返回类型和 Map 泛型),但内存效果显著。
九、总结
改动点 String.join LockKey
──────────────────────────────────────────────────────
内存分配/次 ~200 字节 ~36 字节(节省 82%)
垃圾产生/次 ~128 字节中间对象 0(无垃圾)
字符复制 全量复制 零复制
hashCode O(字符串长度) O(1)(复用缓存)
equals O(字符串长度) O(字段数量),支持短路
Map Key 安全 ✅(String 不可变) ✅(@Value 强制不可变)
代码改动量 - 极小(改类型即可)
结论:
这是一个 零语义变化、零业务风险、只改数据结构 的纯内存优化。
字段数量越多、调用次数越多,收益越大。