垃圾是坏的 — 只是后果不同
在每种语言里制造垃圾都是坏事。不同的是接下来会发生什么。在 Java 里,GC 最终会回收它 — 但带着一次停顿。在 SuperJ 里,它只会累积 — 没有停顿,但慢慢地走向 OOM。两种都需要消除。零垃圾技能在两种语言里并不相同。
在每种语言里制造垃圾都是坏事。不同的是接下来会发生什么。在 Java 里,垃圾回收器最终会回收它 — 但你控制不了什么时候、控制不了停顿多久、控制不了释放的字节是否还给操作系统。在 SuperJ 里,没有回收器,所以垃圾只会累积 — 单个对象的性能影响更小(没有扫描、没有停顿),但在长运行进程的生命周期里缓慢走向 OOM。两者都是坏的。两者都需要消除。区别在于,消除垃圾的技能在两种语言里并不相同:在 SuperJ 里零垃圾的模式(栈分配、arena 回收)在 Java 里会制造垃圾,而 Java 的 GC 默默处理的模式(丢弃引用、清空缓存)在 SuperJ 里是泄漏。本文走过每种制造垃圾的模式,展示 Java 怎么处理它,展示 SuperJ 怎么处理它,并点名每种语言需要的零垃圾技能。
下面每条断言都有测试背书 — 在写文之前都被反转、运行、并确认是红色(失败)过的。
共同前提:垃圾总是坏的
先说两种语言都同意的事:在热循环里制造短生命周期对象是一个 bug,不管有没有东西回收它。 Java 高性能社区二十年前就知道了 — 零 GC 的 Java 厂商(交易所、交易系统、LMAX)写的代码在稳态下不分配任何东西,不是因为 GC 慢,而是因为任何变成垃圾的分配都是运行时白做的功。GC 回收你的垃圾比它泄漏好,但都不如从不制造它。
所以问题从来不是"这泄漏还是 GC 处理了?"问题是"这到底造不造垃圾,如果不造,为什么不造?"答案在 Java 和 SuperJ 里不同,因为两种语言的零垃圾技能不同 — 让一个分配根本不变成垃圾的机制不同。把这些技能搞错,你就在一个不需要造垃圾的语言里造了垃圾,或在一个以为 GC 会救你的语言里泄漏了。
零垃圾技能,并排对比
| 零垃圾技能 | Java | SuperJ |
|---|---|---|
| 栈分配短生命周期对象 | JIT 逃逸分析(运行时、尽力而为、无关键字) | local 关键字 + 编译期逃逸分析(过程间、默认开启) |
| 批量回收一组分配 | try-with-resources + 自定义池(手动) | arena {} 块 — 右大括号处 O(1) 丢弃,每条退出路径 |
| 避免集合里逐元素装箱 | 手工特化集合(fastutil、Eclipse Collections — 第三方) | 特化集合是标准 SDK(Int2ObjMap,不是 HashMap<Integer, …>) |
| 跨迭代复用单个对象 | 对象池(手动、共享时需并发安全) | ObjectPool<T> + Reusable(单线程、无同步)或对象上的 reset() |
| 让运行时回收你丢弃的东西 | GC — 最终,带一次停顿 | 没有。全局 arena 上丢弃的引用一直留到进程退出。 |
最后一行是反转的那一行。在 Java 里,GC 是安全网 — 如果你忘了所有其他技能,回收器会接住你,慢慢地、带一次停顿。在 SuperJ 里,没有安全网。前四项技能不是可选优化;它们是你和泄漏之间唯一的东西。而制造垃圾的模式在两种语言里并不相同 — 因为技能不相同。
让我们逐个走过。
模式 1 — 作用域临时缓冲
零分配代码的主力:一个方法分配一个临时缓冲,做点活,返回一个基本类型结果。缓冲应该随调用消亡。
在 Java 里: new 进堆。如果 JIT 的逃逸分析能证明缓冲不逃逸出方法,它可能标量替换或栈分配 — 但这是运行时优化、尽力而为、没有关键字强制,而且取决于你控制不了的 JIT 内联决策。如果 JIT 证明不了(缓冲被传给被调用者、存进字段、被返回),缓冲就变成垃圾。GC 在下一次新生代扫描中回收它 — 亚毫秒停顿,但终归是停顿,而且 RSS 在扫描跑之前会多出一个缓冲的量。
在 SuperJ 里: arena {} 块内的 new 进 arena,arena 在右大括号处丢弃 — 在每条退出路径上,包括 return、break、throw。这是编译期决策,不是运行时决策。缓冲永远不会变成垃圾;它在块结束时以 O(1) 被回收。
// scopedArenaReclaimsOnReturn
static int makeBoundedScratch() {
arena scratch {
int[] a = new int[64];
for (int i = 0; i < 64; i = i + 1) { a[i] = i; }
return a[42]; // 基本类型按值复制 — 不逃逸
}
}
@Test
static void scopedArenaReclaimsOnReturn() {
long base = System.memReservedBytes();
int sum = 0;
for (int i = 0; i < 100; i = i + 1) { sum = sum + makeBoundedScratch(); }
long after = System.memReservedBytes();
Asserts.assertTrue("primitiveReturnedNoLeak", after == base);
Asserts.assertTrue("didWork", sum == 100 * 42);
}
System.memReservedBytes() 是始终开启的 arena 块计数器(#3108)。100 次调用的差值是恰好 0。arena 在每次 return 上都被丢弃,每次都是。这是 Java 没有的技能:一个在编译期保证回收、不靠运行时辅助的词法作用域。
Java 对比:同样的代码在 Java 里分配 100 个缓冲到堆上。如果 JIT 逃逸分析足够激进,其中一些会被栈分配、永不变成垃圾。如果不 — 而对于跨调用边界的缓冲,"不"是常见情况 — 全部 100 个变成垃圾,GC 在一次新生代停顿里回收。Java 工程师的零垃圾技能是写代码让 JIT 能证明不逃逸(让缓冲留在局部、不传给未知被调用者)。SuperJ 工程师的技能是开一个 arena {} 块。不同技能,同一目标。
同一个循环,放在循环里
近亲:arena 块在循环里,每轮丢弃。1,000 轮和 1 轮预留一样多:
// scopedArenaInLoopReclaimsEachIteration
@Test
static void scopedArenaInLoopReclaimsEachIteration() {
int n = 16;
long base = System.memReservedBytes();
int sum = 0;
for (int k = 0; k < 1000; k = k + 1) {
arena tmp {
int[] b = new int[n];
for (int i = 0; i < n; i = i + 1) { b[i] = i; sum = sum + b[i]; }
}
}
long after = System.memReservedBytes();
Asserts.assertTrue("loopReclaims", after == base);
Asserts.assertTrue("didWork", sum == 1000 * (n * (n - 1) / 2));
}
在 Java 里: 1,000 个缓冲到堆上,1,000 个垃圾对象,一次新生代扫描。在 SuperJ 里: 0 — arena 每轮都丢弃。这是零分配请求处理的骨干:每个请求开一个 arena,在它里面做所有活,出去时丢弃。
模式 2 — 迭代器循环
惯用法:在热循环里用 iterator() 遍历 map。这是两种语言技能分叉最尖锐的地方。
Int2ObjMap contracts = new Int2ObjMap(64);
for (int i = 0; i < 32; i = i + 1) { contracts.put(i, "contract-" + i); }
for (int round = 0; round < 1000000; round = round + 1) {
Int2ObjMapIterator it = contracts.iterator();
while (it.hasNext()) { sum = sum + it.nextKey(); String v = (String) it.nextValue(); }
}
Java 怎么做:垃圾,被回收,带停顿
每次 iterator() 调用在堆上分配一个新迭代器对象。1,000,000 轮 = 1,000,000 个迭代器对象。每个在循环结束的那一刻就是垃圾。GC 会在新生代扫描里回收它们 — 每次扫描亚毫秒,但扫描按 GC 的时间表跑,不是你的,而且它们会暂停线程。制造垃圾的税本身(TLAB bump、头写入、清零)在回收器跑之前就付了。
Java 零垃圾技能:JIT 可能逃逸分析迭代器并栈分配它,如果它能证明迭代器不逃逸出循环帧。但 iterator() 是一个返回堆对象的方法调用;JIT 的逃逸分析是过程内、尽力而为的,对于返回的 new 通常证明不了不逃逸。所以实践中 100 万个迭代器变成垃圾,GC 慢慢处理,带停顿。Java 工程师的技能是复用单个迭代器(如果 API 允许 reset())或避开迭代器(基于索引的迭代)。
SuperJ 怎么做:垃圾,不被回收,真正的泄漏
在 SuperJ 里没有 GC 安全网。每次 iterator() 调用通过 calloc(1, 32) 分配迭代器 — libc 堆,不是 arena,因为 new 在 iterator() 的方法体里,那里 arena 指针为 null(arena {} 块是词法的,不是过程间的 — 它只捕获文本上在块内的 new,不捕获被调用者体内的)。SuperJ 没有 free。1,000,000 个迭代器累积成 32 MB 的 libc 堆分配,没有任何东西追踪、没有任何东西回收。
扩展到 100 万轮并测量 RSS:
| RSS | |
|---|---|
基线(每轮 iterator())— 1,000,000 × calloc(1, 32) | 38.6 MB |
注意 memReservedBytes(arena 计数器)全程停在 2 MB — 因为计数器对 libc calloc 是盲的。它只数 arena 块。当你在 SuperJ 里怀疑泄漏时,量 RSS(/usr/bin/time -l),别只量 memReservedBytes。 计数器对自己量的东西是诚实的;陷阱在于你以为它能量一切。
这个模式的五项零垃圾技能(全部 RSS 验证)
泄漏可修。五种方法,每种都经过编译、跑 100 万轮、测 RSS 验证。每一项的缩减都是真实的,不是理论推演。
技能 1 — 复用迭代器(reset())。 分配一个迭代器,每轮重置。这里的零垃圾技能在两种语言里一样:别每轮分配。需要公开的 reset()(本次会话加到了 Int2ObjMapIterator 上)。
Int2ObjMapIterator it = contracts.iterator(); // 一次 calloc,一次
for (int round = 0; round < 1000000; round = round + 1) {
it.reset();
while (it.hasNext()) { sum = sum + it.nextKey(); String v = (String) it.nextValue(); }
}
技能 2 — 用 local + 内联 new 栈分配。 这是 SuperJ 有而 Java 没有 — 作为关键字 — 的技能。new 必须词法上在调用者的帧里(不是藏在 iterator() 内部),逃逸分析才能看见它。IR 显示 alloca [32 x i8] — 一个栈槽,每轮复用。在修复 #3510 后可用 --sdk-path(archive),该修复修正了栈提升 codegen 路径里缺失的 declare。
for (int round = 0; round < 1000000; round = round + 1) {
local Int2ObjMapIterator it = new Int2ObjMapIterator(contracts); // `new` 在这里
while (it.hasNext()) { … }
}
技能 3 — 避开迭代器;按索引遍历。 每轮零分配。两种语言里同样的技能:如果分配是问题,就别分配。需要知道键域或暴露一个内部迭代的公开 forEach(BiConsumer)。
for (int k = 0; k < 1000; k = k + 1) {
if (contracts.containsKey(k)) { sum = sum + k; String v = (String) contracts.get(k); }
}
技能 4 — 池化迭代器(ObjectPool<Iterator>)。 固定数量的可复用迭代器池;每轮借还。需要迭代器 implements Reusable(本次会话加的)。同样的技能在 Java 里也行(共享时需同步)。
ObjectPool<Int2ObjMapIterator> pool = new ObjectPool<Int2ObjMapIterator>(8, new IterCreator(contracts));
for (int round = 0; round < 1000000; round = round + 1) {
Int2ObjMapIterator it = pool.use();
while (it.hasNext()) { … }
pool.offer(it);
}
技能 5 — 用 arena {} + 内联 new 做 arena 回收。 把 new 词法上放进块里,让 arena 捕获它。迭代器进 sj_arena_malloc,在块退出时回收。需要绕过 iterator() 直接调构造器。这是 SuperJ 有而 Java 没有 — Java 没有 arena {} 块 — 的技能。
for (int round = 0; round < 1000000; round = round + 1) {
arena scratch {
Int2ObjMapIterator it = new Int2ObjMapIterator(contracts); // `new` 在这里,在块里
while (it.hasNext()) { … }
} // 这里回收
}
证据表
五种全部在 32 条目 map 上跑 100 万轮测得:
| # | 方案 | RSS | 对比基线 | IR 分配 | 泄漏消除? |
|---|---|---|---|---|---|
| — | 基线(每轮 iterator()) | 38.6 MB | 0 | 每轮 calloc(1, 32) | (泄漏) |
| 1 | 复用(reset()) | 6.3 MB | −32 MB | 一次 calloc,复用 | ✓ |
| 2 | local + 内联 new(--sdk-path) | 6.3 MB | −32 MB | alloca [32 x i8] | ✓ |
| 3 | 索引(containsKey/get) | 6.4 MB | −32 MB | 每轮零分配 | ✓ |
| 4 | ObjectPool<Iterator> | 6.3 MB | −32 MB | 固定池,借还 | ✓ |
| 5 | arena {} + 内联 new | 6.3 MB | −32 MB | sj_arena_malloc,退出时回收 | ✓ |
五种全部把 RSS 从 38 MB 降到 ~6.3 MB。6.3 MB 是 SDK 运行时的基线;32 MB 的泄漏迭代器在每种方案里都消失了。
这个模式的技能对比
| 技能 | Java | SuperJ |
|---|---|---|
复用(reset()) | 可行(如果 API 允许) | 可行(给迭代器加了 reset()) |
| 栈分配 | JIT 可能会做(尽力而为、无关键字) | local 关键字强制(编译期,IR 验证) |
| 避开迭代器 | 基于索引的循环 | 基于索引的循环(同样技能) |
| 池化 | ThreadLocal + 池(共享需同步) | ObjectPool<T>(单线程、无同步) |
| arena 回收 | 不可用(没有 arena {}) | arena {} + 内联 new(SuperJ 独有技能) |
最后两行是不同的技能。Java 有 GC 兜底,所以迭代器模式即使制造垃圾也"能用" — 你付停顿。SuperJ 没有兜底,所以同样的模式泄漏 — 你付 OOM。但 SuperJ 给了你两项 Java 没有的技能(local 和 arena {}),在垃圾产生之前就消除它,如果你知道用的话。
模式 3 — 堆上增长的动态数组
一个靠翻倍底层数组来增长的列表。每次扩容分配更大的缓冲并复制。旧缓冲死了。
在 Java 里: 旧底层数组变成垃圾。GC 在下次扫描中回收。RSS 增长到最终数组大小加上 GC 还没扫的量,然后稳定。代价受最终容量约束,而非操作数 — 一个增长到 20 万、被读一百万次的列表代价是它最终大小的 2×,不是一百万的 2×。
在 SuperJ 里: 旧底层数组进全局 arena(或 libc calloc),永不收缩。它们留到进程生命周期结束。但 — 这是关键 — 代价仍然受最终容量约束,而非操作数。第二轮不添加元素时不让 arena 增长。
// growingArrayStaleBuffersAreBounded
@Test
static void growingArrayStaleBuffersAreBounded() {
ArrayList warmup = new ArrayList();
for (int i = 0; i < 50000; i = i + 1) { warmup.add("warm-" + i); }
long base = System.memReservedBytes();
ArrayList list = new ArrayList();
for (int i = 0; i < 200000; i = i + 1) { list.add("item-" + i); }
long afterOne = System.memReservedBytes();
for (int i = 0; i < 200000; i = i + 1) { String s = (String) list.get(i); }
long afterTwo = System.memReservedBytes();
Asserts.assertTrue("growthOnFirstPass", afterOne > base);
Asserts.assertTrue("steadyStateAfter", afterTwo == afterOne);
}
第一遍让 arena 增长(底层数组经历翻倍步骤)。第二遍,同样大小,不让 arena 增长。陈旧缓冲还在,但活集趋于平稳。
技能对比:
| 技能 | Java | SuperJ |
|---|---|---|
| 预分配数组大小 | new ArrayList<>(expectedSize) | new ArrayList(expectedCapacity) — 同样技能 |
| 接受陈旧缓冲 | GC 回收它们;RSS 稳定 | arena 保留它们;RSS 趋平(有界) |
| 给数组开作用域 | 不可用(没有 arena {}) | arena {} 块 — 整个数组在退出时回收 |
在两种语言里,垃圾(旧底层数组)都是真实代价。Java 里 GC 回收它;SuperJ 里它留着。但在两者里,代价都受最终大小约束、而非操作数 — 所以动态数组是"垃圾"但不是"泄漏",无论哪种语言。零垃圾技能一样:能预分配就预分配;不能就开作用域。
模式 4 — 无界缓存
一个跨调用累积条目却从不淘汰的 map。这是唯一一个在两种语言里都是 bug 的模式,原因不同。
在 Java 里: 条目可达(map 持有它们),所以 GC 不会回收。堆无界增长。cache.clear() 让它们不可达,GC 在下次扫描中回收。修复是策略:LRU、TTL、有界大小。GC 是让策略生效的机制。
在 SuperJ 里: 条目进全局 arena,永不收缩。cache.clear() 清空 map 的逻辑内容,但底层数组永远留在 arena 里。字节回不来。修复是同样的策略(LRU、TTL、有界大小)— 但机制不同:如果你想要字节回来,必须把缓存开在 arena {} 块里,否则接受 arena 保留陈旧缓冲。
// unboundedCacheGrowsWithoutBound
@Test
static void unboundedCacheGrowsWithoutBound() {
long base = System.memReservedBytes();
Int2ObjMap cache100k = new Int2ObjMap(64);
for (int i = 0; i < 100000; i = i + 1) { cache100k.put(i, "v" + i); }
long after100k = System.memReservedBytes();
Int2ObjMap cache200k = new Int2ObjMap(64);
for (int i = 0; i < 200000; i = i + 1) { cache200k.put(i, "w" + i); }
long after200k = System.memReservedBytes();
Asserts.assertTrue("cache100kGrew", after100k > base);
Asserts.assertTrue("cache200kBigger", after200k > after100k);
}
一个 20 万条目的缓存比 10 万条目的预留更多内存。这就是全部断言,也是全部 bug — 在两种语言里都是。SuperJ 里没有任何机制会回收这两个缓存;两者都在全局 arena 上,进程退出时丢弃,之前不会。Java 里 cache.clear() 会让 GC 回收条目;SuperJ 里 cache.clear() 只回收逻辑活跃度,不回收字节。
技能对比:
| 技能 | Java | SuperJ |
|---|---|---|
| 有界淘汰策略 | LRU/TTL — GC 回收被淘汰条目 | LRU/TTL — arena 保留陈旧缓冲(有界) |
| 清空以释放内存 | cache.clear() → GC 回收 | cache.clear() → 仅逻辑,字节留下 |
| 给缓存开作用域 | 不可用(没有 arena {}) | arena {} 块 — 整个缓存在退出时回收 |
这是 Java 直觉("我清空缓存来释放内存")最危险地反转的模式。在 Java 里它行得通。在 SuperJ 里不行 — 字节本来就不会回来。要回收字节的唯一办法是一开始就把缓存在 arena {} 块里分配。全局 arena 上的长期缓存,不管你清不清,都是泄漏。
模式 5 — 留存型 consumer
一个把每个值塞进列表的 consumer。代价是列表的增长,不是迭代的。
// retainingConsumerGrowthIsBoundedByListCapacity
@Test
static void retainingConsumerGrowthIsBoundedByListCapacity() {
Int2ObjMap contracts = new Int2ObjMap(64);
for (int i = 0; i < 32; i = i + 1) { contracts.put(i, "contract-" + i); }
RetainingConsumer consumer = new RetainingConsumer();
for (int round = 0; round < 10; round = round + 1) {
Int2ObjMapIterator it = contracts.iterator();
while (it.hasNext()) { it.nextKey(); consumer.accept((String) it.nextValue()); }
}
long base = System.memReservedBytes();
for (int round = 0; round < 10; round = round + 1) {
Int2ObjMapIterator it = contracts.iterator();
while (it.hasNext()) { it.nextKey(); consumer.accept((String) it.nextValue()); }
}
long after = System.memReservedBytes();
Asserts.assertTrue("retainedCount", consumer.kept.size() == 20 * 32);
Asserts.assertTrue("boundedGrowth", after == base);
}
前 10 轮(320 次插入)让 arena 增长 — ArrayList 的底层数组经历翻倍步骤。后 10 轮(又是 320 次插入,同样的字符串)让 arena 增长零。底层数组已是 320 容量;字符串是对已在 map 中的对象的引用。
在 Java 里: consumer 的列表在堆上增长。旧底层数组变成垃圾,由 GC 回收。活集受列表最终容量约束。在 SuperJ 里: 一样 — 列表在 arena 上增长,陈旧缓冲留下,但活集趋于平稳。
技能在两种语言里相同:consumer 的留存策略就是内存管理器。无界留存的 consumer 是伪装起来的模式 4(无界缓存)。有界列表的 consumer 是模式 3(动态数组)— 有界,不是泄漏。
模式 6 — 全局 arena,按设计
最后一个模式不是垃圾也不是泄漏;它是契约。任何 arena {} 块外的分配都进全局 arena,它活到进程生命周期结束、永不收缩。
// globalArenaIsMonotone
@Test
static void globalArenaIsMonotone() {
long b1 = System.memReservedBytes();
int[] a = new int[256];
a[0] = 1;
long b2 = System.memReservedBytes();
long b3 = System.memReservedBytes();
Asserts.assertTrue("globalGrew", b2 >= b1);
Asserts.assertTrue("globalMonotone", b3 >= b2);
}
这个测试是为了不断言回收。任何 arena {} 块之外的 new int[256] 都进全局 arena并留下。当 a 越界时什么也不发生 — 引用没了,内存仍被预留。
在 Java 里: 同样的 new int[256] 进堆。当 a 越界时,数组变成垃圾,GC 回收它。Java 工程师的直觉是"引用没了,所以内存释放了"。在 SuperJ 里这个直觉是错的 — 引用没了,内存没释放,没有任何东西会释放它。字节留到进程退出。
技能:给所有不是长期存活的东西开作用域。 全局 arena 是给配置、连接池、intern 字符串 — 应该活到进程结束的东西。其余一切都放 arena {} 块里。Arena 不聪明;作用域聪明。编译器在右大括号和每条提前退出上发一个 drop,那个 drop 才把"永不收缩"变成"作用域结束时恰好收缩"。
脚枪:local,以及守卫它的警告
还有一项技能,特意分开讲,因为它不是泄漏 — 它是编译器会警告你的未定义行为。local 是一个 SuperJ 关键字(SPEC §7.8),强制把对象或数组放到栈上而非 arena:
local Node n = new Node(3); // 栈分配
local int[] arr = new int[10]; // 栈分配
它是一项开发者特权:你在向编译器承诺,这个引用不会活过声明它的那一帧。如果它活过了 — 被返回、被存进字段、被留存型调用捕获 — 之后再读它是未定义行为,和返回指向 C 栈变量的指针一样。
因为逃逸分析默认开启(#3300),当编译器无法证明 local 死在自己帧里时,会以 W_LOCAL_ESCAPES 警告:
tmp/x.sj:5:26: warning[W_LOCAL_ESCAPES]: cannot prove the value bound to 'local m'
dies in this frame. 'local' puts it on the stack, so if the reference outlives the
frame — returned, stored in a field, or captured by a call that retains it —
reading it later is undefined behaviour (SPEC §7.8). Either drop 'local' and let
it be arena-allocated, or keep the value inside this frame.
在 Java 里: 没有 local 关键字。JIT 可能栈分配如果它能证明不逃逸,但你不能强制,而且如果它不能也不会警告 — 对象就进堆变成垃圾。在 SuperJ 里: local 强制栈分配,编译器会在无法证明安全时告诉你。技能是只在测过 arena 代价、想让它消失的地方用 local;arena 是默认,因为它构造上就安全。
模式,再来一遍
| # | 模式 | Java | SuperJ | 零垃圾技能 |
|---|---|---|---|---|
| 1 | 作用域临时缓冲 | 垃圾(GC 回收,带停顿) | 由 arena {} 回收(0 垃圾) | arena {} 块 — SuperJ 独有 |
| 2 | 迭代器循环 | 垃圾(GC 回收,带停顿) | 泄漏(calloc,无 free) | 复用、local、索引、池,或 arena {} + 内联 new |
| 3 | 动态数组增长 | 垃圾(GC 回收旧缓冲) | 陈旧缓冲留下(受最终大小约束) | 预分配或开作用域 — 同样技能 |
| 4 | 无界缓存 | 泄漏(可达,GC 帮不了) | 泄漏(arena 全保留) | 有界淘汰策略 — 同样技能 |
| 5 | 留存型 consumer | 垃圾(列表增长,GC 回收旧缓冲) | 陈旧缓冲留下(受列表容量约束) | 有界列表 — 同样技能 |
| 6 | 全局作用域分配 | 垃圾(GC 在丢弃时回收) | 永远留下(按设计) | 开进 arena {} — SuperJ 独有 |
| — | 逃逸的 local | n/a(无关键字) | 未定义行为,有警告 | 删掉 local 或证明不逃逸 |
从 Java 移植的规则
如果你来自 Java,单一的心智转变是:在两种语言里制造垃圾都是坏事,但后果不同,技能也不同。 在 Java 里,垃圾被回收 — 最终,带一个你控制不了的停顿。在 SuperJ 里,垃圾累积 — 没有停顿,但缓慢走向 OOM。两者都需要消除。区别在于零垃圾技能并不相同。
相同的:
- 不要每轮分配 — 复用或避开(模式 2,技能 1 和 3)。
- 不要建无界缓存 — 用淘汰策略(模式 4)。
- 知道大小时预分配动态数组(模式 3)。
不同的 — SuperJ 给了你 Java 没有的技能:
arena {}— 在编译期、每条退出路径上 O(1) 批量回收一组分配。零分配请求处理的骨干。local— 用关键字强制栈分配,由编译期逃逸分析验证。- 特化集合是标准(
Int2ObjMap,不是HashMap<Integer, …>)— 无装箱,因为装箱不是一个选项。
不同的 — Java 给了你 SuperJ 没有的技能:
- GC 作为安全网。如果你忘了所有其他技能,回收器接住你 — 慢慢地、带停顿地、回收字节。在 SuperJ 里,忘了一项技能就是泄漏。没有人接。前四项技能不是可选优化;它们是你和 OOM 之间唯一的东西。
危险地反转的:
- "GC 会处理的" → 它不会。编译器已经决定了分配去哪。在分配时决定作用域。
- "我清空缓存来释放内存" → 清空只回收逻辑活跃度,不回收字节。arena 保留底层数组。想要字节回来就把缓存开进
arena {}。 - "这个循环分配太多,我该用池" → 对于短生命周期的工作,你通常不需要池:
arena {}或local在垃圾产生之前就消除它。池是给全局 arena 上的长期对象的。 - "我装箱 int,JIT 会优化掉" → SuperJ 没有自动装箱,也没有 JIT。
42放在期望Object的地方是编译错误(E_BOXED_PRIMITIVE)。用特化集合;它们存在正是因为装箱不是一个选项。
让 SuperJ 值得的两点
把上面一切剥到本质,SuperJ 的内存故事建立在两个论断上。
论点 1:SuperJ 让一大类平凡用例默认零 GC — 这解决了超过 80% 的问题。
回看那些模式。模式 1(作用域临时缓冲)在 SuperJ 里用 arena {} 就零垃圾,在 Java 里除非 JIT 碰巧逃逸分析它就是垃圾。模式 6(循环里的作用域 arena)在 SuperJ 里零垃圾,在 Java 里 1,000 个垃圾对象。模式 2 的方案 2 和 5(local 栈分配和 arena {} + 内联 new)在 SuperJ 里零垃圾、在 Java 里没有对应。特化集合(Int2ObjMap 而非 HashMap<Integer, …>)在 SuperJ 里零装箱,在 Java 里需要第三方库(fastutil、Eclipse Collections)。
这些不是边缘情况。它们是每个服务器、解析器、请求处理器的骨干模式:分配一个临时缓冲、遍历一个集合、在作用域里处理一个请求、累加一个结果。在 Java 里每一个都制造垃圾 — 小垃圾、新生代垃圾、"GC 会处理"的垃圾 — 但终究是垃圾,高性能 Java 厂商花巨大的工程力气手工消除的正是这些模式。在 SuperJ 里它们构造上就零 GC,除了用语言的标准特性外不需要额外努力:arena {} 给作用域工作、local 给热路径、特化集合给基本类型键。
80% 这个数字不是测量值;它是一个结构性观察。一个典型程序里的大多数分配是短生命周期、有作用域、不逃逸的 — 正是 arena {} 和 local 免费处理的那个类别。剩下的 20%(无界缓存、跨请求状态、无界增长的长期结构)在两种语言里都需要策略。SuperJ 默认解决 80%;Java 要你围绕 GC 手工工程来达到同样结果。
论点 2:SuperJ 的确定性内存行为让垃圾制造可追踪 — Java 的 GC 把问题藏起来了。
在 Java 里,一个每秒制造 1,000,000 个迭代器对象的热循环在基准测试里跑得好好的。GC 回收它们,新生代翻转,停顿亚毫秒,性能分析器说一切健康。垃圾是隐形的。你在六个月后的生产环境里发现问题 — 负载翻倍、老年代填满、交易时段触发 full GC、停顿不再是亚毫秒。GC 没修 bug;它把症状推迟到环境让它变贵的时候。
在 SuperJ 里,同样的循环把 32 MB 泄漏进 libc 堆,RSS 攀升,你第一次跑程序就看到了。泄漏是确定性的 — 同样的输入、同样的 RSS 增长、同样的 memReservedBytes 差值(当你量对了计数器)。没有"GC 会处理"可以藏在后面。垃圾在开发时、在你测试的规模上就立刻可见 — 不是推迟到你测不了的生产规模上。
这是更深的论点:GC 是一个诊断盲点。 一个制造垃圾并依赖 GC 回收的程序,其内存行为你无法直接观察 — 你只能观察回收器的行为(停顿时间、堆使用量、分配速率),那只是程序行为的代理,由一个你控制不了的运行时中介。一个在 SuperJ 里制造垃圾的程序没有这种中介:垃圾在 RSS 里、在 arena 计数器里、在内存事件日志里,单调增长直到你修代码。确定性不只是性能(无停顿);它是可观测性 — 你能看到垃圾、测量它、追溯到创造它的那一行,因为没有东西在背后帮你清扫。
memReservedBytes 计数器和内存事件日志(superj memlog)正是为此而存在。它们给你每一次 arena 分配的时间线,归因到创造它的 arena {} 块的 file:line。一个增长的 arena是一个你能指着的泄漏;一个平坦的 arena 是你能证明的回收。Java 没有对应物 — 最接近的是 heap dump 之后的 heap histogram,那是一个快照而非时间线,而且需要你在 OOM 杀死进程之前抓到问题。
权衡,表述为两点:
- SuperJ 让平凡用例默认零 GC(
arena {}、local、特化集合)— 那 80% 短生命周期、有作用域的分配,Java 需要手工工程来消除。 - SuperJ 的确定性让非平凡用例可追踪 — 那 20% 真正泄漏的在开发时表现为 RSS 增长,而非生产时的 GC 停顿。Java 的 GC 没有修制造垃圾的代码;它把问题藏到环境让它变贵的时候。SuperJ 让它可见,让你能在上线前修掉。
两者想要的是同一件事 — 热路径零垃圾。SuperJ 默认把你带到 80%,让剩下 20% 可见到能修。Java 要你手工工程那 80%,把 20% 藏在一个让症状变成别人问题的回收器后面 — 生产团队的,凌晨 3 点,一次 full GC 期间。
完整的内存事件日志配方在 manual/memlog.md。