一个广为人知但鲜有人用的技巧:对象池

  • 谢丽

2015 年 7 月 31 日

话题:设计模式语言 & 开发架构

对象池是一种设计模式,它会预先初始化一组可重用的实体,而不是按需销毁然后重建。在使用套接字描述符时,人们通常会将其池化。实际上,套接字描述符的数量通常比较少(最多上千个),之所以要采用池的方式,是因为它们的初始化成本非常高。而在最近发表的一篇博文中,ClojureWerkz核心成员Alex Petrov探讨了另一种对象池应用场景,即将大量的存活期短且初始化成本低的对象池化,以降低内存分配和再分配成本,避免内存碎片。

Alex 将对象池看作是减少 GC 压力的首选方法,同时也是最简单的方法。在下面两种分配模式下,可以选择使用对象池:

  • 对象以固定的速度不断地分配,垃圾收集时间逐步增加,内存使用率随之增大;
  • 对象分配存在爆发期,而每次爆发都会导致系统迟滞,并伴有明显的 GC 中断。

在绝大多数情况下,这些对象要么是数据容器,要么是数据封装器,其作用是在应用程序和内部消息总线、通信层或某些 API 之间充当一个信封。这很常见。例如,数据库驱动会针对每个请求和响应创建 Request 和 Response 对象,消息系统会使用 Message 和 Event 封装器,等等。对象池可以帮助保存和重用这些构造好的对象实例。

Alex 介绍了两种基本的对象池回收模式:“借用(borrowing)”和引用计数。前者更清晰,而后者则意味着要实现自动回收。

借用非常像垃圾收集运行时之上的malloc/free。自然地,在使用这种方式时,开发人员需要面对早先使用非垃圾收集语言时面对的问题。如果某个对象已经释放并返回到池中,那么任何对它的修改或读取都会产生不可预见的结果。例如,在 C 语言中,对已释放的指针进行任何操作都会产生块错误。借用适用于有明确的开始 / 结束点的操作。绝大多数时候,都不要将它用于对象可以被多个线程同步访问的情况。借用最大的优点是,它不知道对象池的存在。被借用的对象本身要有某种reset机制,借用和返回操作都由对象消费者完成。

引用计数在实现方面稍微复杂些,但它对数据结构提供了更细粒度的控制。将对象池封装到一个函数式接口中,消费者就可以不必了解它,就像下面这个样子:

(pooledObject, pooledObjectConsumer) -> { pooledObject.retain(); pooledObjectConsumer.accept(pooledObject); pooledObject.release(); }; 

每当对象进入上述代码块,调用者就会retain该对象,并在执行块执行完毕后将其release。每个对象都持有一个内部计数器和一个指向池的引用。当计数器为 0 时,对象就会返回池中。

通常,引用计数用于同时有多个消费者访问已分配对象的情况,只有当所有的消费者都释放了对象引用时,对象才可以被回收。这种方式也适用于管道或嵌套处理。在这种情况下,开发者可以避免显式的开始 / 结束操作。

分配触发负责在池中对象不足时分配新资源。Alex 介绍了如下三种分配触发方式:

  • 空池触发:任何时候,只要池空了,就分配对象。这是一种最简单的方式。
  • 水位线:空池触发的缺点是,某次对象请求会因为执行对象分配而中断。为了避免这种情况,可以使用水位线触发。当从池中请求新对象时,检查池中可用对象的数量。如果可用对象小于某个阈值,就触发分配过程。
  • Lease/Return 速度:大多数时候,水位线触发已经足够,但有时候可能会需要更高的精度。在这种情况下,可以使用leasereturn速度。例如,如果池中有 100 个对象,每秒有 20 个对象被取走,但只有 10 个对象返回,那么 9 秒后池就空了。开发者可以使用这种信息,提前做好对象分配计划。

增长策略用于指定分配过程被触发后需要分配的对象的数量。Alex 也介绍了三种方式:

  • 固定大小:这是最简单的对象池实现方式。对象一次性预分配,对象池后续不再增长。这种实现适用于对象数量相对确定的情况,但池大小固定可能会导致资源饥饿。
  • 小步增长:为了避免出现资源饥饿,可以允许对象池小步增长,比如一次额外分配一个对象。
  • 块增长:如果无法接受分配导致的中断,就需要保证池中任何时候都有可用的对象。这时,就必须使用块增长。例如,每当水位线到达 25% 时,就将对象池增大 25%。不过,这种方式容易导致内存溢出。搭配 Lease/Return 速度分配触发策略,可以得出更准确的池大小。

当然,使用对象池就意味着开发者开始自己管理内存,所以需要注意以下问题:

  • 引用泄露:对象在系统中某个地方注册了,但没有返回到池中。
  • 过早回收:消费者已经决定将对象返还给对象池,但仍然持有它的引用,并试图执行写或读操作,这时会出现这种情况。
  • 隐式回收:当使用引用计数时可能会出现这种情况。
  • 大小错误:这种情况在使用字节缓冲区和数组时非常常见:对象应该有不同的大小,而且是以定制的方式构造,但返回对象池后却作为通用对象重用。
  • 重复下单:这是引用泄露的一个变种,存在多路复用时特别容易发生:一个对象被分配到多个地方,但其中一个地方释放了该对象。
  • 就地修改:对象不可变是最好的,但如果不具备那样做的条件,就可能在读取对象内容时遇到内容被修改的问题。
  • 缩小对象池:当池中有大量的未使用对象时,要缩小对象池。
  • 对象重新初始化:确保每次从池中取得的对象不含有上次使用时留下的脏字段。

最后,Alex 指出:

对象池并不适合所有人。在应用程序开发的早期阶段就开始使用对象池是没有意义的,因为你那时候还不能确切地知道什么需要池化,也不确定如何池化。


感谢郭蕾对本文的审校。

给 InfoQ 中文站投稿或者参与内容翻译工作,请邮件至editors@cn.infoq.com。也欢迎大家通过新浪微博(@InfoQ@丁晓昀),微信(微信号:InfoQChina)关注我们,并与我们的编辑和其他读者朋友交流(欢迎加入 InfoQ 读者交流群)。

设计模式语言 & 开发架构