4、对象池技术:对象池设计模式、固定大小对象池、动态扩容对象池
对象池,说白了就是「复用」的艺术。
游戏服务器里,每秒可能有成千上万个对象被创建和销毁。子弹、技能特效、网络消息包、怪物AI状态……你想想看,如果每个都 new 一下、delete 一下,GC(垃圾回收)不把你服务器卡成幻灯片才怪。
我个人习惯是:能用池子的地方,绝不让 GC 碰一下。
4.1 对象池设计模式:核心思想
对象池的核心就三件事:借、还、复用。
- 借(Acquire):从池子里拿一个空闲对象。池子空了?那就新建一个(或者阻塞等待)。
- 还(Release):用完了放回去,别真销毁。重置一下状态就行。
- 复用(Reuse):下次再借,拿到的可能是之前用过的对象。
我在项目中遇到过最典型的场景:网络消息包的序列化缓冲区。每个玩家每秒发几十个包,每个包都要分配一个 byte[]。如果不做池化,GC 压力直接爆炸。
核心原则:对象池只解决「频繁创建销毁」的问题,不解决「对象太大」的问题。大对象该用别的策略,后面会讲。
4.2 固定大小对象池
固定大小池子,就是预先分配好 N 个对象,用完了就等着,或者直接报错。
这种模式适合对象数量可控、峰值可预测的场景。比如:连接池、线程池、固定数量的子弹对象。
嗯,这里要注意:固定大小池子最怕「借了不还」。一旦泄漏,池子就慢慢空了,整个系统可能卡死。
4.2.1 简单实现(C# 伪代码)
public class FixedObjectPool<T> where T : class, new()
{
private readonly T[] _pool;
private readonly bool[] _inUse;
private readonly object _lock = new object();
public FixedObjectPool(int size)
{
_pool = new T[size];
_inUse = new bool[size];
for (int i = 0; i < size; i++)
{
_pool[i] = new T();
_inUse[i] = false;
}
}
public T Acquire()
{
lock (_lock)
{
for (int i = 0; i < _pool.Length; i++)
{
if (!_inUse[i])
{
_inUse[i] = true;
ResetObject(_pool[i]); // 重置状态
return _pool[i];
}
}
}
throw new Exception("对象池已耗尽!");
}
public void Release(T obj)
{
lock (_lock)
{
for (int i = 0; i < _pool.Length; i++)
{
if (ReferenceEquals(_pool[i], obj))
{
_inUse[i] = false;
return;
}
}
}
throw new Exception("释放的对象不属于此池!");
}
private void ResetObject(T obj)
{
// 根据具体类型重置字段
}
}
我的经验:固定大小池子的「大小」怎么定?我一般会压测出峰值并发数,然后乘以 1.2 的安全系数。太小了容易耗尽,太大了浪费内存。
4.3 动态扩容对象池
固定大小池子有个硬伤:你永远猜不准峰值。万一某个活动突然涌入大量玩家,池子一耗尽,服务器直接报错。这谁受得了?
所以就有了动态扩容对象池。池子不够用了?那就自动新建。用完了?可以缩回去,也可以留着备用。
我曾经在 MMORPG 的技能特效系统里用过动态池。平时副本里特效不多,但团战一开,满屏都是技能光效。固定池子根本扛不住,动态扩容才是正解。
4.3.1 扩容策略
扩容不是无脑翻倍。我见过有人每次不够就 new 一倍,结果内存直接爆了。合理的策略有几种:
| 策略 | 说明 | 适用场景 |
|---|---|---|
| 线性扩容 | 每次增加固定数量(如 100 个) | 对象创建成本低,峰值增长平缓 |
| 指数扩容 | 每次翻倍(如 1.5 倍或 2 倍) | 对象创建成本高,需要快速应对突发 |
| 按需扩容 | 根据历史峰值动态调整 | 有监控系统,能记录使用曲线 |
我个人习惯用线性扩容 + 上限保护。比如每次扩 50 个,但最多不超过 5000 个。这样既不会突然爆内存,也能应对大部分突发情况。
4.3.2 缩容策略
扩容容易,缩容难。你想想看,高峰期扩了 2000 个对象,高峰期一过,这些对象还占着内存呢。要不要缩?
我的建议是:不要频繁缩容。缩容本身也有开销,而且万一明天又来一波高峰呢?
- 定时缩容:比如每 5 分钟检查一次,如果空闲对象超过 30%,就释放一半。
- 惰性缩容:只在内存紧张时触发缩容。配合 GC 通知或者内存监控。
- 不缩容:如果服务器内存够大,干脆不缩。省心。
避坑指南:我曾经在动态池里犯过一个低级错误——缩容时没有检查对象是否还在使用中。结果把正在被引用的对象给释放了,导致野指针崩溃。后来我加了一个引用计数,只有引用计数为 0 的对象才能被缩容。
4.4 对象池的通用注意事项
不管用哪种池子,有几个坑是绕不开的:
- 线程安全:游戏服务器多线程环境,池子的 Acquire 和 Release 必须加锁。但锁的粒度要小,别搞成全局大锁。我一般用
ConcurrentQueue或者分段锁。 - 对象重置:还回池子时,一定要把对象状态清干净。否则下次拿到的是脏数据。我习惯在 Release 里强制调用一个
Reset()方法。 - 泄漏检测:写一个调试模式,定期检查池子里有没有对象被借走太久。超过一定时间没还,打印警告日志。这个习惯帮我抓过好几次泄漏。
- 池子大小监控:把池子的使用率、等待次数、扩容次数都暴露成性能计数器。线上出问题时,这些数据能救命。
一句话总结:对象池不是银弹。它只解决「频繁创建销毁」的问题。如果你的对象生命周期很长,或者对象本身巨大,池化反而浪费内存。该用的时候用,不该用的时候别硬上。
好了,对象池这块就聊到这儿。下一章我们聊聊内存池与自定义分配器——那是更底层的东西,能让你对内存的控制力再上一个台阶。