JDK 28 起,Arena.ofConfined() 对小块原生内存会优先从可复用池里切。应用不用改 API,也不用改现有 try-with-resources 写法。Per-Åke Minborg 在 inside.java(2026-10-05)介绍了这套 Pooled Confined Arena;对应实现已合入 OpenJDK PR #31365(JDK-8385697)。

为什么小块分配值得池化

Confined arena 常被当成一次原生调用的临时划痕区。典型写法是:

try (Arena arena = Arena.ofConfined()) {
    MemorySegment result = arena.allocate(ValueLayout.JAVA_INT);
    nativeFunction.invokeExact(result);
    return result.get(ValueLayout.JAVA_INT, 0);
}

这种模式生命周期明确,也有线程封闭保证。处理系统调用 errno 出参时尤其常见。JDK 28 之前,哪怕只要几个字节,也可能走完整原生分配器,再挂上清理动作。对只需要指针、int/long 或短字符串的调用来说,记账成本经常盖过真正读写那段内存的成本。

开发过程中的源码与运行时分析也支撑了池化方向:在一次 instrumented 测试套件跑法里,有些 confined arena 根本不分配原生内存;真正分配的那些里,超过 99.99% 用掉的容量小于 64 字节。测试套件当然代表不了所有生产负载,但分布明显偏向「小块、懒创建」。

池怎么工作

每个平台线程可以懒维护一小份原生内存池缓存。默认最多保留 4 个池,每个 64 字节。创建 arena 时不会立刻拿池;第一次遇到适合走池的分配时才去取。从不分配的 arena 因此不会白白创建原生池。

拿到池之后,它归该 arena 独占。合适的小分配按序从池里切出来,例如先切一个指针,再切一个 long,再切一个 int,剩余容量留给后续小请求。装不下,或对齐要求池满足不了,就退回原来的常规分配路径。池化是机会式优化,大块和特殊对齐的分配行为与以前一致。

关闭 arena 时,用过的那一段先清零,再把池还回线程缓存。缓存已满则把池交还给原生分配器。嵌套的 confined arena 彼此隔离,每个拿到池的 arena 独占自己的那块。缓存里没有空闲池时(比如嵌套层数超过了缓存槽位),新来的 arena 会自己分配一个本地池,关闭时再决定是放进缓存还是释放,所以不会无限堆缓存。

PR 说明里还提到,池的数量和大小可以通过不受支持的内部系统属性调整(每线程最多 8 个池,单个池 8 字节到 1 MiB),也可以整体关掉。这是内部开关,生产代码不应依赖它们长期存在。从未用过 confined arena 的线程不会创建这些池,Thread 上只多一个引用字段。

虚拟线程怎么借池

如果给每个虚拟线程都挂一份原生池缓存,会和「轻量」目标打架。实现选择是:虚拟线程向当前 carrier 线程借池。交接那一下会 pin;池从 carrier 缓存里摘出来、归 arena 持有之后,虚拟线程可以正常迁移。arena 关闭时,池还回当时所在 carrier 的缓存(可能已经换过 carrier);缓存满则释放。

这样既不必给可能上百万个虚拟线程各挂一份缓存,又保持了「池在任一时刻只有一个所有者」的模型。

安全语义没变,可观察行为有一处变化

池化不改变 Arena 的生命周期与可访问性规则。关闭之后,scope 仍然不再 alive,相关 MemorySegment 仍然不可访问。复用前会清零上次用过的区域;新分配的池也会零初始化。这满足「arena 返回的原生段初始为 0」的 API 要求,也避免上一块 arena 写过的数据落到下一块。

常规 cleanup 会在池清零、归还之前跑完。顺序很重要,因为 cleanup 仍可能通过 cleanup segment 合法访问那段原生区域。平台线程或 carrier 终止时,缓存里还能拿到的池会确定性释放;已归 open arena 持有的池会从缓存里摘掉,不会因为 carrier 结束或虚拟线程迁移而被误释放。

有一处应用和诊断工具需要注意的行为变化:关闭 pooled arena 时,底层原生内存可能回到 JDK 缓存,而不一定会立刻对应一次 free。不要再假定一次 arena 分配必然对应一次 malloc 和一次 free。段的生命周期规则没变,底层分配器调用次数与时机可能变了。

微基准数字(以原文为准)

官方开发基准给出的「名义加速比」是:基线 JMH average-time 延迟除以池化后延迟,越大越好。5 字节与 20 字节分配的跨平台结果如下(来自 inside.java 表格,原始数据亦见 PR #31365):

平台 5 字节 20 字节
Linux AArch64 12.3× 8.6×
Linux x64 6.8× 7.8×
macOS AArch64 18.6× 16.8×
Windows x64 16.8× 18.9×

另一次在 Apple M4(macOS)上的细分跑法:平台线程 5 字节从约 15.862 ns/op 降到约 1.052 ns/op;20 字节从约 18.139 ns/op 降到约 1.138 ns/op。100 字节及以上大体与无池路径持平,因为默认池只有 64 字节,超限仍走常规路径。虚拟线程因多一层 carrier 交接,绝对值略慢于平台线程,但仍明显快于无池:5 字节约 15.672 ns/op 降到约 3.222 ns/op。

这些都是微基准。原文也写明:微基准加速不会直接等价成整应用同比例变快。收益最大的场景,是 arena 创建、小块原生分配与清理在短原生操作里占了可观比重的那一类。

谁更容易受益

只对 confined arena 生效;ofShared()、ofAuto()、global() 不在这次优化范围内。更可能受益的用法包括:每次原生调用外包一层 confined arena 的 FFM 绑定、jextract 一类生成包装、小的 C 值与结构体、原生句柄与指针出参、传给原生侧的短字符串或短字节数组,以及原生调用本身很便宜、但创建和关闭 arena 的固定成本却很显眼的延迟敏感路径。

已经自己做切片或自定义池、长期持有并大量分配的大 arena、以及超过默认 64 字节的大块分配,预期收益有限或接近中性。同一线程上并发存活的 confined arena 远超缓存槽位数时,后创建的那些也会更多走本地池或常规路径。

对多数仍按「每次原生调用包一层 Arena.ofConfined()」写 FFM 绑定的代码,JDK 28 升级后这条路径会自动变便宜。若你维护诊断工具或自定义分配器探针,记得 arena 关闭后不一定立即 free,别把缓存归还误判成泄漏或重复释放。

参考链接

— 感谢阅读 —

一起交流

分享你的思考,让讨论更进一步。