7.2 位集合、环形缓冲与侵入式链表
7.1 的两个容器都靠「扩容」活着,它们总会在某个时刻分配内存。这一节反过来,做三个构造时就分配完毕、之后一个字节都不再申请的容器:位集合(用一位表示一个布尔状态)、环形缓冲(定容队列)、侵入式链表(节点长在对象自己身上)。它们的共同点是——热路径上没有 new,也就没有 GC。
位集合:32 个布尔压进一个字
引擎里到处是「一堆开关」:哪些实体可见、哪些子系统启用了、哪些资源加载完成。用 boolean[] 存,每个标记在 JS 堆上至少占一个数组槽;用位集合,一个 Uint32Array 元素的 32 个二进制位各管一个标记。BitSet 就是这层封装,另外提供两个位运算才能优雅做到的操作:一次数完 32 位的 popcount,和跳过成片 0 的置位遍历:
// file: src/core/bitset.ts
// tier: L1
// verify: npx vitest run src/core/bitset.test.ts
//
// 位集合:用 Uint32Array 的每一位表示一个布尔状态。1M 个标记只占 128 KB,
// 且「计数」和「找下一个置位」都用位运算一次处理 32 个,比布尔数组快得多。
export class BitSet {
#words: Uint32Array;
#size: number;
constructor(size: number) {
if (size < 0) throw new RangeError('size must be >= 0');
this.#size = size;
this.#words = new Uint32Array((size + 31) >>> 5);
}
get size(): number {
return this.#size;
}
/** 容量以 32 位字为单位向上取整。 */
get wordCount(): number {
return this.#words.length;
}
set(i: number): void {
if (i < 0 || i >= this.#size) throw new RangeError(`bit ${i} out of range`);
this.#words[i >>> 5] |= 1 << (i & 31);
}
clear(i: number): void {
if (i < 0 || i >= this.#size) throw new RangeError(`bit ${i} out of range`);
this.#words[i >>> 5] &= ~(1 << (i & 31));
}
toggle(i: number): void {
this.#words[i >>> 5] ^= 1 << (i & 31);
}
test(i: number): boolean {
if (i < 0 || i >= this.#size) return false;
return (this.#words[i >>> 5] & (1 << (i & 31))) !== 0;
}
/** 置位总数:每个字用 popcount 位技巧一次数完 32 位。 */
popcount(): number {
let total = 0;
for (let w = 0; w < this.#words.length; w++) {
let x = this.#words[w];
x = x - ((x >>> 1) & 0x55555555);
x = (x & 0x33333333) + ((x >>> 2) & 0x33333333);
x = (x + (x >>> 4)) & 0x0f0f0f0f;
total += Math.imul(x, 0x01010101) >>> 24;
}
return total;
}
/** 只遍历置位的下标,跳过成片的 0。 */
*setBits(): Generator<number> {
for (let w = 0; w < this.#words.length; w++) {
let x = this.#words[w];
while (x !== 0) {
const lsb = x & -x; // 最低置位
const bit = 31 - Math.clz32(lsb);
yield (w << 5) + bit;
x ^= lsb; // 抹掉这一位
}
}
}
}
popcount 那四行是经典的 SWAR(在字内并行计数)技巧:先把每 2 位一组相加,再 4 位、8 位,最后用一个乘法把四个字节的计数汇总到最高字节。它不循环 32 次,所以数 1M 个标记的置位数只是一次线性扫描。
本机实测 1M 个标记(每 3 个置 1 位):BitSet 的堆增量是 0.0 MB(Uint32Array 的 buffer 不走 JS 堆),而 boolean[] 是 7.6 MB——差了三个数量级。但遍历全部置位时,BitSet 用了 7.31 ms,boolean[] 只用 2.11 ms:位技巧的逐位拆解有开销。所以位集合的赢面在内存和稀疏扫描(只走置位、跳过成片 0),不在「密集全扫」这一项上。
环形缓冲:定容、零分配的队列
「最近 N 帧耗时」「待处理的事件」「网络收包缓冲」这类需求,天然适合环形缓冲:容量固定,写满就绕回头部。它的关键不是功能,而是构造时把底层数组一次分配好,之后 push / shift 都不再分配:
// file: src/core/ring-buffer.ts
// tier: L1
// verify: npx vitest run src/core/ring-buffer.test.ts
//
// 环形缓冲:固定容量、首尾相接的队列。构造时一次性分配底层数组,
// 之后 push / pop / shift 都不再分配——这是它在帧内热路径上的全部价值。
export class RingBuffer<T> {
#buf: (T | undefined)[];
#cap: number;
#head = 0; // 指向最早元素
#count = 0;
constructor(capacity: number) {
if (capacity <= 0) throw new RangeError('capacity must be > 0');
this.#cap = capacity;
this.#buf = new Array(capacity);
}
get capacity(): number {
return this.#cap;
}
get size(): number {
return this.#count;
}
get isFull(): boolean {
return this.#count === this.#cap;
}
get isEmpty(): boolean {
return this.#count === 0;
}
/** 入队;满则返回 false(不覆盖、不分配)。 */
push(value: T): boolean {
if (this.#count === this.#cap) return false;
this.#buf[(this.#head + this.#count) % this.#cap] = value;
this.#count++;
return true;
}
/** 入队;满则覆盖最旧元素并返回 true(用于「最近 N 帧」这类场景)。 */
pushOverwrite(value: T): void {
if (this.#count < this.#cap) {
this.#buf[(this.#head + this.#count) % this.#cap] = value;
this.#count++;
} else {
this.#buf[this.#head] = value;
this.#head = (this.#head + 1) % this.#cap;
}
}
/** 出队(先进先出)。 */
shift(): T | undefined {
if (this.#count === 0) return undefined;
const v = this.#buf[this.#head];
this.#buf[this.#head] = undefined;
this.#head = (this.#head + 1) % this.#cap;
this.#count--;
return v;
}
/** 看队头但不移除。 */
peek(): T | undefined {
return this.#count === 0 ? undefined : this.#buf[this.#head];
}
/** 按入队顺序遍历,不回绕到预留槽。 */
*[Symbol.iterator](): Generator<T> {
for (let i = 0; i < this.#count; i++) {
yield this.#buf[(this.#head + i) % this.#cap] as T;
}
}
toArray(): T[] {
return [...this];
}
}
push 和 pushOverwrite 是两种语义:前者满了就拒绝(有界队列,保护内存),后者满了就覆盖最旧元素(滑动窗口,保留最近 N 个)。(head + count) % cap 是回绕的写法,注意取模的对象是物理下标、不是逻辑序号——这是环形缓冲最容易写错的地方。本机实测:容量 1024、连推 1000 万次(满则先 shift 再 push),总耗时 63.7 ms,全程零分配。
侵入式链表:节点长在对象身上
普通链表的每个节点是一个独立对象,next 指针把一堆对象串起来——插入时要 new 一个节点,删除时要等 GC。侵入式链表反过来:next 和 prev 直接长在对象自己身上,链表不拥有内存。在 TS 里,我们让对象用一段按索引编号的池表示,next / prev 就是两条平行的 Int32Array,于是「挂进链表」「摘出链表」都只是改两个整数,O(1) 且零分配:
// file: src/core/intrusive-list.ts
// tier: L1
// verify: npx vitest run src/core/intrusive-list.test.ts
//
// 侵入式链表:链表节点(next / prev)不额外分配,直接长在对象自己身上。
// 这里的对象是一段按索引编号的池,next / prev 是两条平行的 Int32Array,
// 所以「挂进链表」不产生任何新分配,O(1) 插入与删除。
const NIL = -1;
export class IntrusiveList {
#next: Int32Array;
#prev: Int32Array;
#capacity: number;
#head = NIL;
#tail = NIL;
#count = 0;
constructor(capacity: number) {
if (capacity <= 0) throw new RangeError('capacity must be > 0');
this.#capacity = capacity;
this.#next = new Int32Array(capacity).fill(NIL);
this.#prev = new Int32Array(capacity).fill(NIL);
}
get size(): number {
return this.#count;
}
get head(): number {
return this.#head;
}
get tail(): number {
return this.#tail;
}
/** 越界 id 必须显式拒绝:Int32Array 越界写入是静默丢弃的。 */
#check(id: number): void {
if (id < 0 || id >= this.#capacity) {
throw new RangeError(`node id ${id} out of capacity ${this.#capacity}`);
}
}
/** 把节点 id 挂到链表尾部,O(1),零分配。 */
pushBack(id: number): void {
this.#check(id);
this.#prev[id] = this.#tail;
this.#next[id] = NIL;
if (this.#tail === NIL) this.#head = id;
else this.#next[this.#tail] = id;
this.#tail = id;
this.#count++;
}
/** 把节点 id 挂到链表头部,O(1)。 */
pushFront(id: number): void {
this.#check(id);
this.#next[id] = this.#head;
this.#prev[id] = NIL;
if (this.#head === NIL) this.#tail = id;
else this.#prev[this.#head] = id;
this.#head = id;
this.#count++;
}
/** 从链表中摘除节点 id,O(1),不需要遍历。 */
remove(id: number): void {
this.#check(id);
const p = this.#prev[id];
const n = this.#next[id];
if (p === NIL) this.#head = n;
else this.#next[p] = n;
if (n === NIL) this.#tail = p;
else this.#prev[n] = p;
this.#prev[id] = NIL;
this.#next[id] = NIL;
this.#count--;
}
/** 弹出头部节点并返回其 id。 */
popFront(): number {
if (this.#head === NIL) return NIL;
const id = this.#head;
this.remove(id);
return id;
}
/** 从任意节点出发向后遍历(含它自己)。id 越界或为 NIL 时不产出。 */
*from(id: number): Generator<number> {
if (id < 0 || id >= this.#capacity) return;
let cur = id;
while (cur !== NIL) {
yield cur;
cur = this.#next[cur];
}
}
/** 顺序遍历整条链表。 */
*[Symbol.iterator](): Generator<number> {
yield* this.from(this.#head);
}
toArray(): number[] {
return [...this];
}
}
export { NIL };
remove(id) 是它的核心价值:只要你手里有节点的 id,摘链就是 O(1),不需要从头找前驱——因为前驱就存在 prev[id] 里。这让「从事件队列里取消一个事件」「从更新列表里临时摘掉一个实体」都变成常数时间。
实测小结与两个坑
三个容器都有 vitest 用例:位集合的 popcount / setBits 顺序、环形缓冲的回绕与覆盖语义、侵入式链表的中间摘链与续遍历,npx vitest run 全部通过。实测数据汇总如下:
| 容器 | 测点 | 本机结果 |
|---|---|---|
| BitSet vs boolean[] | 1M 标记堆增量 | 0.0 MB vs 7.6 MB |
| BitSet vs boolean[] | 遍历全部置位 | 7.31 ms vs 2.11 ms |
| RingBuffer | 1000 万次 push | 63.7 ms,零分配 |
两个必须记住的坑,都来自「用 TypedArray 存结构」这件事:
- TypedArray 越界写入是静默丢弃的。
new Int32Array(8)写arr[8]不报错也不生效,读arr[8]得到undefined。写侵入式链表时,如果 id 超出容量却没人拦,next[id]会变成undefined,遍历循环while (cur !== NIL)就永远停不下来——直接吃满内存。helio 的#check和from里的边界判断,就是为了把这种「静默损坏」变成一次显式的RangeError。 %的取模对象是物理下标。环形缓冲里(head + count) % cap的head + count可能大于等于cap,取模后才是真实下标;把逻辑序号直接当物理下标用,回绕一次就会读错位置。
这三个容器是后面 ECS(第 12、13 章)和 Job System(第 14 章)的零件:位集合存组件的存在标记,环形缓冲当事件队列与帧历史,侵入式链表管可随时摘除的更新列表。7.3 处理最后一种「基础标识」——字符串与资源名。
阅读导航:上一节:动态数组与开放寻址哈希 · 下一节:字符串 intern、资源 GUID 与名字表 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。