HikariCP 源码详解

doMore 693 2023-07-24

简介

HikariCP 是什么

HikariCP是一个“零开销”的 JDBC 连接池。快速、简单、可靠 是它的特性

为什么使用

  1. HikariCP 是目前最快的连接池。
  2. SpringBoot 也把它设置为默认连接池。
  3. HikariCP 非常轻量。笔者项目用到的 4.0.3 版本的 jar 包仅仅只有 156 KB,它的源码真的非常精炼。

使用

-- todo

源码分析

思考

  1. 为什么它这么快

数据库连接池已经发展了很久了,也算是比较成熟的技术,使用比较广泛的类库有 DBCP、C3P0、Druid 等等。眼看着数据库连接池已经发展到了瓶颈,所谓的性能提升也仅仅是一些代码细节的优化,HikariCP 出现后并快速地火了起来,与其他连接池相比,它的快不是普通的快,而是跨越性的快。

首先是模型上的变化,要知道对连接池的操作不外乎四个:borrow、requite、add、remove。
之前的数据库都是 “实做模型”,从抽象层面讲,它非常符合我们的现实生活,例如,某人借走我的钱,钱就不在我的钱包里了。我们熟知的 DBCP、C3P0、Druid 等等都是这样。
但是 HikariCP 就不一样了,在“实做模型”中,borrow、return、add、remove 四个动作都需要加同一把锁,即同一时刻只允许一个线程操作池,并发高时线程切换将非常频繁。因为多个线程操作同一个池塘,连接出入池需要加锁来保证线程安全。

HikariCP 是这样做的,borrow 的连接不会从池塘里取出,而是打上“已借出”的标记,return 的时候,再把这个连接的“已借出”标记去掉。可以把这种做法称为“标记模型”。“标记模型”可以实现 borrow 和 return 动作不加锁。具体怎么做到的呢?

borrow (借出)

先看 borrow 时的操作:

  1. 先看 ThreaLocal 中是否已经获取过,如果有直接返回(已经获取过连接的线程,归还的时候 会放入 本地缓存(ThreadLocal))
  2. 看池塘里哪一个连接可以借出。这里就涉及到读连接池的操作,因为池塘里的连接数量不是一成不变的,为了一致性,就必须加锁。但是,HikariCP 没有加,为什么呢?因为 HikariCP 容忍了读的不一致。borrow 的时候,我际上读的不是真正的池塘,而是当前池塘的一份快照。 HikariCP 存放连接的地方,是一个 CopyOnWriteArrayList 对象,我们知道,CopyOnWriteArrayList 是一个写时复制(写安全、读不安全)集合。
  3. 如何判断是否可以借出,利用 CAS ,如果可以从 未使用 状态标记为 使用中 ,则表示可以借出。
  4. 如果执行前三步还是没有获取到连接,则会 自旋 一段时间,看是否能够获取到新的连接(如果等待数量过多会判断是否需要创建新的,并放入 handoffQueue )。
// 连接池中保存 连接 的集合
private final CopyOnWriteArrayList<T> sharedList;

public T borrow(long timeout, final TimeUnit timeUnit) throws InterruptedException
   {
      // Try the thread-local list first
      final var list = threadList.get();
      for (int i = list.size() - 1; i >= 0; i--) {
         final var 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;
         }
      }

      // Otherwise, scan the shared list ... then poll the handoff queue
      final int waiting = waiters.incrementAndGet();
      try {
         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;
            }
         }

         listener.addBagItem(waiting);

         timeout = timeUnit.toNanos(timeout);
         do {
            final var 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();
      }
   }


requite (归还)

  1. 首先将 连接设置为 未使用
  2. 看目前等待获取连接的数量有多少,如果大于0,则放入 handoffQueue 供别的使用
  3. 如果小于 0 则放入本地缓存
 public void requite(final T bagEntry)
   {
      bagEntry.setState(STATE_NOT_IN_USE);

      for (var 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 var threadLocalList = threadList.get();
      if (threadLocalList.size() < 50) {
         threadLocalList.add(weakThreadLocals ? new WeakReference<>(bagEntry) : bagEntry);
      }
   }

add (新增)

池中不存在的

  1. 首先看连接池是不是被关闭
  2. 连接池处于正常状态,将新的连接放入池子
   public void add(final T bagEntry)
   {
      if (closed) {
         LOGGER.info("ConcurrentBag has been closed, ignoring add()");
         throw new IllegalStateException("ConcurrentBag has been closed, ignoring add()");
      }

      sharedList.add(bagEntry);

      // spin until a thread takes it or none are waiting
      while (waiters.get() > 0 && bagEntry.getState() == STATE_NOT_IN_USE && !handoffQueue.offer(bagEntry)) {
         Thread.yield();
      }
   }

remove (移除)

  1. 查看连接是否处于可被移除的状态
  2. 从池子和本地缓存中移除
   public boolean remove(final T bagEntry)
   {
      if (!bagEntry.compareAndSet(STATE_IN_USE, STATE_REMOVED) && !bagEntry.compareAndSet(STATE_RESERVED, STATE_REMOVED) && !closed) {
         LOGGER.warn("Attempt to remove an object from the bag that was not borrowed or reserved: {}", bagEntry);
         return false;
      }

      final boolean removed = sharedList.remove(bagEntry);
      if (!removed && !closed) {
         LOGGER.warn("Attempt to remove an object from the bag that does not exist: {}", bagEntry);
      }

      threadList.get().remove(bagEntry);

      return removed;
   }

核心类介绍

ConcurrentBag

位置: com.zaxxer.hikari.util.ConcurrentBag

ConcurrentBag 可以算是 HikariCP 最核心的一个类,它是 HikariCP 底层真正的连接池,上述思考中的操作 就是靠它来实现的。

属性描述
CopyOnWriteArrayList sharedList存放着状态为使用中、未使用和保留三种状态的资源对象
ThreadLocal threadList存放着当前线程归还的资源对象
SynchronousQueue handoffQueue这是一个无容量的阻塞队列,出队和入队都可以选择是否阻塞
AtomicInteger waiters当前等待获取元素的线程数

HikariPool

位置:com.zaxxer.hikari.pool.HikariPool

HikariPool 是用来管理连接池的。

属性类型和属性名说明
DataSource dataSource用于获取原生连接对象的数据源。一般我们不指定的话,使用的是DriverDataSource
ThreadPoolExecutor addConnectionExecutor执行创建连接任务的线程池。只开启一个线程执行任务。
ThreadPoolExecutor closeConnectionExecutor执行关闭原生连接任务的线程池。只开启一个线程执行任务。
ScheduledExecutorService houseKeepingExecutorService用于执行检查 idleTimeout、leakDetectionThreshold、keepaliveTime、maxLifetime 等任务的线程池。
ProxyLeakTaskFactory leakTaskFactory用于检测连接是否泄露(即:不受连接池管理的连接)。默认是空实现,可设置一个 阈值(毫秒时间)。会打印一个警告信息
ScheduledFuture<?> houseKeeperTask用于保持最小连接数,以及清除一些不活跃的连接

注意:HikariPool 还会开启一些 监控任务

  1. 心跳机制
  2. 指标监控

HikariDataSource

位置:com.zaxxer.hikari.HikariDataSource

HikariDataSource 实现 javax.sql.DataSource 。配置装配,供用户获取连接等。

   private final HikariPool fastPathPool;
   private volatile HikariPool pool;

HikariDataSource 持有两个 HikariPool 。为什么要这样做呢?

首先,从性能方面考虑,使用 fastPathPool 来创建连接会比 pool 更好一些,因为 pool 被 volatile 修饰了,为了保证可见性不能使用缓存。那为什么还要用到 pool 呢?

排查了一下,发现 fastPathPool 其实只有在 getConnection 时才会使用。在获取监控指标等信息的时候还是 pool 。

个人认为有以下原因:

  1. 被 volatile 修饰的字段在访问时会加 读写 屏障。
  2. fastPathPool 被 final 修饰,不会被篡改。
  3. 收集指标的时候 还是使用 Pool 更准确。
  4. 由于延迟初始化检查, getConnection 会降低。

持有 HikariPool 的两种情况。

情况一:fastPathPool = pool = new HikariPool(this)。使用有参构造new HikariDataSource(HikariConfig configuration)来创建HikariDataSource;

情况二:fastPathPool = null;pool = new HikariPool(this)。使用无参构造new HikariDataSource()来创建 HikariDataSource 。

当然,更推荐使用 情况一 ,可以更加定制化配置信息。

 public Connection getConnection() throws SQLException
   {
      if (fastPathPool != null) {
         return fastPathPool.getConnection();
      }

      // 双重检查 pool 的初始化
      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());
            }
         }
      }

      return result.getConnection();
   }

PropertyElf

位置: com.zaxxer.hikari.util.PropertyElf

PropertyElf 主要是 设置 Hikari 的配置,主要方法 setTargetFromProperties

附录

  1. 使用 javassist 动态生成一部分类。

在项目中直接使用 Hikari 会发现它的 jar 包中 HikariProxyCallableStatementHikariProxyConnectionHikariProxyDatabaseMetaData 等等 Hikari 开头的类,但是下载的源码当中却没有。 并且在 com.zaxxer.hikari.pool.ProxyFactory 中提供的方法 全部是直接抛出异常。方法的注释中提示查看 com.zaxxer.hikari.util.JavassistProxyFactory 。

查看 JavassistProxyFactory 后发现 它使用 javassist 将 ProxyFactory 中的方法进行了替换。并且继承了 com.zaxxer.hikari.pool 包下 Proxy* 的一些类,动态的生成 Hikari 开头的类。

利用到了 maven 插件。

<plugin>
    <!-- Generate proxies -->
    <groupId>org.codehaus.mojo</groupId>
    <artifactId>exec-maven-plugin</artifactId>
    <version>1.6.0</version>
    <extensions>true</extensions>
    <executions>
       <execution>
          <phase>compile</phase>
          <!-- phase>generate-test-sources</phase -->
          <goals>
             <goal>exec</goal>
          </goals>
       </execution>
    </executions>
    <configuration>
       <executable>java</executable>
       <arguments>
          <argument>-cp</argument>
          <argument>${project.build.outputDirectory}${path.separator}${maven.compile.classpath}</argument>
          <!-- 会执行 JavassistProxyFactory 类的 main 方法 -->
          <argument>com.zaxxer.hikari.util.JavassistProxyFactory</argument>
          <argument>${project.basedir}${file.separator}</argument>
       </arguments>
    </configuration>
 </plugin>

暂时还不明白这么做的原因是什么,等到以后再补充。

  1. 连接池状态下,一个复杂的 sql 所需的时间短?还是拆分成一个一个简单的 sql 在程序中做逻辑处理 速度快?

个人感觉是 拆分开 会速度快一点,毕竟 连接的开销 在项目启动的时候已经完成,使用的时候多次发送sql 并不会影响速度。反而是一个复杂的sql 可能会导致 mysql 宕机。
此说法缺乏有效数据验证,待验证后补充