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 对象池的通用注意事项

不管用哪种池子,有几个坑是绕不开的:

  1. 线程安全:游戏服务器多线程环境,池子的 Acquire 和 Release 必须加锁。但锁的粒度要小,别搞成全局大锁。我一般用 ConcurrentQueue 或者分段锁。
  2. 对象重置:还回池子时,一定要把对象状态清干净。否则下次拿到的是脏数据。我习惯在 Release 里强制调用一个 Reset() 方法。
  3. 泄漏检测:写一个调试模式,定期检查池子里有没有对象被借走太久。超过一定时间没还,打印警告日志。这个习惯帮我抓过好几次泄漏。
  4. 池子大小监控:把池子的使用率、等待次数、扩容次数都暴露成性能计数器。线上出问题时,这些数据能救命。

一句话总结:对象池不是银弹。它只解决「频繁创建销毁」的问题。如果你的对象生命周期很长,或者对象本身巨大,池化反而浪费内存。该用的时候用,不该用的时候别硬上。

好了,对象池这块就聊到这儿。下一章我们聊聊内存池与自定义分配器——那是更底层的东西,能让你对内存的控制力再上一个台阶。