JAVA 用 LockKey 值对象替代 StringJoiner 的优化说明

一、问题回顾

原代码用字符串拼接构建 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 强制不可变)
代码改动量     -                       极小(改类型即可)

结论:
  这是一个 零语义变化、零业务风险、只改数据结构 的纯内存优化。
  字段数量越多、调用次数越多,收益越大。