随笔
谎言会复合
诚实原则的推广
一条规则被提出 — 如果它是苹果,就别叫它橘子 — 并通过一个长达三十年的案例研究展示了当一门语言破坏它时会发生什么。从那以后这条规则不断在别处兑现,在那些和数组毫无关系的地方,而一个更深层的结构也随之浮现。问题不在于类型系统里的一个谎言有代价。而在于一个谎言永远不会停止付出代价:每一个虚构都需要第二个虚构来支撑,第二个需要第三个,而每一个都自带它的税、它的失败模式,以及为三个抽象层之外某个人安排好的意外。这篇文章沿着这条模式走过 Java 的装箱和垃圾回收 — 然后走过我们在 SuperJ 自己的 String 上犯的镜像式的错误,因为一条你只对别人的代码用过的原则,还不算你的原则。
一个谎言的形状
这篇文章里的每一个案例都遵循同样的四步形状,所以我们先命名一次:
- 重新标记。 某个东西被声明为它不是的东西,因为这个虚构今天很方便。
- 补丁。 这个虚构其实并不成立,所以加了一个机制来掩盖它裂开的地方。
- 税。 补丁不是免费的。每个人都永远在付它,包括那些从来不需要这个虚构的程序。
- 裂缝。 多年后,一个诚实设计的新特性到来了 — 而它无法与旧的谎言组合。语言现在对同一个东西有两个模型,以及它们之间一道永久的疤痕。
Java 的协变数组走过了这些一模一样的台阶:数组是对象 → 协变 → 每次引用数组存储时一个 ArrayStoreException 检查 → 泛型完全拒绝和数组混合。当时没有被充分认识到的是,同样的台阶会在另一门语言决定舒适胜过诚实的每个地方忠实地重现 — 包括,你将看到的,在我们自己的屋子里。
案例:一个原始类型不是一个对象
一个 int 是一个 32 位二补码值,住在寄存器里。它没有身份 — 不存在"这个 5 而不是那个 5" — 没有头,没有分发表,没有生命周期。一个对象恰恰相反。这不是口味问题;它和数组对对象是同一个范畴区分,而 Java 在这方面的历史甚至更有教益,因为Java 最初说的是真话。
Java 1.0 故意把原始类型排除在对象体系之外,为了性能。裂缝是诚实且可见的:你不能把一个 int 放进 Vector,每个人都知道。谎言在 Java 5 以自动装箱的名字到来:编译器会在方便的地方悄悄地把 int 重新标记为 Integer 再标记回来。一个关键字那么多的舒适。现在看那台阶:
补丁。 装箱要分配,而为每个小整数都分配一个对象是荒谬的 — 所以 JLS 规定了一个缓存:Integer.valueOf 必须对 −128 到 127 的值返回同一个对象。第二个虚构("小整数有稳定身份")支撑起第一个("整数是对象")。
税,三种口味。 缓存意味着对装箱整数用 == 在 127 时能工作,在 128 时悄悄失败 — 一个恰好落在测试找不到的地方的语义悬崖,因为测试数据小而生产数据不小。拆箱意味着 null — 一个 int 无法持有的值 — 流入原始类型的位置:int x = map.get(k) 欢快地编译通过,在 key 缺失时运行时抛出 NullPointerException,一个诚实的 1.0 语言让它无法表达的错误。而分配代价从未离开;它只是变得不可见:Bloch 的《Effective Java》一直把一个两个字符的 bug 印在书里 — 一个类型写成 Long 而不是 long 的循环计数器 — 它悄悄地创建二十亿个对象,让一个一秒的循环慢一个数量级。修复是删掉一个字母。是谎言让一个字母值那么多。
裂缝。 泛型擦除到 Object,所以 List<int> 是非法的,而每个数字的泛型集合都逐元素装箱 — 这就是为什么 Java 生态长出了一整个道歉的影子世界:IntStream 旁挂着 Stream<Integer>,几十个特化的 IntFunction/ToLongBiFunction 接口,以及第三方库(fastutil、Trove、Eclipse Collections)存在的全部理由就是手工特化的原始类型集合 — 诚实的类型,在语言之外维护,因为语言已经投身于虚构。Project Valhalla 现在花了十年时间在小心翼翼地拆除它。
SuperJ 的答案是 Java 1.0 早就知道的,只是这次脊梁挺直了:一个原始类型在任何地方都不是引用,没有需要记的例外。 在期望 Object 的地方传入 42 是编译错误,不是一次幽灵分配。x.equals(42) 不会装箱;它编译不过。而 Java 外包给 fastutil 的后果被简单地承担了:SDK 把诚实的特化类型 — Int2IntMap、Long2ObjMap、IntArrayList — 作为标准集合提供,而不是作为一份第三方忏悔。如果你需要一个装箱的整数,你可以显式地构造一个,在代码审查时为此负责。没有什么是隐藏的,因为没有什么是需要被掩盖的。
案例:一个内存 bug 不是一个可以跑赢的麻烦
这个更微妙,因为虚构包裹着一个真实的成就。追踪式 GC 确实解决了一个真实问题 — 当有人还指着它时释放它永远不安全 — 而且是可靠地解决了。谎言不是收集器。谎言是伴随它而来的营销:"内存生命周期不再是你的事。"
但生命周期从来不是一个自动化可以抹去的记账杂务;它是你程序的一个设计属性 — 谁拥有这个,它什么时候死 — 而一个问题不会因为你停止回答它就消失。把问题路由给收集器,它不会被回答;它会被推迟,台阶由此开始。
补丁们。 一个被遗忘的监听器或一个静态缓存让一个对象永远可达 — 收集器对它的契约诚实,无限期地保留着你的 bug。GC 没有消除内存泄漏;它把它重新实现为"非预期保留",在导致它的那行无法诊断,只有几周后通过堆转储考古才能诊断。于是平台长出了三种幽灵指针 — WeakReference、SoftReference、PhantomReference — 它们的全部目的就是把生命周期事实悄悄告诉你被告知不需要它们的收集器。然后来了 finalize(),清理可以搭着回收一起做的承诺;它被证明如此不可预测以至于被标记为待移除,被 Cleaner 取代,而且很说明问题地,被 try-with-resources 取代 — 后者是手动的、词法的生命周期管理,在 GC 语言内部重新发明,因为文件和套接字从来无法真正容忍这个虚构。仔细读这条裂缝:对于除内存以外的每个资源,Java 悄悄地承认了诚实的模型 — 显式所有权、作用域释放 — 而只在失败足够柔软可以隐藏的地方维持谎言。
税。 每个对象都带着收集器元数据;现代并发收集器里每次指针存储都付一个写屏障(和 ArrayStoreException 的过路费形状相同 — 一笔按操作收取的费用,用来撑住一个虚构);而在两个无辜的语句之间,世界停了下来。一整个职业 — GC 调优 — 存在的目的就是和本应意味着你再也不用想内存的机器谈判。你不断地想内存。你只是被剥夺了对此说任何话的词汇。
SuperJ 的答案是让生命周期留在源码里,留在类型系统的视线内。每次分配都属于一个 arena。默认是全局 arena — 程序生命周期,可以安全地存储和返回,并诚实地标注如此。对于有界工作,你打开一个词法 arena { } 块:里面的分配是指针推进,块关闭时里面的一切在一次 O(1) 释放中死去 — 没有标记,没有清扫,没有暂停,最重要的是没有关于何时的歧义。块就是生命周期;你可以读它。
而这里是把这变成一条原则而非一种偏好的地方:编译器也拒绝反方向的谎言。 把一个 arena 分配的对象在其 arena 关闭之后存储或返回,你会在那一行得到一个编译错误 — 逃逸分析把"这个比它声明的生命周期活得长"当作类型错误对待,因为它就是:值的标签(随块死亡)和它的用法(比块活得长)不一致。GC 转化为三周后在堆转储里发现的泄漏的 bug,变成了写这个矛盾的那条确切语句上的一个红色波浪线。同时 SDK 在这里连小小的装饰性虚构也拒绝 — 没有 destroy() 约定,没有 Destroyable 接口,因为在 arena 之下一个置空引用的"清理"方法什么也不释放。真正拥有外部资源的东西得到一个显式的、诚实的 close() 或 free()。别的什么都没资格假装有一个。
案例:我们对自己撒的谎 — 一个 String 是一个对象
如果这条原则只审判过 Java,你有权怀疑它是穿着白大褂的偏见。所以这里被告是我们 — 而谎言朝相反方向跑。Java 的错误把非对象重新标记为对象。我们拿了一个真正的对象并假装它不是。
重新标记。 SuperJ 的 String 最初是无头的:只是一个指向长度前缀缓冲区的 char*。背后的系统直觉很容易重构 — 一个字符串就是"只是一些字节",裸指针对 C 是零拷贝,跳过对象头省一个字。都对。都不在点子上。因为 Java 家族语言里的 String 过着一种彻底多态的生活:它是擦除泛型背后的 map key,它流经 Object 类型的槽位,它的 equals/hashCode 通过动态分派被调用,它应答 instanceof。身份、分派、类型标签 — String 用了对象头存在所提供的每一样服务。它是苹果。我们把它标记为"裸缓冲区"。
补丁。 虚构在每个擦除边界处撕裂,于是编译器长出了一个装箱垫片:每当一个 String 跨入 Object 领地,代码生成就当场围绕裸指针合成一个 {vtable, type_id, data} 的盒子。然后垫片需要垫片。擦除分派长出了特殊的 String 臂 — 一个带"它其实是个字符串吗?"专用分支的类型 id 测试 — 因为装箱 String 的方法无法按正常方式到达。擦除返回被当作 String 消费需要反向操作:拆回裸指针。外部 C 字符串需要自己的重新装箱路径。每个补丁都很小、局部、且完全合理 — 而这正是从一步之外看一个台阶的感觉。
税。 每次 map put 以及 每次 get 各一次 calloc(1, 24) — 每当 String 遇到一个泛型。在追踪的基准测试上那是每个计时轮次二十万次堆分配,大约每次 String 键 map 操作代价的 30%,通过阅读生成的 IR 验证。而没有任何优化器能救我们:全程序 LTO 直勾勾盯着盒子却删不掉它,因为盒子不是低效 — 它是承重的补丁。你无法在谎言还站着的时候优化掉它的租金。
裂缝。 语言最终对一个类型有两种 ABI — 裸数据指针路径和对象路径 — 以及一个全部职责就是钉住两条路径在 equals 和 hashCode 上一致的金标准测试,这样它们不会漂移分开。当一个类型需要它的两个自我之间的条约、由回归测试来执行时,设计在告诉你什么。
修复是终于说出了那句诚实的话:String 是 { vtable, type_id, char* data } — 一个真正的对象,在构造时一次性建好,data 字段仍然指向同一个长度前缀、NUL 终止的缓冲区,仍然原封不动地交给 C。零拷贝互操作完好无损;死掉的是伪装。热循环里按操作的盒子消失了(现在一个门 grep IR 来保证它不再出现),特殊情况臂变成了普通分派,基准测试当场下降约 30%。我们通过撒谎"省下"的每值一个字,一直让我们为每次使用付一次堆分配。
如果一切都是对象,是对象就毫无意义
有一种信息论的方式可以一次说完这些。一个谓词只有在有东西能让它失败时才携带信息。"一切都是 Object"是一个零信息量的声明 — 它无法区分,所以无法告知,所以没有东西能安全地建立在它之上。一旦 instanceof Object 成了同义反复,对象这个词就被花光了。
比花光还糟 — 一个万能标签不仅什么都没说,它还隐藏一切。如果数组、整数、字符串、lambda 和锁都是"对象",那这个标签不会告诉你它们之中哪个有身份、哪个可以被分派、哪个要分配、哪个可以是 null、哪个有值得追踪的生命周期。所有承重的区分仍然在那里 — 它们只是被推到了词汇之下,你通过一次 ArrayStoreException、一次 Integer 缓存悬崖、一次拆箱 NPE 重新发现它们。万能类型是范畴错误藏身的地方。
所以 SuperJ 谨慎地花费这个词,而且 — 正如 String 事件以艰难的方式证明的 — 这种谨慎两个方向都切:
- 一个数组不是对象:没有头、没有 vtable、没有
type_id、不能赋值给Object— 因为在一个没有反射、没有 GC 的语言里没有任何东西会消费数组的对象身份,我们不会为一个没有客户的服务向每个数组收取一个头。 - 一个原始类型不是对象:没有装箱、没有身份、在边界处编译错误。
- 一个struct不是对象:它是一个固定布局,它的字节就是线上格式 — 没有 vtable、没有继承、你和帧之间什么都没有。
- 一个String 是对象,而且现在这么说了:完整的头、真正的分派、一等公民的
Object— 因为它确实过着那种生活,而在我们假装不是如此的那段时间里,我们在每个擦除边界处为伪装付着租金。
四种值,四个不同的答案,每个答案由这个值实际做什么来赢得。这就是让 Object 有意义所需要的:必须可能不是它。 在 SuperJ 里,当一个值是 Object 时,那是一个你可以建立其上的事实 — 它有身份、有类型标签、有分发表、有多态的生活。这个声明有内容,因为这个声明可以是假的。
经济学,说一次
把四条台阶并排看回去 — Java 的三条,我们的一条:
| 重新标记 | 补丁 | 税 | 裂缝 | |
|---|---|---|---|---|
| 数组(Java) | "数组是 Object" | 协变 | 每次写入的存储检查 + 具化元素类型 | 数组 vs 泛型,永远 |
| 原始类型(Java) | "int 是 Integer" | −128…127 缓存 | 不可见分配、== 悬崖、拆箱 NPE | Stream<Integer> vs IntStream、fastutil、Valhalla |
| 内存(Java) | "生命周期不是你的事" | weak/soft/phantom 引用、finalizer → Cleaner | 头、写屏障、暂停、调优 | try-with-resources:手动生命周期,对除内存外的一切重新承认 |
| String(SuperJ,我们的忏悔) | "String 是裸缓冲区" | 每个擦除边界处装箱 + 特殊分派臂 | 每次泛型使用一次堆分配,约 map 操作的 30% | 一个类型两种 ABI,由一个金标准测试拴在一起 |
四个领域,一种算术。第一个谎言总是便宜的 — 这正是它诱人之处;重新标记在它发布的那天不花一文。补丁是利息累积的地方,税是它被征收的方式,而裂缝是那笔气球式付款:你后来无法拥有的诚实设计,因为不诚实的那个正蹲在它的地基上。一个谎言是对类型系统的一笔贷款,而类型系统总是会来收账。 我们知道,因为我们现在在柜台的两边都站过了。
诚实原则就是拒绝这笔贷款:在第一个机会、在编译时说这个东西是什么,即使真相暂时不方便 — 一个编译不过的移植、一个你必须手写的装箱、一个你必须在源码里回答的所有权问题、一个你必须付的头,因为这个值确实过着多态的生活。这些不便真实存在,而它们是全部的代价:在最要紧的那一行,由拥有最多上下文的人,一次性付清。另一种选择把同样的代价 — 连本带利 — 推迟到虚构最终撕裂时离得最近的那个人身上。
SuperJ 里每一项看起来像紧缩的移除 — 没有自动装箱、没有 GC、没有反射、没有线程、没有万能 Object — 都是同一个交易朝一个方向做。String 的头是同一个交易朝另一个方向做。我们不是在收集删除,也不是在囤积字节。我们是一次一笔贷款地,拒绝向真相借贷。