开篇

std.ArrayList(T) 使用一段连续内存保存元素,空间不够时通过 allocator 扩容,访问方式仍然保持数组一样的简洁。Java 开发者若沿用自动内存管理的习惯,很容易在这里踩到资源未释放的坑。

Zig 语言没有垃圾回收机制,所有内存分配都必须显式完成。ArrayList 正是把这种显式控制封装在动态数组接口里。它对外提供 append、items、len 等熟悉的操作,内部却把 allocator 作为构造时的必传参数。这意味着开发者从创建第一行代码开始,就必须决定用哪一套分配器,以及在什么时候把内存还回去。

这种设计直接打破了 Java 程序员对 List 的固有认知。在 Java 中,ArrayList 内部虽然也会扩容,但内存释放完全由 GC 负责,开发者几乎不用操心。Zig 则把所有权问题摆在明面上:你分配,你负责释放。忽略这一点,程序要么在高负载下耗尽内存,要么在嵌入式或长时间运行的服务中慢慢泄漏。

接下来的内容会逐一拆解 ArrayList 的初始化、扩容、缩容、释放等关键环节,并结合实际项目场景说明如何避免常见错误。重点不是语法,而是思维转变:把 ArrayList 看成一块需要自己亲手管理的连续内存,而不是“自动”的列表。

ArrayList 初始化必须显式绑定 allocator

创建 ArrayList 的第一步是调用 init 函数,并传入一个有效的 allocator。典型写法是:

1
2
var list = std.ArrayList(u32).init(allocator);
defer list.deinit();

这里的 allocator 通常来自 std.heap.page_allocator、std.heap.c_allocator,或者自己用 ArenaAllocator 封装的局部分配器。init 函数会把这个 allocator 存到 ArrayList 的内部字段里,后续所有 append、resize、clearAndFree 等操作都会通过它申请或释放内存。

这与 Java 的 new ArrayList<>() 形成鲜明对比。Java 的构造器不需要任何内存管理参数,因为运行时会自动追踪对象。Zig 要求开发者在创建时就做出选择:是使用全局页分配器,还是在函数作用域内用固定大小的 arena 以避免碎片。这一步决定了后续所有内存行为的边界。

如果忘记传入 allocator,编译器会直接报错。这是一种强制机制,迫使开发者在写代码的第一时间考虑内存策略。很多从其他语言转来的开发者一开始觉得麻烦,但很快发现这种显式绑定让内存问题的定位变得极其简单——只要看 allocator 从哪里来,就知道内存生命周期应该在哪里结束。

实际项目中,推荐的做法是把 allocator 作为参数层层传递,而不是到处使用全局变量。这样测试时可以轻松换成测试专用的 FixedBufferAllocator,生产环境再切换到真正的堆分配器。

空间不足时 allocator 扩容的具体机制

当 ArrayList 当前容量无法容纳新元素时,它会调用内部的 ensureTotalCapacity 或 ensureUnusedCapacity 方法。这些方法最终通过 allocator.realloc 完成内存重新分配。

具体过程是:先计算新的容量,通常是当前容量的 1.5 倍或 2 倍,然后调用 allocator.realloc 把原有内存块扩展到新大小。realloc 可能在原地扩展,也可能分配一块更大的连续内存,把旧数据 memcpy 过去,再把旧内存释放。ArrayList 保证扩容后 items 指针仍然指向一段合法的连续内存,因此用户代码里的 list.items[0] 这种数组式访问始终有效。

整个过程对调用者是透明的。开发者只需要不断 append,ArrayList 会在必要时自动完成 realloc。这看起来和 Java 的 ensureCapacity 类似,但底层完全不同。Java 的扩容最终由 JVM 管理,旧数组对象会变成垃圾被回收;Zig 的 realloc 则是真正的操作系统层面内存块搬迁,旧内存必须由 allocator 负责释放,否则就会泄漏。

理解这一点很重要。很多开发者以为 append 失败只是容量不够,实际上它可能因为 allocator 根本分配不出新内存而返回 OutOfMemory 错误。生产代码里应该总是检查 append 的返回值,或者使用 appendAssumeCapacity 在已确保容量的情况下使用。

连续内存带来的另一个好处是缓存友好。遍历 list.items 时 CPU 能充分利用预取机制,这也是很多性能敏感场景选择 ArrayList 而不是链表的原因。

移除元素后容量不会自动缩小

ArrayList 的 remove、orderedRemove、swapRemove 等方法只会移动元素并减少 len 字段,capacity 和实际分配的内存块大小保持不变。这与信号中只提到“空间不够时通过 allocator 扩容”而完全没有提及自动缩容的描述完全一致。

Java 的 ArrayList 在 remove 较多元素后虽然也不会立即缩容,但提供了 trimToSize 方法。Zig 的 ArrayList 同样需要开发者手动调用 shrinkAndFree 或 resize 来真正释放多余内存。shrinkAndFree(0) 可以把容量降到 0 并释放全部内存,而 shrinkRetainingCapacity 只修改 len,不释放内存。

这种设计是故意的。频繁的 realloc 代价很高,尤其在循环中反复增删元素时。如果每次 remove 都自动 shrink,性能会急剧下降。因此 Zig 把决定权交给开发者:你认为当前容量已经过大、需要归还内存时才调用 shrinkAndFree。

实际开发中,常见的错误是在循环处理大量临时数据后忘记 shrink,导致程序常驻内存越来越高。正确做法是在一次批量操作结束后,根据业务逻辑判断是否需要立即释放内存。例如解析完一个 JSON 对象后,如果不再需要这个列表,就调用 list.clearAndFree() 而不是只 clearRetainingCapacity。

忘记 deinit 导致的内存泄漏

ArrayList 的 deinit 方法会调用 allocator.free 释放内部的内存缓冲区。如果开发者只声明了 var list = … 而没有在合适的位置调用 defer list.deinit(),那么这块内存就会一直占用到程序结束。

在命令行工具或短生命周期程序中,这可能只是小问题。但在服务器、游戏服务器、嵌入式设备上,长期运行的模块如果反复创建不释放的 ArrayList,内存占用会线性增长,最终导致 OOM。

常见泄漏场景包括:

  1. 在循环里创建临时 ArrayList 却只用 clearRetainingCapacity;
  2. 把 ArrayList 作为结构体字段,却在结构体 deinit 时忘记调用 list.deinit();
  3. 使用 catch 提前返回时跳过了 defer 语句。

Zig 的编译器和调试分配器(debug allocator)能在运行时检测部分泄漏,但生产环境的 release 模式不会。因此养成“创建即 defer deinit”的习惯至关重要。

实战项目里的动态数组典型用法

在中文开发者常见的项目中,ArrayList 最常用于 JSON 解析后的数据收集、配置文件动态加载、日志批量处理、网络包缓冲等场景。

例如写一个 HTTP 路由表时,可以用 ArrayList(Route) 保存所有路由信息,启动时一次性 append 完所有路由,运行期间只读不修改。这种只增不删的用法几乎不需要担心缩容问题,只需在程序退出时 deinit 即可。

另一个典型场景是游戏开发中的实体管理。每一帧可能需要收集满足条件的实体 ID,这时可以用 ArrayList(u32) 作为临时结果集,帧结束时调用 clearRetainingCapacity 复用内存,避免每帧都 realloc。

中文开源项目里,很多 CLI 工具使用 ArrayList(u8) 拼接字符串,最后用 list.items 直接转为 []const u8 传递给 std.fs 或网络接口。这种用法充分利用了 ArrayList 连续内存的特性,性能比反复字符串拼接高很多。

在 WebAssembly 或嵌入式项目中,开发者通常会创建一个固定大小的 ArenaAllocator,把所有 ArrayList 都绑定到这个 arena 上。程序退出或请求结束时一次性清空 arena,极大简化了内存管理代码。

性能敏感场景下的资源释放实践

高频操作中,重复的 realloc 是性能杀手。最佳实践是预先估算容量,在循环开始前调用 list.ensureTotalCapacity(max_possible_size),这样循环内 append 就不会触发任何内存分配。

对于需要反复使用的列表,应该维护一个对象池。每次使用前调用 list.clearRetainingCapacity(),用完后不释放,等下次复用。这种做法在游戏循环、实时音视频处理中非常常见,能把内存分配次数从每秒数万次降到几乎为零。

当确实需要释放内存时,优先使用 shrinkAndFree 而不是反复 init/deinit。shrinkAndFree 会把 capacity 降到指定值并真正 free 多余部分,而不会销毁 ArrayList 结构体本身。

在多线程场景下,需要特别注意 allocator 的线程安全性。std.heap.GeneralPurposeAllocator 在 debug 模式下有锁保护,但 release 模式下建议每个线程使用独立的 arena 以避免锁竞争。

最后,强烈建议在项目早期就引入内存追踪工具,例如用 std.heap.DebugAllocator 包装主 allocator。这样任何忘记 deinit 的 ArrayList 都会在测试阶段被立刻发现,而不是等到生产环境内存持续上涨才排查。

通过这些实践,Zig 开发者可以把 ArrayList 用得既安全又高效,彻底摆脱“以为它会自动管理内存”的错误认知。

参考来源