一个订单快照对象被三个地方复用: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. 自测
- 一个对象被三个模块改,最先该确认的是哪一层没有切断共享?
- 三类代价里,哪一类加锁解决不了?为什么?
- 四层切断点中,为什么「共享范围越大越该靠外切」?举个反例说明切在内层的后果。
record解决了什么?它的集合字段为什么还需要额外一次拷贝?unmodifiableList与List.copyOf在缓存场景下分别会出什么问题?- 什么证据出现之前,你都不该把「不可变导致变慢」当成结论?