commons-pool2 池化技術探究

vivo網際網路技術發表於2021-04-27

一、前言

我們經常會接觸各種池化的技術或者概念,包括物件池、連線池、執行緒池等,池化技術最大的好處就是實現物件的重複利用,尤其是建立和使用大物件或者寶貴資源(HTTP連線物件,MySQL連線物件)等方面的時候能夠大大節省系統開銷,對提升系統整體效能也至關重要。

在併發請求下,如果需要同時為幾百個query操作建立/關閉MySQL的連線或者是為每一個HTTP請求建立一個處理執行緒或者是為每一個圖片或者XML解析建立一個解析物件而不使用池化技術,將會給系統帶來極大的負載挑戰。

本文主要是分析commons-pool2池化技術的實現方案,希望通過本文能讓讀者對commons-pool2的實現原理一個更全面的瞭解。

二、commons-pool2池化技術剖析

越來越多的框架在選擇使用apache commons-pool2進行池化的管理,如jedis-cluster,commons-pool2工作的邏輯如下圖所示:
image

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 來設定維護任務執行的時間間隔。

非同步檢測執行緒Evictor時序圖
檢測和回收實現邏輯分析:

在構造方法內部邏輯的最後呼叫了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

相關文章