2990 字
约 9 分钟
16
S04-A09 · 可变状态与不可变设计:在哪一层切断共享

一个订单快照对象被三个地方复用:A 改了状态字段,B 在它上面挂了计算结果,C 拿到的对象里已经混进了前两家的中间值。出了 bug,三个人都说自己的代码是只读的——而且他们说的都是真的。

这类争论没法靠读代码解决,因为可变共享的代价从来不在某一行,而在所有权没有落在任何一层。谁都能拿到引用,就等于谁都不负责。

所以不可变不是全局目标,而是手段;目的是在正确的位置切断共享。判据只有一句:每一次不可变化,都要能说出它挡住了哪条具体的修改路径。 说不出,就是把 final 当护身符。


1. 共享可变状态的三类代价

代价 观测特征 典型误判
竞态 偶发、不可复现、随并发度与机器数变化 本地压测不复现 → 当成环境问题
时序耦合 同一输入,结果取决于调用顺序 两次日志一样 → 其实顺序不同
调试困难 断点处的值与你读代码的预期不符 追问谁改的 → 没人认,因为都对

三者的修复成本不同。竞态可以靠加锁,也可以靠切断共享;时序耦合加锁没用,锁不约束顺序;调试困难的成本是时间,一次能烧掉半天,而且烧完往往只是把范围缩小一点。

2. 四层切断点与各层成本

层 切什么 手段 成本 适用
值对象 对象自身可变性 字段 final、构造时校验 每次变更产新对象 金额、区间、ID、坐标
DTO 跨层边界的引用 出站时构造新对象 一次浅拷贝 接口出入参
集合返回 集合被外部增删 返回快照或只读视图 快照 O(n)、视图 O(1) 缓存、注册表、配置
跨线程传递 线程间的引用 不可变消息或深拷贝 序列化或深拷贝成本 线程池、异步任务、事件

四层不必都做。判据是:共享范围越大、生命周期越长,切断点就越该靠外(越早切)。一个对象只在方法里活三行,切它没有收益;一个对象进缓存、被多线程读、被下游追加字段,就必须在最外层切断,因为内部的修改路径你数不清。

3. 构造代价:什么时候可接受

代价 可接受 成问题
新对象分配 每请求几十到几百个,年轻代能吸收 热点路径每请求上万次
防御性拷贝 O(n) n 是配置、字典、小列表 n 是结果集、消息体
变更时重建 变更频率低:配置、状态转移 循环里逐元素累加

判断依据不是「分配贵不贵」,而是分配速率是否已经出现在 GC 日志或分配采样里。没出现在那里的分配,都是可接受的;争论「不可变慢」的人,通常两个证据都没拿。

// 值对象:字段 final,构造时校验,变更产新实例
public record Money(long cents, String currency) {
    public Money {
        if (cents < 0) throw new IllegalArgumentException("cents");
    }
    public Money plus(Money o) {
        return new Money(cents + o.cents, currency);
    }
}

4. 防御性拷贝放边界还是深处

位置 优点 缺点
边界:入参与出参 一次拷贝覆盖所有内部代码 内部仍可变,靠约定
深处:每个 getter 局部安全,谁调都安全 调用次数放大拷贝成本
都不做,靠文档 零成本 一定有人改,且在出事后

判据是「信任边界」:跨越模块、线程、序列化的地方拷一次,边界内部靠约定与测试。拷在深处看起来更安全,实际是把一次 O(n) 变成每请求 O(n) 次。

// 入参在边界转成不可变,边界内部不再需要防御
public OrderView get(String id) {
    Order o = repo.load(id);
    return new OrderView(o.id(), o.status(), List.copyOf(o.items()));
}

5. record 与 final 能解决什么、不能解决什么

手段 解决 不解决
final 字段 引用不再被替换 引用对象内部仍可变
record 值语义、构造器校验、equals/hashCode 数组与集合字段仍是引用
只读视图 外部无法增删 持有底层引用者仍能改
深拷贝 彻底隔离 成本、泛型擦除、循环引用
// record 的集合字段仍是可变引用,构造器里补一次拷贝才算真不可变
public record Snapshot(List<Item> items) {
    public Snapshot {
        items = List.copyOf(items);
    }
}

record 解决的是「这个对象不该有 setter」,不解决「这个对象持有的东西不该被改」。把这两件事混同,会得到一批看起来很现代、实际照样被外部改掉的数据类。

6. 不可变与性能的交换

因素 影响 观测手段
分配速率 年轻代回收频率与停顿 GC 日志、分配采样
逃逸分析 未逃逸对象可栈上分配、标量替换 JIT 日志确认,别假设
拷贝带宽 大对象深拷贝吃内存带宽 采样里数组拷贝帧占比
对象头与指针 对象数变多,额外占用上升 堆直方图与实例数
// 先量分配速率,再谈不可变是否成为瓶颈
$ jcmd 5123 GC.class_histogram | head -20
$ java -XX:+UnlockDiagnosticVMOptions -XX:+PrintEscapeAnalysis -jar app.jar

「不可变一定慢」和「不可变一定快」都是错的。逃逸分析能消掉一部分分配,但只在对象不逃逸、方法被充分编译时成立;按 JDK 17 的 C2 行为核对,别把优化当保证。

7. 不可变视图与真不可变的差别

做法 外部能改 底层能改 语义
unmodifiableList 不能 能,视图跟着变 只读视图
List.copyOf 不能 不能,是快照 真不可变
直接返回内部引用 能 能 无保护
Arrays.asList 部分能,可 set 能 半吊子
// 视图不是快照:持有底层引用的人一改,读的人就看到变化
List<Rule> view = Collections.unmodifiableList(rules);
List<Rule> snap = List.copyOf(rules);
rules.add(extra);   // view 变长,snap 不变

视图的代价是 O(1),因为它什么都没复制,也就什么都没保护。缓存里放视图、注册表返回视图,是最常见的错误组合:读的人以为拿到了稳定值,写的人以为没人看见。

8. 可变是合理的场景

场景 为什么该可变 前提
构建器 分步装配,最后才冻结 build 之后禁止再写
累加器与聚合 每元素更新一次,避免 O(n) 分配 局部变量不出方法
性能敏感内循环 复用缓冲、原地排序 所有权写进注释
大对象图编辑 深拷贝成本高 单线程或分区隔离

9. 团队约定与语言能力的匹配

语言能力 靠什么保证 失效方式
Java record / sealed 编译期约束 集合字段漏拷
Kotlin val 加不可变集合 编译期加标准库 平台类型混入可变实现
Python 约定式 评审与类型标注 运行时无人拦
JS Object.freeze 浅冻结 嵌套对象照样可变

语言不帮忙时,能靠的只有三样:边界处的拷贝、能变红的约束测试、写进类注释的所有权说明。缺一样,约定会在三个月内失效——失效的第一个信号,是评审里开始出现「这个应该没事吧」。

可执行的做法只有一个:给每个对外暴露的不可变类型补一条「试图修改应当失败」的测试。约定写在注释里会腐烂,写成断言才会在有人改它的时候变红。

10. 度量

度量 定义 读法
共享可变点数 被两个以上模块写同一字段的位置数 按模块统计,越少越好
并发缺陷数 与共享状态相关的缺陷 / 全部缺陷 看趋势,不看单点
拷贝开销占比 拷贝相关采样帧 / 总采样帧 超阈值说明切得太深
约定违反次数 评审中被指出的可变共享 判断约定是否还活着

11. 排错:六种「明明 final 了还在变」

现象 原因 处理
本地压测不复现 竞态依赖并发度与机器数 提并发、插同步点、开检测工具
加了 final 还在变 引用不可变,对象可变 找 setter 与集合字段
返回副本后下游仍读到新值 返回的是视图不是快照 换成 List.copyOf
深拷贝后性能掉一半 拷贝被放大到每次 getter 拷贝挪到边界只做一次
不同线程读到不同值 缺 happens-before 用不可变对象或同步发布
构建器复用出脏数据 build 后仍可写 build 时冻结,或禁止复用实例

12. 边界:什么时候强行不可变化代价更大

场景 结论 理由
单线程批处理 不必 无并发,也无跨模块共享
对象生命周期极短 不必 逃逸不出方法,切断无收益
性能已证明是瓶颈 先改可变再谈 分配与拷贝已在采样里排前面
跨模块共享且长生命周期 必须切断 修改路径无法枚举
对外 API 出入参 必须拷贝或使用不可变类型 调用方行为不可控
无语言支持也无评审 只能靠边界拷贝 约定无法执行

反过来还有一条:不要用「以后可能要并发」作为不可变化的理由。为假想中的并发付出的拷贝成本是确定的,收益是不确定的;等真出现并发,再按第 2 节的四层去切,位置反而更准。


13. 自测

  1. 一个对象被三个模块改,最先该确认的是哪一层没有切断共享?
  2. 三类代价里,哪一类加锁解决不了?为什么?
  3. 四层切断点中,为什么「共享范围越大越该靠外切」?举个反例说明切在内层的后果。
  4. record 解决了什么?它的集合字段为什么还需要额外一次拷贝?
  5. unmodifiableList 与 List.copyOf 在缓存场景下分别会出什么问题?
  6. 什么证据出现之前,你都不该把「不可变导致变慢」当成结论?
S04-A09 · 可变状态与不可变设计:在哪一层切断共享
https://www.clxhxhhr.top/posts/6274/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。