1223 字
3 分钟
对象池(Object Pool)
一、为什么需要对象池
想象一个游戏引擎每帧要创建上千个粒子对象。粒子诞生、飞行、消亡,下一帧又是新一轮粒子。每一次 new Particle() 都意味着内存分配,每一次消亡都等着 GC 来回收。GC 一旦触发,整个游戏循环停顿几毫秒,玩家感受到的就是画面卡顿。
再比如一个 HTTP 服务器,每个请求都需要一个 bytes.Buffer 来拼装响应。QPS 过万时,每秒分配上万个 Buffer,GC 压力陡增。服务明明还有足够的 CPU 处理请求,却被垃圾回收拖慢了节奏。
问题的根源在于:频繁创建和销毁同类型的短期对象,会让分配器和 GC 都过载。创建需要找空闲内存块、初始化字段;销毁需要标记为可回收、等待 GC 扫描。如果这些对象的结构都一样,为什么要反复经历创建-销毁的轮回?
对象池的思路很简单:提前造好一批对象,用的时候取,用完还回去,不销毁。
二、现实类比
共享单车站点。不用每次出行都买一辆新车,你从停靠点取一辆,骑完还回去。车是预先购置的,被很多人反复使用。站点里总有一定数量的车待命,高峰期不够了再临时调配。
三、核心思想
对象池维护一组预初始化的对象。调用方通过 Get() 获取对象,用完通过 Put() 归还而非丢弃。归还时重置对象状态,使其像新的一样等待下一次使用。
sequenceDiagram
participant C as 调用方
participant P as 对象池
C->>P: Get()
P-->>C: 返回已有对象(或创建新的)
Note over C: 使用对象...
C->>P: Put(对象)
Note over P: 重置状态,存入池中
Note over P: 下次 Get() 无需分配
核心权衡:内存占用(空闲对象常驻池中)vs CPU/GC 节省(热路径零分配)。
| 操作 | 复杂度 | 说明 |
|---|---|---|
| Get(池有空闲) | O(1) | 直接返回已有对象 |
| Get(池为空) | O(alloc) | 创建新对象 |
| Put(归还) | O(1) | 重置状态 + 推入空闲列表 |
| 内存开销 | O(池大小) | 空闲对象保留在储备中 |
四、变体与对比
| 模式 | 与对象池的关系 | 适用场景 |
|---|---|---|
| 空闲链表(Free List) | 空闲链表管理池内部的槽位分配 | 固定大小内存块的分配回收 |
| Arena 分配器 | Arena 为池对象批量分配内存 | 同一生命周期的大量临时对象 |
| 引用计数 | 引用计数追踪何时可将对象归还池中 | 共享所有权的资源管理 |
| 信号量 | 池大小充当信号量限制并发使用 | 连接池、线程池等有界资源 |
五、多语言实现
5.1 Go 实现
Go 标准库的 sync.Pool 是生产级对象池,采用每 P 本地池实现无锁访问。下面演示一个通用的对象池:
package pool
import "sync"
// BufferPool 复用 bytes.Buffer,减少 HTTP 处理中的分配var bufPool = sync.Pool{ New: func() any { // 预分配 4KB 容量 b := make([]byte, 0, 4096) return &b },}
// GetBuffer 从池中获取一个 bufferfunc GetBuffer() *[]byte { return bufPool.Get().(*[]byte)}
// PutBuffer 归还 buffer 到池中func PutBuffer(buf *[]byte) { // 重置长度但保留容量,下次可直接复用 *buf = (*buf)[:0] bufPool.Put(buf)}
// ProcessRequest 演示池化 buffer 的典型用法func ProcessRequest(data []byte) []byte { buf := GetBuffer() defer PutBuffer(buf)
*buf = append(*buf, data...) // ... 业务处理 ...
// 必须复制结果,因为 buf 会被归还复用 result := make([]byte, len(*buf)) copy(result, *buf) return result}关键点:sync.Pool 的 Get() 先从当前 P 的本地池取(无锁),本地池空了再去其他 P 偷。Put() 也优先放回本地池。这种分片设计极大减少了锁竞争。
5.2 TypeScript 实现
class ObjectPool<T> { private pool: T[] = []; private factory: () => T; private reset: (obj: T) => void;
constructor( factory: () => T, reset: (obj: T) => void, initialSize = 0 ) { this.factory = factory; this.reset = reset; // 预热:提前创建初始对象 for (let i = 0; i < initialSize; i++) { this.pool.push(factory()); } }
get(): T { // 池中有空闲对象则复用,否则新建 if (this.pool.length > 0) { return this.pool.pop()!; } return this.factory(); }
release(obj: T): void { // 重置对象状态后放回池中 this.reset(obj); this.pool.push(obj); }
get size(): number { return this.pool.length; }}
// 使用示例:连接池interface Connection { id: number; query(sql: string): string; reset(): void;}
const connPool = new ObjectPool<Connection>( () => ({ id: Math.random(), query: (sql) => `result: ${sql}`, reset() {} }), (conn) => conn.reset(), 5);六、生产验证
| 项目 | 源码位置 | 用途 |
|---|---|---|
| Go 标准库 | pool.go#L52-L97 | sync.Pool 的 Get() 先查 per-P 本地池(无锁),回退到从其他 P 偷取。被 fmt、encoding/json、HTTP 处理器广泛使用 |
| Godot 引擎 | pooled_list.h#L35-L100 | 基于 freelist 的对象池,元素在连续页中分配,避免每帧为实体、粒子、物理体分配内存 |
| .NET 运行时 | ArrayPool.cs | ArrayPool<T> 提供可复用数组的共享池,被 ASP.NET Core 和 System.Text.Json 使用 |
七、小结
何时使用:
- 高频分配场景——游戏循环、请求处理、粒子系统
- 构造昂贵的对象——数据库连接、线程上下文、大缓冲区
- GC 敏感的系统——实时服务、游戏引擎、低延迟交易
- 固定资源限制——连接池、线程池、文件描述符池
何时不用:
- 廉价对象——分配快且 GC 压力不大时,池增加了不必要的复杂性
- 生命周期差异大——对象持有时间不可预测时,池帮不上忙
- 小规模使用——对象数量少时,池的管理开销超过节省
- 不可变对象——池只对需要重置的可变对象有意义
八、参考资料
- Go sync.Pool 源码 - Go 标准库对象池实现,per-P 分片设计
- Godot PooledList - 游戏引擎中基于 freelist 的对象池
- .NET ArrayPool - 可复用数组的共享池实现
- HikariCP - JDBC 连接池,默认最大 10 个连接的设计哲学
- Unity ObjectPool - Unity 引擎内置的对象池 API
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时
相关文章 智能推荐
1
享元(Flyweight)
程序设计 共享不可变的部分,只存储变化的部分——用更少的对象表示更多的数据,游戏引擎和编译器中的经典内存优化。
2
Arena 分配器(Arena Allocator)
程序设计 整块申请内存,用完一次性释放——bump pointer 分配的极致性能,编译器和游戏引擎的核心内存策略。
3
空闲链表(Free List)
程序设计 用链表管理已释放的内存块,O(1) 分配和释放——操作系统内核和游戏引擎的底层分配利器。
4
写时复制(Copy-on-Write)
程序设计 共享读取,只在修改时才复制——节省内存的延迟优化策略,Linux fork 和 Git 对象模型的基石。
5
Slab 分配器(Slab Allocator)
程序设计 固定大小对象池,消除碎片——Linux 内核和 Memcached 的高频对象分配策略






