java開源對象池,一個廣爲人知但鮮有人用的技巧:對象池

對象池是一種設計模式,它會預先初始化一組可重用的實體,而不是按需銷燬然後重建。在使用套接字描述符時,人們通常會將其池化。實際上,套接字描述符的數量通常比較少(最多上千個),之所以要採用池的方式,是因爲它們的初始化成本非常高。而在最近發表的一篇博文中,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速度:大多數時候,水位線觸發已經足夠,但有時候可能會需要更高的精度。在這種情況下,可以使用lease和return速度。例如,如果池中有100個對象,每秒有20個對象被取走,但只有10個對象返回,那麼9秒後池就空了。開發者可以使用這種信息,提前做好對象分配計劃。

增長策略用於指定分配過程被觸發後需要分配的對象的數量。Alex也介紹了三種方式:

固定大小:這是最簡單的對象池實現方式。對象一次性預分配,對象池後續不再增長。這種實現適用於對象數量相對確定的情況,但池大小固定可能會導致資源飢餓。

小步增長:爲了避免出現資源飢餓,可以允許對象池小步增長,比如一次額外分配一個對象。

塊增長:如果無法接受分配導致的中斷,就需要保證池中任何時候都有可用的對象。這時,就必須使用塊增長。例如,每當水位線到達25%時,就將對象池增大25%。不過,這種方式容易導致內存溢出。搭配Lease/Return速度分配觸發策略,可以得出更準確的池大小。

當然,使用對象池就意味着開發者開始自己管理內存,所以需要注意以下問題:

引用泄露:對象在系統中某個地方註冊了,但沒有返回到池中。

過早回收:消費者已經決定將對象返還給對象池,但仍然持有它的引用,並試圖執行寫或讀操作,這時會出現這種情況。

隱式回收:當使用引用計數時可能會出現這種情況。

大小錯誤:這種情況在使用字節緩衝區和數組時非常常見:對象應該有不同的大小,而且是以定製的方式構造,但返回對象池後卻作爲通用對象重用。

重複下單:這是引用泄露的一個變種,存在多路複用時特別容易發生:一個對象被分配到多個地方,但其中一個地方釋放了該對象。

就地修改:對象不可變是最好的,但如果不具備那樣做的條件,就可能在讀取對象內容時遇到內容被修改的問題。

縮小對象池:當池中有大量的未使用對象時,要縮小對象池。

對象重新初始化:確保每次從池中取得的對象不含有上次使用時留下的髒字段。

最後,Alex指出:

對象池並不適合所有人。在應用程序開發的早期階段就開始使用對象池是沒有意義的,因爲你那時候還不能確切地知道什麼需要池化,也不確定如何池化。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章