杭州中小企业网站建设,小黄豆crm,网站制作div区域是哪儿,公司建品牌网站好迄今为止#xff0c;我们所接触到的一切都有个限制#xff0c;需要预先知道大小。数组总是有一个编译时已知的长度#xff08;事实上#xff0c;长度是类型的一部分#xff09;。我们所有的字符串都是字符串字面量#xff0c;其长度在编译时是已知的。
此外#xff0c;…迄今为止我们所接触到的一切都有个限制需要预先知道大小。数组总是有一个编译时已知的长度事实上长度是类型的一部分。我们所有的字符串都是字符串字面量其长度在编译时是已知的。
此外我们所见过的两种内存管理策略即全局数据和调用栈虽然简单高效但都有局限性。这两种策略都无法处理动态大小的数据而且在数据生命周期方面都很固定。
本部分分为两个主题。第一个主题是第三个内存区域--堆的总体概述。另一个主题是 Zig 直接而独特的堆内存管理方法。即使你熟悉堆内存比如使用过 C 语言的 malloc你也会希望阅读第一部分因为它是 Zig 特有的。
1.1 堆
堆是我们可以使用的第三个也是最后一个内存区域。与全局数据和调用栈相比堆有点像蛮荒之地什么都可以使用。具体来说在堆中我们可以在运行时创建大小已知的内存并完全控制其生命周期。
调用堆栈Stack之所以令人惊叹是因为它管理数据的方式简单且可预测通过压入和弹出堆栈帧。这一优点同时也是缺点数据的生命周期与它在调用堆栈中的位置息息相关。堆(Heap)则恰恰相反。它没有内置的生命周期因此我们的数据可长可短。这个优点也是它的缺点它没有内置的生命周期所以如果我们不释放数据就没有人会释放。
让我们来看一个例子
const std import(std);pub fn main() !void {// well be talking about allocators shortlyvar gpa std.heap.GeneralPurposeAllocator(.{}){};const allocator gpa.allocator();// ** The next two lines are the important ones **var arr try allocator.alloc(usize, try getRandomCount());defer allocator.free(arr);for (0..arr.len) |i| {arr[i] i;}std.debug.print({any}\n, .{arr});
}fn getRandomCount() !u8 {var seed: u64 undefined;try std.os.getrandom(std.mem.asBytes(seed));var random std.rand.DefaultPrng.init(seed);return random.random().uintAtMost(u8, 5) 5;
}我们稍后将讨论 Zig 的分配器目前需要知道的是分配器是 std.mem.Allocator 类型。我们使用了它的两种方法alloc 和 free。分配内存可能出错故我们用 try 捕获调用 allocator.alloc 产生的错误。目前唯一可能的错误是 OutOfMemory。其参数主要告诉我们它是如何工作的它需要一个类型T和一个计数成功时返回一个类型为 []T 的切片。它分配发生在运行时期间必须如此因为我们的计数只在运行时才可知。
一般来说每次 alloc 都会有相应的 free。alloc分配内存free释放内存。不要让这段简单的代码限制了你的想象力。这种 try alloc defer free 的模式很常见这是有原因的在我们分配内存的地方附近释放相对来说是万无一失的。但同样常见的是在一个地方分配而在另一个地方释放。正如我们之前所说堆没有内置的生命周期管理。你可以在 HTTP 处理程序中分配内存然后在后台线程中释放这是代码中两个完全独立的部分。
1.2 defer 和 errdefer
上面的代码介绍了一个新的语言特性defer它在退出作用域时执行给定的代码。『作用域退出』包括到达作用域的结尾或从作用域返回。严格来说 defer 与分配器或内存管理并无严格关系你可以用它来执行任何代码。但上述用法很常见。
Zig 的 defer 类似于 Go 的 defer但存在一个主要区别。在 Zig 中defer 将在其包含作用域的末尾运行。在 Go 中defer 是在包含函数的末尾运行。除非你是 Go 开发人员否则 Zig 的做法可能不会令人惊讶。
与defer 相似的是 errdefer它作用与之类似是在退出作用域时执行给定的代码但只在返回错误时执行。在进行更复杂的设置时如果因为出错而不得不撤销之前的分配这将非常有用。
以下示例在复杂性上有所增加。它展示了 errdefer 和一个常见的模式即在 init 函数中分配内存并在 deinit 中释放
const std import(std);
const Allocator std.mem.Allocator;pub const Game struct {players: []Player,history: []Move,allocator: Allocator,fn init(allocator: Allocator, player_count: usize) !Game {var players try allocator.alloc(Player, player_count);errdefer allocator.free(players);// store 10 most recent moves per playervar history try allocator.alloc(Move, player_count * 10);return .{.players players,.history history,.allocator allocator,};}fn deinit(game: Game) void {const allocator game.allocator;allocator.free(game.players);allocator.free(game.history);}
};这段代码主要突显两件事
errdefer 的作用。在正常情况下player 在 init 分配在 deinit 释放。但有一种边缘情况即 history 初始化失败。在这种情况下我们需要撤销 players 的分配。我们动态分配的两个切片players 和 history的生命周期是基于我们的应用程序逻辑的。没有任何规则规定何时必须调用 deinit 或由谁调用。这是件好事因为它为我们提供了任意的生命周期但也存在缺点就是如果从未调用 deinit 或调用 deinit 超过一次就会出现混乱和错误。
1.3 双重释放和内存泄漏
上面提到过没有规则规定什么时候必须释放什么东西。但事实并非如此还是有一些重要规则只是它们不是强制的需要你自己格外小心。
第一条规则是不可释放同一内存两次。
const std import(std);pub fn main() !void {var gpa std.heap.GeneralPurposeAllocator(.{}){};const allocator gpa.allocator();var arr try allocator.alloc(usize, 4);allocator.free(arr);allocator.free(arr);std.debug.print(This wont get printed\n, .{});
}可以预见到最后一行代码不会被打印出来。这是因为我们释放了相同的内存两次。这被称为双重释放是无效的。要避免这种情况似乎很简单但在具有复杂生命周期的大型项目中却很难发现。
第二条规则是无法释放没有引用的内存。这听起来似乎很明显但谁负责释放内存并不总是很清楚。下面的代码声明了一个转小写的函数
const std import(std);
const Allocator std.mem.Allocator;fn allocLower(allocator: Allocator, str: []const u8) ![]const u8 {var dest try allocator.alloc(u8, str.len);for (str, 0..) |c, i| {dest[i] switch (c) {A...Z c 32,else c,};}return dest;
}上面的代码没问题。但以下用法不是:
// 对于这个特定的代码我们应该使用 std.ascii.eqlIgnoreCase
fn isSpecial(allocator: Allocator, name: [] const u8) !bool {const lower try allocLower(allocator, name);return std.mem.eql(u8, lower, admin);
}这是内存泄漏。allocLower 中创建的内存永远不会被释放。不仅如此一旦 isSpecial 返回这块内存就永远无法释放。在有垃圾收集器的语言中当数据变得无法访问时垃圾收集器最终会释放无用的内存。
但在上面的代码中一旦 isSpecial 返回我们就失去了对已分配内存的唯一引用即 lower 变量。而直到我们的进程退出后这块内存块才会释放。我们的函数可能只会泄漏几个字节但如果它是一个长时间运行的进程并且重复调用该函数未被释放的内存块就会逐渐累积起来最终会耗尽所有内存。
至少在双重释放的情况下我们的程序会遭遇严重崩溃。内存泄漏可能很隐蔽。不仅仅是根本原因难以确定。真正的小泄漏或不常执行的代码中的泄漏甚至很难被发现。这是一个很常见的问题Zig 提供了帮助我们将在讨论分配器时看到。
1.4 创建与销毁
std.mem.Allocator的alloc方法会返回一个切片其长度为传递的第二个参数。如果想要单个值可以使用 create 和 destroy 而不是 alloc 和 free。
前面几部分在学习指针时我们创建了 User 并尝试增强它的功能。下面是基于堆的版本
const std import(std);pub fn main() !void {// again, well talk about allocators soon!var gpa std.heap.GeneralPurposeAllocator(.{}){};const allocator gpa.allocator();// create a User on the heapvar user try allocator.create(User);// free the memory allocated for the user at the end of this scopedefer allocator.destroy(user);user.id 1;user.power 100;// this line has been addedlevelUp(user);std.debug.print(User {d} has power of {d}\n, .{user.id, user.power});
}fn levelUp(user: *User) void {user.power 1;
}pub const User struct {id: u64,power: i32,
};create 方法接受一个参数类型T。它返回指向该类型的指针或一个错误即 !*T。如果我们创建了User, 但没有设置 id, power时会发生什么。这就像将这些字段设置为未定义undefined其行为也是未定义的。意即属性没有初始化时在访问未初始化的变量行为也是未定义这意味着程序可能会出现不可预测的行为比如返回错误的值、崩溃等问题。
当我们探索悬空指针时函数错误地返回了本地user的地址
pub const User struct {fn init(id: u64, power: i32) *User{var user User{.id id,.power power,};// this is a dangling pointerreturn user;}
};在这种情况下返回一个 User可能更有意义。但有时你会希望函数返回一个指向它所创建的东西的指针。当你想让生命周期不受调用栈的限制时你就会这样做。为了解决上面的悬空指针问题我们可以使用create 方法
// 我们的返回类型改变了因为 init 现在可以失败了
// *User - !*User
fn init(allocator: std.mem.Allocator, id: u64, power: i32) !*User{var user try allocator.create(User);user.* .{.id id,.power power,};return user;
}我引入了新的语法user.* .{...}。这有点奇怪我不是很喜欢它但你会看到它。右侧是你已经见过的内容它是一个带有类型推导的结构体初始化器。我们可以明确地使用 user.* User{...}。左侧的 user.* 是我们如何去引用该指针所指向的变量。 接受一个 T 类型并给我们一个 *T 类型。.* 是相反的操作应用于一个 *T 类型的值时它给我们一个 T 类型。即获取地址.*获取值。
请记住create 返回一个 !*User所以我们的 user 是 *User 类型。
1.5 分配器 Allocator
Zig 的核心原则之一是无隐藏内存分配。这与 C 语言中使用标准库的 malloc 函数分配内存的做法形成了鲜明的对比。在 C 语言中如果你想知道一个函数是否分配内存你需要阅读源代码并查找对 malloc 的调用。
Zig 没有默认的分配器。在上述所有示例中分配内存的函数都使用了一个 std.mem.Allocator 参数。按照惯例这通常是第一个参数。所有 Zig 标准库和大多数第三方库都要求调用者在分配内存时提供一个分配器。
这种显式性有两种形式。在简单的情况下每次函数调用都会提供分配器。这样的例子很多但 std.fmt.allocPrint 是你迟早会用到的一个。它类似于我们一直在使用的 std.debug.print只是分配并返回一个字符串而不是将其写入 stderr
const say std.fmt.allocPrint(allocator, Its over {d}!!!, .{user.power});
defer allocator.free(say);另一种形式是将 Allocator 传递给 init 然后由对象内部使用。这种方法不那么明确因为你已经给了对象一个分配器来使用但你不知道哪些方法调用将实际分配。对于长寿命对象来说这种方法更实用。
注入分配器的优势不仅在于显式还在于灵活性。std.mem.Allocator 是一个接口提供了 alloc、free、create 和 destroy 函数以及其他一些函数。到目前为止我们只看到了 std.heap.GeneralPurposeAllocator但标准库或第三方库中还有其他实现。
如果你正在构建一个库那么最好接受一个 std.mem.Allocator然后让库的用户决定使用哪种分配器实现。否则你就需要选择正确的分配器正如我们将看到的这些分配器并不相互排斥。在你的程序中创建不同的分配器可能有很好的理由。
1.6 通用分配器 GeneralPurposeAllocator
顾名思义std.heap.GeneralPurposeAllocator 是一种通用的、线程安全的分配器可以作为应用程序的主分配器。对于许多程序来说这是唯一需要的分配器。程序启动时会创建一个分配器并传递给需要它的函数。我的 HTTP 服务器库中的示例代码就是一个很好的例子
const std import(std);
const httpz import(httpz);pub fn main() !void {// create our general purpose allocatorvar gpa std.heap.GeneralPurposeAllocator(.{}){};// get an std.mem.Allocator from itconst allocator gpa.allocator();// pass our allocator to functions and libraries that require itvar server try httpz.Server().init(allocator, .{.port 5882});var router server.router();router.get(/api/user/:id, getUser);// blocks the current threadtry server.listen();
}我们创建了 GeneralPurposeAllocator从中获取一个 std.mem.Allocator 并将其传递给 HTTP 服务器的 init 函数。在一个更复杂的项目中声明的变量allocator 可能会被传递给代码的多个部分每个部分可能都会将其传递给自己的函数、对象和依赖。
你可能会注意到创建 gpa 的语法有点奇怪。什么是GeneralPurposeAllocator(.{}){}
我们之前见过这些东西只是现在都混合了起来。std.heap.GeneralPurposeAllocator 是一个函数由于它使用的是 PascalCase帕斯卡命名法我们知道它返回一个类型。也许这个更明确的版本会更容易解读
const T std.heap.GeneralPurposeAllocator(.{});
var gpa T{};// 等同于:var gpa std.heap.GeneralPurposeAllocator(.{}){};也许你仍然不太确信 .{} 的含义。我们之前也见过它.{} 是一个具有隐式类型的结构体初始化。
类型是什么字段在哪里类型其实是 std.heap.general_purpose_allocator.Config但它并没有直接暴露出来这也是我们没有显式给出类型的原因之一。没有设置字段是因为 Config 结构定义了默认值我们将使用默认值。这是配置、选项的中常见的模式。事实上我们在下面几行向 init 传递 .{.port 5882} 时又看到了这种情况。在本例中除了端口这一个字段外我们都使用了默认值。
1.7 std.testing.allocator
希望在讨论内存泄漏时你已经感到足够的困扰而当我提到 Zig 能够伸出援手时你肯定急切地想要了解更多。 这种帮助来自于 std.testing.allocator它是一个 std.mem.Allocator 的实现。目前它基于通用分配器GeneralPurposeAllocator实现并与 Zig 的测试运行器无缝集成但这只是实现细节。关键在于如果在测试中使用 std.testing.allocator我们就能捕捉到大部分内存泄漏。
你或许已经对动态数组通常称为 ArrayLists有所了解。在许多动态编程语言中所有数组都是动态的。 动态数组能够支持可变数量的元素。Zig 提供了一个通用的 ArrayList但我们将亲手打造一个专门用于保存整数的 ArrayList并展示如何检测泄漏
pub const IntList struct {pos: usize,items: []i64,allocator: Allocator,fn init(allocator: Allocator) !IntList {return .{.pos 0,.allocator allocator,.items try allocator.alloc(i64, 4),};}fn deinit(self: IntList) void {self.allocator.free(self.items);}fn add(self: *IntList, value: i64) !void {const pos self.pos;const len self.items.len;if (pos len) {// weve run out of space// create a new slice thats twice as largevar larger try self.allocator.alloc(i64, len * 2);// copy the items we previously added to our new spacememcpy(larger[0..len], self.items);self.items larger;}self.items[pos] value;self.pos pos 1;}
};有趣的部分发生在 add 函数里当 pos len时表明我们已经填满了当前数组并且需要创建一个更大的数组。我们可以像这样使用IntList
const std import(std);
const Allocator std.mem.Allocator;pub fn main() !void {var gpa std.heap.GeneralPurposeAllocator(.{}){};const allocator gpa.allocator();var list try IntList.init(allocator);defer list.deinit();for (0..10) |i| {try list.add(intCast(i));}std.debug.print({any}\n, .{list.items[0..list.pos]});
}代码运行并打印出正确的结果。不过尽管我们在 list 上调用了 deinit还是出现了内存泄漏。如果你没有发现也没关系因为我们要写一个测试并使用 std.testing.allocator
const testing std.testing;
test IntList: add {// Were using testing.allocator here!var list try IntList.init(testing.allocator);defer list.deinit();for (0..5) |i| {try list.add(intCast(i10));}try testing.expectEqual(as(usize, 5), list.pos);try testing.expectEqual(as(i64, 10), list.items[0]);try testing.expectEqual(as(i64, 11), list.items[1]);try testing.expectEqual(as(i64, 12), list.items[2]);try testing.expectEqual(as(i64, 13), list.items[3]);try testing.expectEqual(as(i64, 14), list.items[4]);
}as 是一个执行类型强制的内置函数。如果你好奇为什么我们的测试要用到这么多那么你不是唯一一个。从技术上讲这是因为第二个参数即 actual被强制为第一个参数即 expected。在上面的例子中我们的期望值都是 comptime_int这就造成了问题。
如果你按照步骤操作把测试放在 IntList 和 main 的同一个文件中。Zig 的测试通常写在同一个文件中经常在它们测试的代码附近。当使用 zig test learning.zig 运行测试时我们会得到了一个令人惊喜的失败
Test [1/1] test.IntList: add... [gpa] (err): memory address 0x101154000 leaked:
/code/zig/learning.zig:26:32: 0x100f707b7 in init (test).items try allocator.alloc(i64, 2),^
/code/zig/learning.zig:55:29: 0x100f711df in test.IntList: add (test)var list try IntList.init(testing.allocator);... MORE STACK INFO ...[gpa] (err): memory address 0x101184000 leaked:
/code/test/learning.zig:40:41: 0x100f70c73 in add (test)var larger try self.allocator.alloc(i64, len * 2);^
/code/test/learning.zig:59:15: 0x100f7130f in test.IntList: add (test)try list.add(intCast(i10));此处有多个内存泄漏。幸运的是测试分配器准确地告诉我们泄漏的内存是在哪里分配的。你现在能发现泄漏了吗如果没有请记住通常情况下每个 alloc 都应该有一个相应的 free。我们的代码在 deinit 中调用 free 一次。然而在 init 中 alloc 被调用一次每次调用 add 并需要更多空间时也会调用 alloc。每次我们 alloc 更多空间时都需要 free 之前的 self.items。
// 现有的代码
var larger try self.allocator.alloc(i64, len * 2);
memcpy(larger[0..len], self.items);// 添加的代码
// 释放先前分配的内存
self.allocator.free(self.items);将items复制到我们的 larger 切片中后, 添加最后一行free可以解决泄漏的问题。如果运行 zig test learning.zig便不会再有错误。
1.8 ArenaAllocator
通用分配器GeneralPurposeAllocator是一个合理的默认设置因为它在所有可能的情况下都能很好地工作。但在程序中你可能会遇到一些固定场景使用更专业的分配器可能会更合适。其中一个例子就是需要在处理完成后丢弃的短期状态。解析器Parser通常就有这样的需求。一个 parse 函数的基本轮廓可能是这样的
fn parse(allocator: Allocator, input: []const u8) !Something {var state State{.buf try allocator.alloc(u8, 512),.nesting try allocator.alloc(NestType, 10),};defer allocator.free(state.buf);defer allocator.free(state.nesting);return parseInternal(allocator, state, input);
}虽然这并不难管理但 parseInternal 内可能还会申请临时内存当然这些内存也需要释放。作为替代方案我们可以创建一个 ArenaAllocator一次性释放所有分配
fn parse(allocator: Allocator, input: []const u8) !Something {// create an ArenaAllocator from the supplied allocatorvar arena std.heap.ArenaAllocator.init(allocator);// this will free anything created from this arenadefer arena.deinit();// create an std.mem.Allocator from the arena, this will be// the allocator well use internallyconst aa arena.allocator();var state State{// were using aa here!.buf try aa.alloc(u8, 512),// were using aa here!.nesting try aa.alloc(NestType, 10),};// were passing aa here, so any were guaranteed that// any other allocation will be in our arenareturn parseInternal(aa, state, input);
}ArenaAllocator 接收一个子分配器在本例中是传入 init 的分配器然后创建一个新的 std.mem.Allocator。当使用这个新的分配器分配或创建内存时我们不需要调用 free 或 destroy。当我们调用 arena.deinit 时会一次性释放所有该分配器申请的内存。事实上ArenaAllocator 的 free 和 destroy 什么也不做。
必须谨慎使用 ArenaAllocator。由于无法释放单个分配因此需要确保 ArenaAllocator 的 deinit 会在合理的内存增长范围内被调用。有趣的是这种知识可以是内部的也可以是外部的。例如在上述代码中由于状态生命周期的细节属于内部事务因此在解析器中利用 ArenaAllocator 是合理的。
我们的 IntList 却不是这样。它可以用来存储 10 个或 1000 万个值。它的生命周期可以以毫秒为单位也可以以周为单位。它无法决定使用哪种类型的分配器。使用 IntList 的代码才有这种知识。最初我们是这样管理 IntList 的
var gpa std.heap.GeneralPurposeAllocator(.{}){};
const allocator gpa.allocator();var list try IntList.init(allocator);
defer list.deinit();我们可以选择 ArenaAllocator 替代
var gpa std.heap.GeneralPurposeAllocator(.{}){};
const allocator gpa.allocator();var arena std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();
const aa arena.allocator();var list try IntList.init(aa);// 说实话我很纠结是否应该调用 list.deinit。
// 从技术上讲我们不必这样做因为我们在上面调用了 defer arena.deinit()。defer list.deinit();...由于 IntList 接受的参数是 std.mem.Allocator 因此我们不需要做什么改变。如果 IntList内部创建了自己的 ArenaAllocator那也是可行的。允许在ArenaAllocator内部创建ArenaAllocator。
最后举个简单的例子我上面提到的 HTTP 服务器在响应中暴露了一个 ArenaAllocator。一旦发送了响应它就会被清空。由于ArenaAllocator的生命周期可以预测从请求开始到请求结束因此它是一种高效的选择。就性能和易用性而言它都是高效的。
1.9 固定缓冲区分配器 FixedBufferAllocator
我们要讨论的最后一个分配器是 std.heap.FixedBufferAllocator它可以从我们提供的缓冲区即 []u8中分配内存。这种分配器有两大好处。首先由于所有可能使用的内存都是预先创建的因此速度很快。其次它自然而然地限制了可分配内存的数量。这一硬性限制也可以看作是一个缺点。另一个缺点是free 和 destroy 只对最后分配/创建的项目有效想想堆栈。调用释放非最后分配的内存是安全的但不会有任何作用。FixedBufferAllocator 会按照栈的方式进行内存分配和释放。你可以分配新的内存块但只能按照后进先出LIFO的顺序释放它们。
const std import(std);pub fn main() !void {var buf: [150]u8 undefined;var fa std.heap.FixedBufferAllocator.init(buf);defer fa.reset();const allocator fa.allocator();const json try std.json.stringifyAlloc(allocator, .{.this_is an anonymous struct,.above true,.last_param are options,}, .{.whitespace .indent_2});defer allocator.free(json);std.debug.print({s}\n, .{json});
}输出内容
{this_is: an anonymous struct,above: true,last_param: are options
}但如果将 buf 更改为 [120]u8将得到一个内存不足的错误。
固定缓冲区分配器FixedBufferAllocators的常见模式是 reset 并重复使用竞技场分配器ArenaAllocators也是如此。这将释放所有先前的分配并允许重新使用分配器。
由于没有默认的分配器Zig 在分配方面既透明又灵活。std.mem.Allocator接口非常强大它允许专门的分配器封装更通用的分配器正如我们在ArenaAllocator中看到的那样。
更广泛地说我们希望堆分配的强大功能和相关责任是显而易见的。对于大多数程序来说分配任意大小、任意生命周期的内存的能力是必不可少的。
然而由于动态内存带来的复杂性你应该注意寻找替代方案。例如上面我们使用了 std.fmt.allocPrint但标准库中还有一个 std.fmt.bufPrint。后者使用的是缓冲区而不是分配器
const std import(std);pub fn main() !void {const name Leto;var buf: [100]u8 undefined;const greeting try std.fmt.bufPrint(buf, Hello {s}, .{name});std.debug.print({s}\n, .{greeting});
}该 API 将内存管理的负担转移给了调用者。如果名称较长或 buf 较小bufPrint 可能会返回 NoSpaceLeft 的错误。但在很多情况下应用程序都有已知的限制例如名称的最大长度。在这种情况下bufPrint 更安全、更快速。
动态分配的另一个可行替代方案是将数据流传输到 std.io.Writer。与我们的 Allocator 一样Writer 也是被许多具体类型实现的接口。上面我们使用 stringifyAlloc 将 JSON 序列化为动态分配的字符串。我们本可以使用 stringify 将其写入到一个 Writer 中
pub fn main() !void {const out std.io.getStdOut();try std.json.stringify(.{.this_is an anonymous struct,.above true,.last_param are options,}, .{.whitespace .indent_2}, out.writer());
}在很多情况下用 std.io.BufferedWriter 封装我们的 Writer 会大大提高性能。
我们的目标并不是消除所有动态分配。这行不通因为这些替代方案只有在特定情况下才有意义。但现在你有了很多选择。从堆栈到通用分配器以及所有介于两者之间的东西比如静态缓冲区、流式 Writer 和专用分配器。