幹掉Druid,HakariCP 為什麼這麼快?

2024年2月6日 22点热度 0人点赞

Springboot 2.0將 HikariCP 作為默認數據庫連接池這一事件之後,HikariCP 作為一個後起之秀出現在大眾的視野中。HikariCP 是在日本的程序員開源的,hikari日語意思為“光”,HikariCP 也以速度快的特點受到越來越多人的青睞。

今天就讓我們來探討一下HikariCP為什麼這麼快?

連接池技術

我們平常編碼過程中,經常會碰到線程池啊,數據庫連接池啊等等,那麼這個池到底是一門怎樣的技術呢?

簡單來說,連接池是一個創建和管理連接的緩沖池技術。連接池主要由三部分組成:連接池的建立、連接池中連接的使用管理、連接池的關閉。

連接池技術的核心思想是:連接復用,通過建立一個數據庫連接池以及一套連接使用、分配、管理策略,使得該連接池中的連接可以得到高效、安全的復用。它不僅僅隻限於管理數據庫訪問連接,也可以管理其他連接資源。

HikariCP

HikariCP 項目的 README 中的一段話。

Fast, simple, reliable. HikariCP is a "zero-overhead" production ready JDBC connection pool. At roughly 130Kb, the library is very light.

快速、簡單、可靠。HikariCP是一個“零開銷”的生產就緒JDBC連接池。這個庫大約有130Kb,非常輕。

這個介紹真是簡潔但“全面”。搭配上下邊這張圖。

看到這些數據,再加上Springboot 2.0 將 HikariCP 作為默認數據庫連接池這件事,我已經十分好奇 HikariCP 的實現原理了。

HikariCP為什麼這麼快?

  • 兩個HikariPool:定義了兩個HikariPool對象,一個采用final類型定義,避免在獲取連接時才初始化,提高性能,也避免volatile的額外開銷。
  • FastList替代ArrayList:采用自定義的FastList替代了ArrayList,FastList的get方法去除了范圍檢查邏輯,並且remove方法是從尾部開始掃描的,而並不是從頭部開始掃描的。因為Connection的打開和關閉順序通常是相反的。
  • 更快的並發集合實現:使用自定義ConcurrentBag,性能更優。
  • 更快的獲取連接:同一個線程獲取數據庫連接時從ThreadLocal中獲取,沒有並發操作。
  • 精簡字節碼:HikariCP利用了一個第三方的Java字節碼修改類庫Javassist來生成委托實現動態代理,速度更快,相比於JDK 代理生成的字節碼更少。

HikariCP原理

我們通過分析源碼來看 HikariCP 是如何這麼快的。先來看一下 HikariCP 的簡單使用。

maven依賴:

<dependency>
    <groupId>com.zaxxer</groupId>
    <artifactId>HikariCP</artifactId>
    <version>4.0.3</version>
</dependency>
@Test
public void testHikariCP() throws SQLException {
    // 1、創建Hikari配置
    HikariConfig hikariConfig = new HikariConfig();
    // JDBC連接串
    hikariConfig.setJdbcUrl("jdbc:mysql://127.0.0.1:3306/iam?characterEncoding=utf8");
    // 數據庫用戶名
    hikariConfig.setUsername("root");
    // 數據庫用戶密碼
    hikariConfig.setPassword("123456");
    // 連接池名稱
    hikariConfig.setPoolName("testHikari");
    // 連接池中最小空閑連接數量
    hikariConfig.setMinimumIdle(4);
    // 連接池中最大空閑連接數量
    hikariConfig.setMaximumPoolSize(8);
    // 連接在池中的最大空閑時間
    hikariConfig.setIdleTimeout(600000L);
    // 數據庫連接超時時間
    hikariConfig.setConnectionTimeout(10000L);
    // 2、創建數據源
    HikariDataSource dataSource = new HikariDataSource(hikariConfig);
    // 3、獲取連接
    Connection connection = dataSource.getConnection();
    // 4、獲取Statement
    Statement statement = connection.createStatement();
    // 5、執行Sql
    ResultSet resultSet = statement.executeQuery("SELECT COUNT(*) AS countNum tt_user");
    // 6、輸出執行結果
    if (resultSet.next()) {
        System.out.println("countNum結果為:"   resultSet.getInt("countNum"));
    }
    // 7、釋放鏈接
    resultSet.close();
    statement.close();
    connection.close();
    dataSource.close();
}

HikariConfig:可以設置一些數據庫基本配置信息和一些連接池的配置信息。

HikariDataSource:實現了 DataSource,DataSource是一個數據源標準或者說規范,Java所有連接池需要基於這個規范進行實現。

我們就從 HikariDataSource 開始說起。HikariDataSource有兩個構造方法HikariDataSource()HikariDataSource(HikariConfig configuration)

private final HikariPool fastPathPool;
private volatile HikariPool pool;
public HikariDataSource()
{
   super();
   fastPathPool = null;
}
public HikariDataSource(HikariConfig configuration)
{
   configuration.validate();
   configuration.copyStateTo(this);
   LOGGER.info("{} - Starting...", configuration.getPoolName());
   pool = fastPathPool = new HikariPool(this);
   LOGGER.info("{} - Start completed.", configuration.getPoolName());
   this.seal();
}

HikariPool為什麼要有兩個(fastPathPool和pool)呢?

可以看到無參構造方法fastPathPool是null,有參構造pool = fastPathPool,采用無參構造在getConnection()時候才會初始化(下邊會詳細講解),性能略低,並且pool是volatile關鍵字修飾,會有一些額外開銷。所以建議使用有參構造。這也是HikariPool快的原因之一。

有參構造裡有一行new HikariPool(this),我們來看一下怎麼個事。

代碼太多了,往後隻貼關鍵代碼了。。。

public HikariPool(final HikariConfig config)
{
   super(config);
   // 初始化ConcurrentBag對象
   this.connectionBag = new ConcurrentBag<>(this);
   // 創建SuspendResumeLock對象 
   this.suspendResumeLock = config.isAllowPoolSuspension() ? new SuspendResumeLock() : SuspendResumeLock.FAUX_LOCK;
   // 根據配置的最大連接數,創建鏈表類型阻塞隊列
   LinkedBlockingQueue<Runnable> addConnectionQueue = new LinkedBlockingQueue<>(maxPoolSize);
   this.addConnectionQueueReadOnlyView = unmodifiableCollection(addConnectionQueue);
   // 初始化創建連接線程池
   this.addConnectionExecutor = createThreadPoolExecutor(addConnectionQueue, poolName   " connection adder", threadFactory, new ThreadPoolExecutor.DiscardOldestPolicy());
   // 初始化關閉連接線程池
   this.closeConnectionExecutor = createThreadPoolExecutor(maxPoolSize, poolName   " connection closer", threadFactory, new ThreadPoolExecutor.CallerRunsPolicy());
   // 創建保持連接池連接數量的任務
   this.houseKeeperTask = houseKeepingExecutorService.scheduleWithFixedDelay(new HouseKeeper(), 100L, housekeepingPeriodMs, MILLISECONDS);
   ...
}

HikariPool 是為HikariCP提供基本池行為的主要連接池類。

houseKeepingExecutorService.scheduleWithFixedDelay(new HouseKeeper(), 100L, housekeepingPeriodMs, MILLISECONDS)這行代碼是 創建保持連接池連接數量的任務。該任務會關閉需要被丟棄的連接,保證最小連接數,HouseKeeper類的run()方法中有一行代碼fillPool()會創建連接,我們來看一下。

創建連接

private synchronized void fillPool()
{
    // 計算需要添加的連接數量
    final int connectionsToAdd = Math.min(config.getMaximumPoolSize() - getTotalConnections(), config.getMinimumIdle() - getIdleConnections()) - addConnectionQueue.size();
    for (int i = 0; i < connectionsToAdd; i  ) {
        // 向創建連接線程池中提交創建連接的任務
        addConnectionExecutor.submit((i < connectionsToAdd - 1) ? poolEntryCreator : postFillPoolEntryCreator);
    }
    ...
}

來看一下PoolEntryCreator是如何創建連接的。

@Override
public Boolean call()
{
   // 連接池狀態正常並且需求創建連接時
   while (poolState == POOL_NORMAL && shouldCreateAnotherConnection()) {
      // 創建PoolEntry對象
      final PoolEntry poolEntry = createPoolEntry();
      if (poolEntry != null) {
         // 將PoolEntry對象添加到ConcurrentBag對象中的sharedList中
         connectionBag.add(poolEntry);
         return Boolean.TRUE;
      }
   }
   ...
   return Boolean.FALSE;
}

PoolEntryCreator實現了Callable接口,在call()方法裡可以看到創建連接的過程。來繼續看一下createPoolEntry()方法。

 private PoolEntry createPoolEntry()
{
    // 初始化PoolEntry對象
    final PoolEntry poolEntry = newPoolEntry();
    ...
}

繼續進入newPoolEntry()方法。

PoolEntry newPoolEntry() throws Exception
{
   return new PoolEntry(newConnection(), this, isReadOnly, isAutoCommit);
}

PoolEntry構造時會先創建Connection對象傳入構造函數中。PoolEntry是ConcurrentBag實例中用來跟蹤Connection的。

獲取鏈接

獲取鏈接是通過getConnection()方法獲取的,源碼如下。

public Connection getConnection() throws SQLException
{
   if (isClosed()) {
      throw new SQLException("HikariDataSource "   this   " has been closed.");
   }
   if (fastPathPool != null) {
      return fastPathPool.getConnection();
   }
   HikariPool result = pool;
   if (result == null) {
      synchronized (this) {
         result = pool;
         if (result == null) {
            validate();
            LOGGER.info("{} - Starting...", getPoolName());
            try {
               pool = result = new HikariPool(this);
               this.seal();
            }
            catch (PoolInitializationException pie) {
               if (pie.getCause() instanceof SQLException) {
                  throw (SQLException) pie.getCause();
               }
               else {
                  throw pie;
               }
            }
            LOGGER.info("{} - Start completed.", getPoolName());
         }
      }
   }

會先去fastPathPool獲取連接,如果fastPathPool為null,就會通過pool獲取,如果pool也為null,會通過 雙檢 代碼來初始化線程池。

這個上文提到過為什麼兩個HikariPool,fastPathPool是 final 修飾的,而pool是 volatile 修飾的,這就說明fastPathPool比pool性能更高,所以建議要用有參構造來創建HikariDataSource,才能享受到這點小細節的優化。

繼續進入HikariPool#getConnection(final long hardTimeout),方法中有一行關鍵的代碼PoolEntry poolEntry = connectionBag.borrow(timeout, MILLISECONDS),這行代碼的作用是從ConcurrentBag中借出一個PoolEntry對象。PoolEntry可以看作是對Connection對象的封裝,連接池中存儲的連接其實就是一個個的PoolEntry

這個connectionBag是用來做什麼的呢?

ConcurrentBag

ConcurrentBag 是HikariCP自定義的一個無鎖並發集合類。我們接著來看一下 ConcurrentBag 的成員變量。

private final CopyOnWriteArrayList<T> sharedList;
private final boolean weakThreadLocals;
private final ThreadLocal<List<Object>> threadList;
private final IBagStateListener listener;
private final AtomicInteger waiters;
private volatile boolean closed;
private final SynchronousQueue<T> handoffQueue;

回到borrow()方法,看一下borrow的實現邏輯。

public T borrow(long timeout, final TimeUnit timeUnit) throws InterruptedException
{
   // 從ThreadLocal中獲取當前線程綁定的對象集合,存在則獲取
   final List<Object> list = threadList.get();
   for (int i = list.size() - 1; i >= 0; i--) {
      final Object entry = list.remove(i);
      @SuppressWarnings("unchecked")
      final T bagEntry = weakThreadLocals ? ((WeakReference<T>) entry).get() : (T) entry;
      if (bagEntry != null && bagEntry.compareAndSet(STATE_NOT_IN_USE, STATE_IN_USE)) {
         return bagEntry;
      }
   }
   // 等待對象加一
   final int waiting = waiters.incrementAndGet();
   try {
      // sharedList有未使用的則返回一個
      for (T bagEntry : sharedList) {
         if (bagEntry.compareAndSet(STATE_NOT_IN_USE, STATE_IN_USE)) {
            // If we may have stolen another waiter's connection, request another bag add.
            if (waiting > 1) {
               listener.addBagItem(waiting - 1);
            }
            return bagEntry;
         }
      }
      // sharedList沒有,添加一個監聽任務
      listener.addBagItem(waiting);
      timeout = timeUnit.toNanos(timeout);
      do {
         final long start = currentTime();
         // 阻塞隊列計時獲取
         final T bagEntry = handoffQueue.poll(timeout, NANOSECONDS);
         if (bagEntry == null || bagEntry.compareAndSet(STATE_NOT_IN_USE, STATE_IN_USE)) {
            return bagEntry;
         }
         timeout -= elapsedNanos(start);
      } while (timeout > 10_000);
      return null;
   }
   finally {
      // 等待線程數減一
      waiters.decrementAndGet();
   }
}
  1. 先從ThreadLocal中獲取以前用過的連接。ThreadLocal是當前線程的緩存,加快本地連接獲取速度。
  2. ThreadLocal中未獲取到,會嘗試從sharedList中獲取,sharedList集合存在初始化的PoolEntry。sharedList是CopyOnWriteArrayList類型的,寫時復制,特別適合這種讀多寫少的場景。
  3. sharedList中未獲取到那就到阻塞隊列中等著,看有沒有歸還的連接可以使用。

釋放連接

用完連接後我們要釋放,通過connection.close()釋放連接,釋放連接時HakariCP也做到了一些巧妙的細節。ProxyConnection的close()方法是HakariCP釋放連接的實現邏輯。我們知道連接關閉前必須要關閉Statement,HakariCP對這裡做了優化,來看一下代碼實現。

private final FastList<Statement> openStatements;
private synchronized void closeStatements()
{
   final int size = openStatements.size();
   if (size > 0) {
      for (int i = 0; i < size && delegate != ClosedConnection.CLOSED_CONNECTION; i  ) {
         try (Statement ignored = openStatements.get(i)) {
         }
         catch (SQLException e) {
            LOGGER.warn("{} - Connection {} marked as broken because of an exception closing open statements during Connection.close()",
                        poolEntry.getPoolName(), delegate);
            leakTask.cancel();
            poolEntry.evict("(exception closing Statements during Connection.close())");
            delegate = ClosedConnection.CLOSED_CONNECTION;
         }
      }
      openStatements.clear();
   }
}

存儲Statement對象用的是 FastList,這也是 HakariCP 之所以快的原因之一。

為什麼用FastList而不用ArrayList呢?

  • 去掉索引范圍檢查:查看源碼會發現,FastList的get()方法比ArrayList少了一行代碼rangeCheck(index),這行代碼的作用是范圍檢查,少了這行代碼必然會性能更優。不禁感嘆真的是太細了啊,到處都是細節。
  • 尾部刪除FastList#remove()方法是從尾部開始掃描的,而並不是從頭部開始掃描的。因為Connection的打開和關閉順序通常是相反的。FastList的根據下標刪除方法也去掉索引范圍檢查。

關閉掉Statement之後,我們再回過頭來繼續往下看。

poolEntry.recycle(lastAccess);

recycle方法會將該連接歸還給線程池,recycle方法套了好幾層,最終執行的是ConnectionBag的recycle方法,我們直接進入看一下。

public void requite(final T bagEntry)
{
   bagEntry.setState(STATE_NOT_IN_USE);
   for (int i = 0; waiters.get() > 0; i  ) {
      if (bagEntry.getState() != STATE_NOT_IN_USE || handoffQueue.offer(bagEntry)) {
         return;
      }
      else if ((i & 0xff) == 0xff) {
         parkNanos(MICROSECONDS.toNanos(10));
      }
      else {
         Thread.yield();
      }
   }
   final List<Object> threadLocalList = threadList.get();
   if (threadLocalList.size() < 50) {
      threadLocalList.add(weakThreadLocals ? new WeakReference<>(bagEntry) : bagEntry);
   }
}

首先將狀態設置為未使用,然後判斷當前是否存在等待連接的線程,如果存在則將連接加入到公平隊列中,由隊列中有等待連接的線程則會從阻塞隊列中去獲取使用;如果當前沒有等待連接的線程,並且ThreadLocal中的連接小於50,則將連接添加到本地線程變量ThreadLocal緩存中,當前線程下次獲取連接時直接可從ThreadLocal中獲取到。

總結

這次源碼探究,真的感覺看到了無數個小細節,無數個小優化,積少成多。平時開發過程中,一些小的細節也一定要“扣”。