值真的贵吗
前面二十二章一直在说不可变有多好。这一章摊账单:哪些地方它确实更慢、慢多少、有什么逃生舱。也包括一个不太好意思的问题——第 2 章那个「快 600 多倍」,是不是挑了个对自己有利的比法?
三件事,各做一百万次:
① 往一个集合末尾加一个元素(做一百万次) ② 随机读取第 i 个元素 ③ 遍历全部元素求和
问:和 Java 的 ArrayList / int[] 比,
Clojure 的持久化向量在哪一项差距最大?
先给结论
答案是 C:遍历,而且这个答案对大多数人是反直觉的。
操作 持久化向量 vs 原生数组 为什么 ────────────────────────────────────────────────────────────── 末尾加元素 接近,有时更快 尾巴优化,多数时候只碰 32 格 随机读第 i 个 慢 2–3 倍 走 log₃₂(n) 层,一百万个元素是 4 跳 遍历求和 慢 3–10 倍 ★ 缓存不友好,见下 中间插入 / 删除 快得多 数组要挪动后面所有元素 「改一个,旧的还要」 快几百倍 数组必须整个复制一份(第 2 章)
为什么遍历最吃亏?因为缓存。
int[] 在内存里是连续的一整块。
CPU 读第一个元素时会顺手把后面 64 字节(一整条缓存行)拉进来,
于是接下来十几个元素都是免费的。硬件的预取器还会继续往后猜。
持久化向量是一堆分散的 32 格小数组, 彼此之间在内存里没有连续关系。每跳到下一个叶子, 很可能就是一次缓存未命中。
这是不可变数据结构最真实、最难绕开的一笔成本, 而且它不会出现在复杂度分析里——两边都是 O(n), 常数差了一个数量级。
不可变的代价不在「复制」(第 2 章证明了不用复制), 在间接寻址和缓存局部性。 你付的是访问速度的常数因子,买的是共享、并发安全和时间旅行。
逃生舱:瞬变
有一类场景,不可变的成本纯属浪费:你在造一个新集合的过程中。
;; 造一个一百万元素的向量 (reduce conj [] (range 1000000))
这里产生了一百万个中间版本,而这些中间版本没有任何人看见过——
它们生下来就是为了立刻被下一次 conj 取代。为它们维护结构共享,
是在为一份没人要的保证付钱。
transient 就是为这个准备的:
(persistent! (reduce conj! (transient []) (range 1000000)))
瞬变的规矩只有一条:从 transient 到 persistent!
之间,别让任何人看见它。在这段窗口里它允许就地修改
(因为没有别人持有引用,改了不会影响任何人);
persistent! 一调用,它立刻变回不可变的值,再改就报错。
三条使用要点:
- 必须用返回值。
(conj! t x)返回的可能是 另一个对象,不能只依赖副作用。写(reduce conj! t xs)就自然对了。 - 不能跨线程。瞬变绑定在创建它的线程上。
- 只用在局部。如果它逃出了当前函数,说明你用错了。
好消息是:into、mapv、filterv、
frequencies、group-by 这些函数内部已经用了瞬变。
所以大多数时候你不需要手写——用 into 就已经是快的那条路了。
关于第 2 章那个 600 倍
现在回答那个不太好意思的问题。第 2 章的对比是:
100 万元素向量 assoc 一个 0.0017 ms 100 万元素的数组整个复制一遍 1.04 ms
这个比法公平吗?要分情况说:
如果你需要「改完之后旧版本还能用」——公平,而且是唯一的比法。 数组要满足这个需求,除了整个复制没有别的办法。 这正是防御性复制、快照、撤销、并发读所要求的场景。
如果你不需要旧版本——不公平。
arr[i] = x 是一次内存写,几个纳秒,
比 assoc 快几十倍。这时候持久化向量纯属多余。
所以老实的说法是:
持久化数据结构不是「更快的数据结构」, 是「让『保留旧版本』这件事从 O(n) 降到 O(log n)」的数据结构。 如果你根本不需要旧版本,它只会更慢。
而这本书的立场是:你需要旧版本的次数,比你以为的多得多—— 每一次防御性复制、每一次「这个对象线程安全吗」、 每一次撤销、每一次快照读,都是在需要它。 只是在可变的世界里,这个需求被伪装成了别的问题。
什么时候老老实实用可变
Clojure 从来没禁止你用数组。该用就用:
;; 数值计算、图像处理、紧凑循环
(let [arr (double-array 1000000)]
(dotimes [i 1000000]
(aset arr i (Math/sin i))) ;; 就地写,和 Java 一样快
(areduce arr i ret 0.0 (+ ret (aget arr i))))
判断标准很简单:
用不可变,如果…… 用可变,如果…… ───────────────────────────────────────────────────────────── 数据要被多处共享 完全局部,出不了这个函数 需要并发访问 单线程紧凑循环 需要保留历史 / 快照 / 撤销 只关心最终结果 形状复杂、嵌套深 一大块同类型的数字 性能不是这段代码的瓶颈 这里就是热点,而且量过
最后一行最重要:先量,再优化。 绝大多数应用的瓶颈在 I/O、在数据库、在网络, 不在于你的 map 是不是持久化的。
你其实已经在为不可变付过一次钱了,而且付得心甘情愿:
String 是不可变的。
Java 为此付出了实实在在的成本:每次拼接产生新对象,
所以才需要 StringBuilder(这就是 Java 版的 transient——
在局部可变,最后 toString() 变回不可变,
和 transient / persistent! 是同一个模式)。
但没有人认为「String 不可变」是个错误设计。 因为它买到的东西太值了:可以放心传递、可以当 map 的 key、 可以缓存哈希、线程安全、不用防御性复制。
这本书要说的,无非是把这笔交易从 String 推广到所有数据。
- JVM 的逃逸分析:短命的小对象经常根本不会被分配到堆上, 所以「产生很多中间对象」在现代 JVM 上没有听起来那么可怕。
- 分代垃圾回收:年轻代的回收成本约等于「活着的对象数」, 而不是「死掉的对象数」。大量立刻死掉的中间版本,回收起来几乎免费。 这是函数式风格能在 JVM 上跑得动的一个关键前提。
- Android:这里要谨慎一些。 内存压力更大、GC 停顿更敏感,热路径上(比如列表滚动时的每一帧) 确实要小心分配。这不是不能用,是要量。
「既然不可变有性能代价,那就在性能敏感的地方全用可变, 其他地方用不可变。」
方向对,但边界画错了。正确的边界不是「性能敏感 / 不敏感」, 而是「这个数据会不会被别人看见」。
;; ✓ 局部可变,出去时是值 —— 这是最佳实践
(defn build-index [rows]
(persistent! ;; ← 出门前变回不可变
(reduce (fn [acc row] (assoc! acc (:id row) row))
(transient {}) rows))) ;; ← 内部随便快
;; ✗ 把可变的东西暴露出去 —— 你会重新拥有第 1 章的所有问题
(def cache (java.util.HashMap.)) ;; 谁都能改,什么时候改不知道
这个模式有个名字,叫「局部可变,边界不可变」。 它让你在函数内部拿到可变的全部速度, 同时在函数外部保住值的全部好处。Java 的 StringBuilder 就是这个模式。
答案是 C:遍历,慢 3–10 倍, 原因是缓存局部性——分散的 32 格小数组打不过一整块连续内存。
A 「加元素最慢」——恰恰相反,这是它最强的项之一。
尾巴优化让多数 conj 只碰一个 32 格数组
(第 2 章量过:尾巴没满时新建 0 个节点)。
B 「随机读最慢」——确实慢,但只慢 2–3 倍: 4 跳指针在有缓存的情况下并不算贵。 大多数人会选 B,因为「走 4 层树」听起来比「遍历」更费事—— 而真正的差距藏在硬件层面,不在算法层面。
D 「三项差不多」——不同项的差距是不一样的, 知道差在哪一项才知道该在哪儿用逃生舱。
你进来时(第 2 章):不可变几乎没有代价, 因为它根本不复制。
你出去时:代价不在复制,在间接寻址和缓存局部性; 逃生舱是瞬变;而「快 600 倍」那个对比只在你需要保留旧版本时才公平。
第 2 章的结论完全没被推翻——那些节点数是实测的, 那个场景也是真实的。这一章只重建了一条路径: 加上了「在什么前提下」这个限定。
这一章的一句话
不可变的代价不是复制,是缓存; 而「快几百倍」只在你需要保留旧版本时成立——只不过这种时候比你以为的多。
最后一章收尾:24 条错误直觉的自查表、 往下该读什么、这门语言真实的短板, 以及一件更重要的事——这本书里有哪些东西, 你明天就能用在手上那个 Kotlin 项目里,而完全不需要换语言。