本节摘要:val 只锁引用,不锁内容;只读集合只锁结构,不锁元素字段。"不可变"有三层——只读引用、只读视图、真不可变对象——三层各挡一类事故,混为一谈就会得到虚假的安全感。本节解剖一个真实的竞态事故,讲清三层不可变的分工、替换式更新(copy 出新对象)为何天然线程安全,以及把可变性赶到架构边界的分层策略。
团队里的新同学听了"Kotlin 用 val 更安全"的说法,把购物车仓库改成了这样:
class CartRepo { val items = mutableListOf<CartItem>() // val 修饰的 可变列表 fun add(item: CartItem) { items.add(item) } fun total(): Long = items.sumOf { it.price * it.count } }
结果线上照旧出现偶现崩溃与错单:ConcurrentModificationException 时有时无,总价偶尔多算一件。新同学困惑了——明明全是 val。
问题在于三层不可变被当成了同一层。val items 锁住的只有引用(items 永远指向这一个列表对象),对列表内容(add 进去的元素)没有任何约束。主线程 add、后台线程 sumOf 遍历,结构修改与遍历交错,事故与 Java 版一字不差。val 是引用的锁,不是内容的锁——这把锁锁错了门。
把三层拆开看:
// 第一层 只读引用:items 不能再指向别的列表 但内容随便改 val items = mutableListOf<CartItem>() // 第二层 只读视图:通过这个引用改不了结构 但同源可变引用仍能改(4.1 节) val view: List<CartItem> = items // 第三层 真不可变对象:引用与内容都不会变 全线程安全 val frozen: List<CartItem> = listOf(CartItem("S1", 9900, 1))
只有第三层可以毫无保留地跨线程共享——没有写,就没有竞争。前两层是"减少误改面积"的工程手段,第三层才是"消灭竞态"的并发手段。
内容不可变的对象怎么"改"?答案是造一个新的。第 3 章的 copy 在这里显示出真正的分量:
data class Cart(val items: List<CartItem>) { // 全 val 全只读 fun withItem(item: CartItem) = copy(items = items + item) // 加一件 = 新购物车 fun without(sku: String) = copy(items = items.filterNot { it.sku == sku }) fun total() = items.sumOf { it.price * it.count } }
withItem 不修改任何既有对象,它基于旧购物卡构造新购物车。旧实例的持有者(可能是正在遍历的后台线程、可能是正在渲染的 UI、可能是单元测试里的断言)永远不会看到一半更新一半没更新的中间态——它们手里的引用指向的内存从头到尾没变过。这就是替换式更新的并发红利:不共享可变状态,就没有竞态;没有竞态,就不需要锁。
对照 Java 的思路差异很本质。Java 生态面对并发修改的答案偏向"怎么安全地改":synchronized、ConcurrentHashMap、CopyOnWriteArrayList,都是在"原地修改"的前提下加保护。Kotlin 不可变数据的答案是"不改,换一个"——问题被釜底抽薪。安卓官方架构的 UiState 每次都是新对象、Compose 框架的重组依赖输入不变、函数式核心的领域层全部走替换式更新,都是这条路线的延伸。
替换式更新需要一个"引用会变"的挂点——Java 时代是 AtomicReference 或 volatile 字段,安卓现代写法是 StateFlow:
class CartViewModel : ViewModel() { private val _cart = MutableStateFlow(Cart(emptyList())) val cart: StateFlow<Cart> = _cart.asStateFlow() fun add(item: CartItem) { _cart.update { it.withItem(item) } // 原子地 基于旧值造新值 } }
update 是"读取当前值、apply 变换、原子替换"的循环重试封装,并发下的丢失更新由它兜住。数据的每次演化都是 copy 出的新 Cart 实例,collect 到的每个状态都是完整快照。UI 拿到快照渲染,渲染期间数据再怎么演化都与这份快照无关——不存在"渲染到一半数据变了"的撕裂。第 8 章会把这套单向数据流讲完整,这里只需要看到它对第 4 层不可变的依赖有多深。
下图把一个典型安卓应用的线程世界与数据快照的关系画在一起:不可变快照可以自由穿越线程边界,可变状态的流动必须经过收口点。

不可变不是零成本:copy 要分配新对象,大列表的 items + item 是一次全量复制。工程答案不是放弃不可变,而是把可变性限制在边界上的小口袋里:
回头看本节开头的事故代码,按这个策略重构后:
class CartRepo { private val _cart = MutableStateFlow(Cart(emptyList())) val cart: StateFlow<Cart> = _cart.asStateFlow() fun add(item: CartItem) { _cart.update { it.withItem(item) } } fun total(): Long = cart.value.total() // 快照上的纯计算 }
可变面从"一个被全工程共享的 MutableList"收缩到"StateFlow 内部的一个槽位"。排查竞态时,你需要看的地方从全工程变成一个 update 调用点——这就是"修改点显式化"在并发场景的兑现。
替换式更新不是免费的,三条代价要心里有数。其一,分配压力:高频更新的大对象每次全量 copy,GC 压力上升;安卓上的实际解法是控制快照粒度(购物车拆成元数据与条目列表两个流)、或用持久化数据结构思路(标准库未内置,框架层如 Compose 的 SnapshotState 提供了部分能力)。其二,拜占庭更新:多个入口并发 update 同一流时,变换函数必须无副作用且幂等倾向,否则重试语义会放大业务问题。其三,纪律成本:不可变纪律靠 review 维持太脆,Detekt 与 ktlint 都有规则盯 var 与可变集合的出现位置,第 8 章把它们接进 CI。
替换式更新并非 Kotlin 独创,Java 并发包早有对应物,对照着看能更准确地定位 StateFlow 的生态位。AtomicReference 的 compareAndSet:最早的原子替换原语,手工实现"读改写"循环,正确但啰嗦——业务代码里写 CAS 循环是上个时代的姿势。CopyOnWriteArrayList:每次修改复制整个数组,读多写少场景的并发容器——它就是"替换式更新"在 Java 集合层的具象,代价与本节讨论的 copy 分配压力相同。volatile 字段:保证可见性不保证原子性,单独使用时"读-改-写"仍会丢更新——它解决的是替换式更新的发布问题,不是更新本身的并发安全。StateFlow 的 update 封装的是 AtomicReference 的 CAS 循环,再把"变更通知"(收集者自动收到新值)叠加其上——可以把它理解为"带订阅功能的 AtomicReference",这正是它在安卓状态管理里全面胜出的原因:替换的安全它有,通知的能力它也有。
定位清楚了,选型也清楚:纯计数器用 AtomicLong(无通知需求);跨线程的单值状态用 StateFlow(替换加通知);复杂的数据结构演化用不可变数据加 copy(第 3 章纪律);真需要并发写入的容器(如正在编写的下载队列)才考虑并发集合——而多数"真需要"在重构为替换式更新后会发现并不需要。
问:不可变快照会不会让内存占用翻倍——每次更新都复制一份?
对象头的复制是引用复制,不是深拷贝——copy 一个 data class 只分配一个新对象,字段引用原样指向旧值。真正的全量复制发生在列表字段上(items + item 会复制列表结构,元素仍是引用)。所以内存压力的主要来源是大列表的频繁结构性更新,对策是控制快照粒度:把"每次变化的部分"(比如最后一页数据)与"稳定的大头"(历史数据)拆成不同的流,变化只复制小列表。极端高频场景(每帧更新的动画状态)确实不适合全量快照,那属于 Compose 的 SnapshotState 或专门的帧缓冲设计——业务状态的更新频率远低于此,快照的分配开销在安卓运行时(并发复制回收器)下通常无感。
问:val 加 StateFlow 之后,var 在工程里还有合法位置吗?
有,而且明确:局部临时状态(算法中的累加器、循环游标)、构造期内的自举变量、以及唯一收口点(StateFlow 内部的 value、缓存的写穿点)。判断标准是可见范围:局部 var 的风险半径只有几行,全局 var 的风险半径是全工程。评审时对 var 的态度应该随作用域放大而收紧——方法内的 var 几乎不用看,类级 var 要问理由,跨模块可见的 var 基本都该改设计。这个"按作用域定价"的视角,比"全面禁止 var"的教条更接近工程真相。
到此,三根支柱立完:空安全、穷举、不可变。下一章进入全册的高潮——协程,把"忘了取消""回调地狱""线程裸奔"三类并发事故一并送进结构化并发的回收站。