对象池是一种设计模式,它会预先初始化一组可重用的实体,而不是按需销毁然后重建。在使用套接字描述符时,人们通常会将其池化。实际上,套接字描述符的数量通常比较少(最多上千个),之所以要采用池的方式,是因为它们的初始化成本非常高。而在最近发表的一篇 博文 中, ClojureWerkz 核心成员 Alex Petrov 探讨了另一种对象池应用场景,即将大量的存活期短且初始化成本低的对象池化,以降低内存分配和再分配成本,避免内存碎片。
Alex将对象池看作是减少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
速度。例如,如果池中有100个对象,每秒有20个对象被取走,但只有10个对象返回,那么9秒后池就空了。开发者可以使用这种信息,提前做好对象分配计划。 增长策略用于指定分配过程被触发后需要分配的对象的数量。Alex也介绍了三种方式:
当然,使用对象池就意味着开发者开始自己管理内存,所以需要注意以下问题:
最后,Alex指出:
对象池并不适合所有人。在应用程序开发的早期阶段就开始使用对象池是没有意义的,因为你那时候还不能确切地知道什么需要池化,也不确定如何池化。
感谢郭蕾对本文的审校。
给InfoQ中文站投稿或者参与内容翻译工作,请邮件至editors@cn.infoq.com。也欢迎大家通过新浪微博(@InfoQ,@丁晓昀),微信(微信号: InfoQChina )关注我们,并与我们的编辑和其他读者朋友交流(欢迎加入InfoQ读者交流群 )。