對象池是一種設計模式,它會預先初始化一組可重用的實體,而不是按需銷毀然後重建。在使用套接字描述符時,人們通常會將其池化。實際上,套接字描述符的數量通常比較少(最多上千個),之所以要采用池的方式,是因為它們的初始化成本非常高。而在最近發表的一篇 博文 中, 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指出:
對象池並不適合所有人。在應用程序開發的早期階段就開始使用對象池是沒有意義的,因為你那時候還不能確切地知道什麼需要池化,也不確定如何池化。