系统
LomentGC
当一个 Loment 程序释放内存时,它不把整件事交给收集器。每一个分配的地方,都在编译期被 指派给四种策略之一 —— 而留给 runtime 收集器多少,是写进产物里的一个数字,而不是文档里 的一句说法。
阶梯
从最强到最弱:编译器取它能证明的最强那一层。证明得越多,付得越少。每一层的 代价都写在它旁边,而不是丢在脚注里。
L0 — 静态
分配是一个字面量大小,而指针始终只被整体传递,作为 load、store 或 atomic 的第一个 参数。它根本不会到堆上,所以没有什么要回收。代价:更大的栈帧。
L1 — 末次使用
形状相同,声明在函数体的顶层,于是末次使用是可数的。编译器会在它之后最早的
return 之前插入一个 free。代价:在提前返回的路径上,
插入点可能被错过,那条路径会泄漏一次。是泄漏,不是 bug。
L2 — 块纪元
在循环体内部,每一次分配都是一个从不离开该体的局部量。体的结束一次性归还整片区域, 办法是把分配器的前沿绕回去 —— 没有链表,没有扫描。纪元是从块结构和逃逸推断 出来的,不是手写。代价:如果体里有一次分配逃逸了,整块都必须留下。
L3 — runtime
编译器无法证明的一切。收集器的全部代价。
三档
程序从它开始的那个文件里写一行,选择它愿意沿阶梯往下走到哪一层。
choose gc_manual
默认值。没有东西被自动回收;程序自己管理内存。一行都不写就得到它 —— 默认值是该设置 存在之前就有的行为,这是每个设置都遵循的规则。
choose gc_auto
一个 runtime 收集器来回收:带精确根的标记-清扫,由已分配的字节数触发。
choose gc_auto_alpha
四层全开,于是收集器只看到编译期各层无法证明的部分 —— 而且它是自适应的:阈值跟随活集, 并带一个空间触发,所以上限不是 arena 的某个固定比例。
一条被明说、而不是被发现的上限。在 gc_auto 下,触发阈值是
固定的 32 KiB,而 arena 是 64 KiB,所以一个活集超过大约 32 KB 的程序仍然会耗尽。
弥合这道缺口,正是自适应收集器、以及 gc_auto_alpha 下的分代收集器要做的事。
L3 内部
runtime 这一层也不是一个算法。它分三步长出来,每一步都必须在某个可度量的程序上胜过 它之前的那一步。
标记-清扫
根是精确的 —— 根表持有槽地址 —— 而块内部则按保守方式对待,因为
alloc 是无类型的,没有任何东西记录一个块里装了什么。
自适应触发
固定阈值只能猜活集会变多大。自适应阈值则去读它:当活集翻倍时,或空间吃紧时,就回收。
分代
两代,按地址切分,而不是按对象年龄 —— 一个不移动的收集器在每次 minor 回收 结束时推进一条前沿来晋升。一张卡表和一道写屏障抓住从老到新的指针。它省下的是标记-传播 那一趟,那一趟于是只覆盖年轻代。
为什么它落进产物里
每个单元都导出一份机器可读的摘要 —— 一个形式对象 —— 携带
gc_ladder:每一层各拿走了多少个 alloc 站点,加上总数。四个
计数必须加总等于总数,而两个独立的程序在不读一行源码的情况下核这道算术。
于是"这个程序的内存是怎样管理的?"变成构建输出里的一个数字,而不是某份文档里的一个说法。
声明了什么,没声明什么。这座阶梯实现了两次 —— 参考编译器和自举的那个 —— 两者必须逐字节一致。各层背后的测量来自项目自己的判据,而那些都是小程序;收集器从未 跑过真实负载,在你把这一切读成某种性能主张之前,这件事值得知道。