commons-pool2 池化技術探究
- 2021 年 4 月 27 日
- 筆記
- commons-pool2, JAVA, 伺服器, 池化技術
一、前言
我們經常會接觸各種池化的技術或者概念,包括對象池、連接池、執行緒池等,池化技術最大的好處就是實現對象的重複利用,尤其是創建和使用大對象或者寶貴資源(HTTP連接對象,MySQL連接對象)等方面的時候能夠大大節省系統開銷,對提升系統整體性能也至關重要。
在並發請求下,如果需要同時為幾百個query操作創建/關閉MySQL的連接或者是為每一個HTTP請求創建一個處理執行緒或者是為每一個圖片或者XML解析創建一個解析對象而不使用池化技術,將會給系統帶來極大的負載挑戰。
本文主要是分析commons-pool2池化技術的實現方案,希望通過本文能讓讀者對commons-pool2的實現原理一個更全面的了解。
二、commons-pool2池化技術剖析
越來越多的框架在選擇使用apache commons-pool2進行池化的管理,如jedis-cluster,commons-pool2工作的邏輯如下圖所示:
2.1 核心三元素
2.1.1 ObjectPool
對象池,負責對對象進行生命周期的管理,並提供了對對象池中活躍對象和空閑對象統計的功能。
2.1.2 PooledObjectFactory
對象工廠類,負責具體對象的創建、初始化,對象狀態的銷毀和驗證。commons-pool2框架本身提供了默認的抽象實現BasePooledObjectFactory ,業務方在使用的時候只需要繼承該類,然後實現warp和create方法即可。
2.1.3 PooledObject
池化對象,是需要放到ObjectPool對象的一個包裝類。添加了一些附加的資訊,比如說狀態資訊,創建時間,激活時間等。commons-pool2提供了DefaultPooledObject和 PoolSoftedObject 2種實現。其中PoolSoftedObject繼承自DefaultPooledObject,不同點是使用SoftReference實現了對象的軟引用。獲取對象的時候使用也是通過SoftReference進行獲取。
2.2 對象池邏輯分析
2.2.1 對象池介面說明
1)我們在使用commons-pool2的時候,應用程式獲取或釋放對象的操作都是基於對象池進行的,對象池核心介面主要包括如下:
/**
*向對象池中增加對象實例
*/
void addObject() throws Exception, IllegalStateException,
UnsupportedOperationException;
/**
* 從對象池中獲取對象
*/
T borrowObject() throws Exception, NoSuchElementException,
IllegalStateException;
/**
* 失效非法的對象
*/
void invalidateObject(T obj) throws Exception;
/**
* 釋放對象至對象池
*/
void returnObject(T obj) throws Exception;
除了介面本身之外,對象池還支援對對象的最大數量,保留時間等等進行設置。對象池的核心參數項包括maxTotal,maxIdle,minIdle,maxWaitMillis,testOnBorrow 等。
2.2.2 對象創建解耦
對象工廠是commons-pool2框架中用於生成對象的核心環節,業務方在使用過程中需要自己去實現對應的對象工廠實現類,通過工廠模式,實現了對象池與對象的生成與實現過程細節的解耦,每一個對象池應該都有對象工廠的成員變數,如此實現對象池本身和對象的生成邏輯解耦。
可以通過程式碼進一步驗證我們的思路:
public GenericObjectPool(final PooledObjectFactory<T> factory) {
this(factory, new GenericObjectPoolConfig<T>());
}
public GenericObjectPool(final PooledObjectFactory<T> factory,
final GenericObjectPoolConfig<T> config) {
super(config, ONAME_BASE, config.getJmxNamePrefix());
if (factory == null) {
jmxUnregister(); // tidy up
throw new IllegalArgumentException("factory may not be null");
}
this.factory = factory;
idleObjects = new LinkedBlockingDeque<>(config.getFairness());
setConfig(config);
}
public GenericObjectPool(final PooledObjectFactory<T> factory,
final GenericObjectPoolConfig<T> config, final AbandonedConfig abandonedConfig) {
this(factory, config);
setAbandonedConfig(abandonedConfig);
}
可以看到對象池的構造方法,都依賴於對象構造工廠PooledObjectFactory,在生成對象的時候,基於對象池中定義的參數和對象構造工廠來生成。
/**
* 向對象池中增加對象,一般在預載入的時候會使用該功能
*/
@Override
public void addObject() throws Exception {
assertOpen();
if (factory == null) {
throw new IllegalStateException(
"Cannot add objects without a factory.");
}
final PooledObject<T> p = create();
addIdleObject(p);
}
create() 方法基於對象工廠來生成的對象,繼續往下跟進程式碼來確認邏輯;
final PooledObject<T> p;
try {
p = factory.makeObject();
if (getTestOnCreate() && !factory.validateObject(p)) {
createCount.decrementAndGet();
return null;
}
} catch (final Throwable e) {
createCount.decrementAndGet();
throw e;
} finally {
synchronized (makeObjectCountLock) {
makeObjectCount--;
makeObjectCountLock.notifyAll();
}
}
此處確認了factory.makeObject()的操作,也印證了上述的推測,基於對象工廠來生成對應的對象。
為了更好的能夠實現對象池中對象的使用以及跟蹤對象的狀態,commons-pool2框架中使用了池化對象PooledObject的概念,PooledObject本身是泛型類,並提供了getObject()獲取實際對象的方法。
2.2.3 對象池源碼分析
經過上述分析我們知道了對象池承載了對象的生命周期的管理,包括整個對象池中對象數量的控制等邏輯,接下來我們通過GenericObjectPool的源碼來分析究竟是如何實現的。
對象池中使用了雙端隊列LinkedBlockingDeque來存儲對象,LinkedBlockingDeque對列支援FIFO和FILO兩種策略,基於AQS來實現隊列的操作的協同。
LinkedBlockingDeque提供了隊尾和隊頭的插入和移除元素的操作,相關操作都進行了加入重入鎖的加鎖操作隊列中設置notFull 和 notEmpty兩個狀態變數,當對隊列進行元素的操作的時候會觸發對應的執行await和notify等操作。
/**
* 第一個節點
* Invariant: (first == null && last == null) ||
* (first.prev == null && first.item != null)
*/
private transient Node<E> first; // @GuardedBy("lock")
/**
* 最後一個節點
* Invariant: (first == null && last == null) ||
* (last.next == null && last.item != null)
*/
private transient Node<E> last; // @GuardedBy("lock")
/** 當前隊列長度 */
private transient int count; // @GuardedBy("lock")
/** 隊列最大容量 */
private final int capacity;
/** 主鎖 */
private final InterruptibleReentrantLock lock;
/** 隊列是否為空狀態鎖 */
private final Condition notEmpty;
/** 隊列是否滿狀態鎖 */
private final Condition notFull;
隊列核心點為:
1.隊列中所有的移入元素、移出、初始化構造元素都是基於主鎖進行加鎖操作。
2.隊列的offer和pull支援設置超時時間參數,主要是通過兩個狀態Condition來進行協調操作。如在進行offer操作的時候,如果操作不成功,則基於notFull狀態對象進行等待。
public boolean offerFirst(final E e, final long timeout, final TimeUnit unit)
throws InterruptedException {
Objects.requireNonNull(e, "e");
long nanos = unit.toNanos(timeout);
lock.lockInterruptibly();
try {
while (!linkFirst(e)) {
if (nanos <= 0) {
return false;
}
nanos = notFull.awaitNanos(nanos);
}
return true;
} finally {
lock.unlock();
}
}
如進行pull操作的時候,如果操作不成功,則對notEmpty進行等待操作。
public E takeFirst() throws InterruptedException {
lock.lock();
try {
E x;
while ( (x = unlinkFirst()) == null) {
notEmpty.await();
}
return x;
} finally {
lock.unlock();
}
}
反之當操作成功的時候,則進行喚醒操作,如下所示:
private boolean linkLast(final E e) {
// assert lock.isHeldByCurrentThread();
if (count >= capacity) {
return false;
}
final Node<E> l = last;
final Node<E> x = new Node<>(e, l, null);
last = x;
if (first == null) {
first = x;
} else {
l.next = x;
}
++count;
notEmpty.signal();
return true;
}
2.3 核心業務流程
2.3.1 池化對象狀態變更
上圖是PooledObject的狀態機圖,藍色表示狀態,紅色表示與ObjectPool相關的方法.PooledObject的狀態為:IDLE、ALLOCATED、RETURNING、ABANDONED、INVALID、EVICTION、EVICTION_RETURN_TO_HEAD
所有狀態是在PooledObjectState類中定義的,其中一些是暫時未使用的,此處不再贅述。
2.3.2 對象池browObject過程
第一步、根據配置確定是否要為標籤刪除調用removeAbandoned方法。
第二步、嘗試獲取或創建一個對象,源碼過程如下:
//1、嘗試從雙端隊列中獲取對象,pollFirst方法是非阻塞方法
p = idleObjects.pollFirst();
if (p == null) {
p = create();
if (p != null) {
create = true;
}
}
if (blockWhenExhausted) {
if (p == null) {
if (borrowMaxWaitMillis < 0) {
//2、沒有設置最大阻塞等待時間,則無限等待
p = idleObjects.takeFirst();
} else {
//3、設置最大等待時間了,則阻塞等待指定的時間
p = idleObjects.pollFirst(borrowMaxWaitMillis,
TimeUnit.MILLISECONDS);
}
}
}
示意圖如下所示:
第三步、調用allocate使狀態更改為ALLOCATED狀態。
第四步、調用工廠的activateObject來初始化對象,如果發生錯誤,請調用destroy方法來銷毀對象,例如源程式碼中的六個步驟。
第五步、調用TestFactory的validateObject進行基於TestOnBorrow配置的對象可用性分析,如果不可用,則調用destroy方法銷毀對象。3-7步驟的源碼過程如下所示:
//修改對象狀態
if (!p.allocate()) {
p = null;
}
if (p != null) {
try {
//初始化對象
factory.activateObject(p);
} catch (final Exception e) {
try {
destroy(p, DestroyMode.NORMAL);
} catch (final Exception e1) {
}
}
if (p != null && getTestOnBorrow()) {
boolean validate = false;
Throwable validationThrowable = null;
try {
//驗證對象的可用性狀態
validate = factory.validateObject(p);
} catch (final Throwable t) {
PoolUtils.checkRethrow(t);
validationThrowable = t;
}
//對象不可用,驗證失敗,則進行destroy
if (!validate) {
try {
destroy(p, DestroyMode.NORMAL);
destroyedByBorrowValidationCount.incrementAndGet();
} catch (final Exception e) {
// Ignore - validation failure is more important
}
}
}
}
2.3.3 對象池returnObject的過程執行邏輯
第一步、調用markReturningState方法將狀態更改為RETURNING。
第二步、基於testOnReturn配置調用PooledObjectFactory的validateObject方法以進行可用性檢查。如果檢查失敗,則調用destroy消耗該對象,然後確保調用idle以確保池中有IDLE狀態對象可用,如果沒有,則調用create方法創建一個新對象。
第三步、調用PooledObjectFactory的passivateObject方法進行反初始化操作。
第四步、調用deallocate將狀態更改為IDLE。
第五步、檢測是否已超過最大空閑對象數,如果超過,則銷毀當前對象。
第六步、根據LIFO(後進先出)配置將對象放置在隊列的開頭或結尾。
2.4 拓展和思考
2.4.1 關於LinkedBlockingDeque的另種實現
上文中分析到commons-pool2中使用了雙端隊列以及java中的condition來實現隊列中對象的管理和不同執行緒對對象獲取和釋放對象操作之間的協調,那是否有其他方案可以實現類似效果呢?答案是肯定的。
使用雙端隊列進行操作,其實是想將空閑對象和活躍對象進行隔離,本質上將我們用兩個隊列來分別存儲空閑隊列和當前活躍對象,然後再統一使用一個對象鎖,也是可以達成相同的目標的,大概的思路如下:
1、雙端隊列改為兩個單向隊列分別用於存儲空閑的和活躍的對象,隊列之間的同步和協調可以通過對象鎖的wait和notify完成。
public class PoolState {
protected final List<PooledObject> idleObjects = new ArrayList<>();
protected final List<PooledObject> activeObjects = new ArrayList<>();
//...
}
2、在獲取對象時候,原本對雙端隊列的LIFO或者FIFO變成了從空閑隊列idleObjects中獲取對象,然後在獲取成功並對象狀態合法後,將對象添加到活躍對象集合activeObjects 中,如果獲取對象需要等待,則PoolState對象鎖應該通過wait操作,進入等待狀態。
3、在釋放對象的時候,則首先從活躍對象集合activeObjects 刪除元素,刪除完成後,將對象增加到空閑對象集合idleObjects中,需要注意的是,在釋放對象過程中也需要去校驗對象的狀態。當對象狀態不合法的時候,對象應該進行銷毀,不應該添加到idleObjects中。釋放成功後則PoolState通過notify或者notifyAll喚醒等待中的獲取操作。
4、為保障對活躍隊列和空閑隊列的操作執行緒安全性,獲取對象和釋放對象需要進行加鎖操作,和commons2-pool中的一致。
2.4.2 對象池的自我保護機制
我們在使用commons-pool2中獲取對象的時候,會從雙端隊列中阻塞等待獲取元素(或者是創建新對象),但是如果是應用程式的異常,一直未調用returnObject或者invalidObject的時候,那可能就會出現對象池中的對象一直上升,到達設置的上線之後再去調用borrowObject的時候就會出現一直等待或者是等待超時而無法獲取對象的情況。
commons-pool2為了避免上述分析的問題的出現,提供了兩種自我保護機制:
2.4.2.1 基於閾值的檢測
從對象池中獲取對象的時候會校驗當前對象池的活躍對象和空閑對象的數量佔比,當空閑獨享非常少,活躍對象非常多的時候,會觸發空閑對象的回收,具體校驗規則為:如果當前對象池中少於2個idle狀態的對象或者 active數量>最大對象數-3 的時候,在borrow對象的時候啟動泄漏清理。通過AbandonedConfig.setRemoveAbandonedOnBorrow 為 true 進行開啟。
//根據配置確定是否要為標籤刪除調用removeAbandoned方法
final AbandonedConfig ac = this.abandonedConfig;
if (ac != null && ac.getRemoveAbandonedOnBorrow() && (getNumIdle() < 2) && (getNumActive() > getMaxTotal() - 3) ) {
removeAbandoned(ac);
}
2.4.2.2 非同步調度執行緒檢測
AbandonedConfig.setRemoveAbandonedOnMaintenance 設置為 true 以後,在維護任務運行的時候會進行泄漏對象的清理,通過設置setTimeBetweenEvictionRunsMillis 來設置維護任務執行的時間間隔。
檢測和回收實現邏輯分析:
在構造方法內部邏輯的最後調用了startEvictor方法。這個方法的作用是在構造完對象池後,啟動回收器來監控回收空閑對象。startEvictor定義在GenericObjectPool的父類BaseGenericObjectPool(抽象)類中,我們先看一下這個方法的源碼。
在構造器中會執行如下的設置參數;
public final void setTimeBetweenEvictionRunsMillis(
final long timeBetweenEvictionRunsMillis) {
this.timeBetweenEvictionRunsMillis = timeBetweenEvictionRunsMillis;
startEvictor(timeBetweenEvictionRunsMillis);
}
當且僅當設置了timeBetweenEvictionRunsMillis參數後才會開啟定時清理任務。
final void startEvictor(final long delay) {
synchronized (evictionLock) {
EvictionTimer.cancel(evictor, evictorShutdownTimeoutMillis, TimeUnit.MILLISECONDS);
evictor = null;
evictionIterator = null;
//如果delay<=0則不會開啟定時清理任務
if (delay > 0) {
evictor = new Evictor();
EvictionTimer.schedule(evictor, delay, delay);
}
}
}
繼續跟進程式碼可以發現,調度器中設置的清理方法的實現邏輯實際在對象池中定義的,也就是由GenericObjectPool或者GenericKeyedObjectPool來實現,接下來我們繼續探究對象池是如何進行對象回收的。
a)、核心參數:
minEvictableIdleTimeMillis:指定空閑對象最大保留時間,超過此時間的會被回收。不配置則不過期回收。
softMinEvictableIdleTimeMillis:一個毫秒數值,用來指定在空閑對象數量超過minIdle設置,且某個空閑對象超過這個空閑時間的才可以會被回收。
minIdle:對象池裡要保留的最小空間對象數量。
b)、回收邏輯
以及一個對象回收策略介面EvictionPolicy,可以預料到對象池的回收會和上述的參數項及介面EvictionPolicy發生關聯,繼續跟進程式碼會發現如下的內容,可以看到在判斷對象池可以進行回收的時候,直接調用了destroy進行回收。
boolean evict;
try {
evict = evictionPolicy.evict(evictionConfig, underTest,
idleObjects.size());
} catch (final Throwable t) {
// Slightly convoluted as SwallowedExceptionListener
// uses Exception rather than Throwable
PoolUtils.checkRethrow(t);
swallowException(new Exception(t));
// Don't evict on error conditions
evict = false;
}
if (evict) {
// 如果可以被回收則直接調用destroy進行回收
destroy(underTest);
destroyedByEvictorCount.incrementAndGet();
}
為提升回收的效率,在回收策略判斷對象的狀態不是evict的時候,也會進行進一步的狀態判斷和處理,具體邏輯如下:
1.嘗試激活對象,如果激活失敗則認為對象已經不再存活,直接調用destroy進行銷毀。
2.在激活對象成功的情況下,會通過validateObject方法取校驗對象狀態,如果校驗失敗,則說明對象不可用,需要進行銷毀。
boolean active = false;
try {
// 調用activateObject激活該空閑對象,本質上不是為了激活,
// 而是通過這個方法可以判定是否還存活,這一步裡面可能會有一些資源的開闢行為。
factory.activateObject(underTest);
active = true;
} catch (final Exception e) {
// 如果激活的時候,發生了異常,就說明該空閑對象已經失聯了。
// 調用destroy方法銷毀underTest
destroy(underTest);
destroyedByEvictorCount.incrementAndGet();
}
if (active) {
// 再通過進行validateObject校驗有效性
if (!factory.validateObject(underTest)) {
// 如果校驗失敗,說明對象已經不可用了
destroy(underTest);
destroyedByEvictorCount.incrementAndGet();
} else {
try {
/*
*因為校驗還激活了空閑對象,分配了額外的資源,那麼就通過passivateObject把在activateObject中開闢的資源釋放掉。
*/
factory.passivateObject(underTest);
} catch (final Exception e) {
// 如果passivateObject失敗,也可以說明underTest這個空閑對象不可用了
destroy(underTest);
destroyedByEvictorCount.incrementAndGet();
}
}
}
三、寫在最後
連接池能夠給程式開發者帶來一些便利性,前言中我們分析了使用池化技術的好處和必要性,但是我們也可以看到commons-pool2框架在對象的創建和獲取上都進行了加鎖的操作,這會在並發場景下一定程度的影響應用程式的性能,其次池化對象的對象池中對象的數量也是需要進行合理的設置,否則也很難起到真正的使用對象池的目的,這給我們也帶來了一定的挑戰。
作者:vivo 互聯網伺服器團隊-Huang Xiaoqun