SpringBoot 2.0 中 HikariCP 数据库链接池原理解析

做为后台服务开发,在平常工做中咱们每天都在跟数据库打交道,一直在进行各类CRUD操做,都会使用到数据库链接池。按照发展历程,业界知名的数据库链接池有如下几种:c3p0、DBCP、Tomcat JDBC Connection Pool、Druid 等,不过最近最火的是 HiKariCP。git

HiKariCP 号称是业界跑得最快的数据库链接池,自从 SpringBoot 2.0 将其做为默认数据库链接池后,其发展势头锐不可当。那它为何那么快呢?今天我们就重点聊聊其中的缘由。github

1、什么是数据库链接池

在讲解HiKariCP以前,咱们先简单介绍下什么是数据库链接池(Database Connection Pooling),以及为何要有数据库链接池。sql

从根本上而言,数据库链接池和咱们经常使用的线程池同样,都属于池化资源,它在程序初始化时建立必定数量的数据库链接对象并将其保存在一块内存区中。它容许应用程序重复使用一个现有的数据库链接,当须要执行 SQL 时,咱们是直接从链接池中获取一个链接,而不是从新创建一个数据库链接,当 SQL 执行完,也并非将数据库链接真的关掉,而是将其归还到数据库链接池中。咱们能够经过配置链接池的参数来控制链接池中的初始链接数、最小链接、最大链接、最大空闲时间等参数,来保证访问数据库的数量在必定可控制的范围类,防止系统崩溃,同时保证用户良好的体验。数据库链接池示意图以下所示:数据库

SpringBoot 2.0 中 HikariCP 数据库链接池原理解析

所以使用数据库链接池的核心做用,就是避免数据库链接频繁建立和销毁,节省系统开销。由于数据库链接是有限且代价昂贵,建立和释放数据库链接都很是耗时,频繁地进行这样的操做将占用大量的性能开销,进而致使网站的响应速度降低,甚至引发服务器崩溃。数组

2、常见数据库链接池对比分析

这里详细总结了常见数据库链接池的各项功能比较,咱们重点分析下当前主流的阿里巴巴Druid与HikariCP,HikariCP在性能上是彻底优于Druid链接池的。而Druid的性能稍微差点是因为锁机制的不一样,而且Druid提供更丰富的功能,包括监控、sql拦截与解析等功能,二者的侧重点不同,HikariCP追求极致的高性能。缓存

SpringBoot 2.0 中 HikariCP 数据库链接池原理解析

下面是官网提供的性能对比图,在性能上面这五种数据库链接池的排序以下:HikariCP>druid>tomcat-jdbc>dbcp>c3p0:tomcat

SpringBoot 2.0 中 HikariCP 数据库链接池原理解析

3、HikariCP 数据库链接池简介

HikariCP 号称是史上性能最好的数据库链接池,SpringBoot 2.0将它设置为默认的数据源链接池。Hikari相比起其它链接池的性能高了很是多,那么,这是怎么作到的呢?经过查看HikariCP官网介绍,对于HikariCP所作优化总结以下:安全

1. 字节码精简 :优化代码,编译后的字节码量极少,使得CPU缓存能够加载更多的程序代码;服务器

HikariCP在优化并精简字节码上也下了功夫,使用第三方的Java字节码修改类库Javassist来生成委托实现动态代理.动态代理的实如今ProxyFactory类,速度更快,相比于JDK Proxy生成的字节码更少,精简了不少没必要要的字节码。数据结构

2. 优化代理和拦截器:减小代码,例如HikariCP的Statement proxy只有100行代码,只有BoneCP的十分之一;

3. 自定义数组类型(FastStatementList)代替ArrayList:避免ArrayList每次get()都要进行range check,避免调用remove()时的从头至尾的扫描(因为链接的特色是后获取链接的先释放);

4. 自定义集合类型(ConcurrentBag):提升并发读写的效率;

5. 其余针对BoneCP缺陷的优化,好比对于耗时超过一个CPU时间片的方法调用的研究。

固然做为一个数据库链接池,不能说快就会被消费者所推崇,它还具备很是好的健壮性及稳定性。HikariCP从15年推出以来,已经经受了广大应用市场的考验,而且成功地被SpringBoot2.0做为默认数据库链接池进行推广,在可靠性上面是值得信任的。其次借助于其代码量少,占用cpu和内存量小的优势,使得它的执行率很是高。最后,Spring配置HikariCP和druid基本没什么区别,迁移过来很是方便,这些都是为何HikariCP目前如此受欢迎的缘由。

字节码精简、优化代理和拦截器、自定义数组类型。

4、HikariCP 核心源码解析

4.1 FastList 是如何优化性能问题的

 首先咱们来看一下执行数据库操做规范化的操做步骤:

  1. 经过数据源获取一个数据库链接;

  2. 建立 Statement;

  3. 执行 SQL;

  4. 经过 ResultSet 获取 SQL 执行结果;

  5. 释放 ResultSet;

  6. 释放 Statement;

  7. 释放数据库链接。

当前全部数据库链接池都是严格地根据这个顺序来进行数据库操做的,为了防止最后的释放操做,各种数据库链接池都会把建立的 Statement 保存在数组 ArrayList 里,来保证当关闭链接的时候,能够依次将数组中的全部 Statement 关闭。HiKariCP 在处理这一步骤中,认为 ArrayList 的某些方法操做存在优化空间,所以对List接口的精简实现,针对List接口中核心的几个方法进行优化,其余部分与ArrayList基本一致 。

首先是get()方法,ArrayList每次调用get()方法时都会进行rangeCheck检查索引是否越界,FastList的实现中去除了这一检查,是由于数据库链接池知足索引的合法性,能保证不会越界,此时rangeCheck就属于无效的计算开销,因此不用每次都进行越界检查。省去频繁的无效操做,能够明显地减小性能消耗。

  • FastList get()操做
public T get(int index)
{
   // ArrayList 在此多了范围检测 rangeCheck(index);
   return elementData[index];
}

其次是remove方法,当经过 conn.createStatement() 建立一个 Statement 时,须要调用 ArrayList 的 add() 方法加入到 ArrayList 中,这个是没有问题的;可是当经过 stmt.close() 关闭 Statement 的时候,须要调用 ArrayList 的 remove() 方法来将其从 ArrayList 中删除,而ArrayList的remove(Object)方法是从头开始遍历数组,而FastList是从数组的尾部开始遍历,所以更为高效。假设一个 Connection 依次建立 6 个 Statement,分别是 S一、S二、S三、S四、S五、S6,而关闭 Statement 的顺序通常都是逆序的,从S6 到 S1,而 ArrayList 的 remove(Object o) 方法是顺序遍历查找,逆序删除而顺序查找,这样的查找效率就太慢了。所以FastList对其进行优化,改为了逆序查找。以下代码为FastList 实现的数据移除操做,相比于ArrayList的 remove()代码, FastList 去除了检查范围 和 从头至尾遍历检查元素的步骤,其性能更快。

SpringBoot 2.0 中 HikariCP 数据库链接池原理解析

  • FastList 删除操做
public boolean remove(Object element)
{
   // 删除操做使用逆序查找
   for (int index = size - 1; index >= 0; index--) {
      if (element == elementData[index]) {
         final int numMoved = size - index - 1;
         // 若是角标不是最后一个,复制一个新的数组结构
         if (numMoved > 0) {
            System.arraycopy(elementData, index + 1, elementData, index, numMoved);
         }
         //若是角标是最后面的 直接初始化为null
         elementData[--size] = null;
         return true;
      }
   }
   return false;
}

经过上述源码分析,FastList 的优化点仍是很简单的。相比ArrayList仅仅是去掉了rage检查,扩容优化等细节处,删除时数组从后往前遍历查找元素等微小的调整,从而追求性能极致。固然FastList 对于 ArrayList 的优化,咱们不能说ArrayList很差。所谓定位不一样、追求不一样,ArrayList做为通用容器,更追求安全、稳定,操做前rangeCheck检查,对非法请求直接抛出异常,更符合 fail-fast(快速失败)机制,而FastList追求的是性能极致。

下面咱们再来聊聊 HiKariCP 中的另一个数据结构 ConcurrentBag,看看它又是如何提高性能的。

4.2 ConcurrentBag 实现原理分析

当前主流数据库链接池实现方式,大都用两个阻塞队列来实现。一个用于保存空闲数据库链接的队列 idle,另外一个用于保存忙碌数据库链接的队列 busy;获取链接时将空闲的数据库链接从 idle 队列移动到 busy 队列,而关闭链接时将数据库链接从 busy 移动到 idle。这种方案将并发问题委托给了阻塞队列,实现简单,可是性能并非很理想。由于 Java SDK 中的阻塞队列是用锁实现的,而高并发场景下锁的争用对性能影响很大。

HiKariCP 并无使用 Java SDK 中的阻塞队列,而是本身实现了一个叫作 ConcurrentBag 的并发容器,在链接池(多线程数据交互)的实现上具备比LinkedBlockingQueue和LinkedTransferQueue更优越的性能。

ConcurrentBag 中最关键的属性有 4 个,分别是:用于存储全部的数据库链接的共享队列 sharedList、线程本地存储 threadList、等待数据库链接的线程数 waiters 以及分配数据库链接的工具 handoffQueue。其中,handoffQueue 用的是 Java SDK 提供的 SynchronousQueue,SynchronousQueue 主要用于线程之间传递数据。

  • ConcurrentBag 中的关键属性
// 存放共享元素,用于存储全部的数据库链接
private final CopyOnWriteArrayList<T> sharedList;
// 在 ThreadLocal 缓存线程本地的数据库链接,避免线程争用
private final ThreadLocal<List<Object>> threadList;
// 等待数据库链接的线程数
private final AtomicInteger waiters;
// 接力队列,用来分配数据库链接
private final SynchronousQueue<T> handoffQueue;

ConcurrentBag 保证了所有的资源均只能经过 add() 方法进行添加,当线程池建立了一个数据库链接时,经过调用 ConcurrentBag 的 add() 方法加入到 ConcurrentBag 中,并经过 remove() 方法进行移出。下面是 add() 方法和 remove() 方法的具体实现,添加时实现了将这个链接加入到共享队列 sharedList 中,若是此时有线程在等待数据库链接,那么就经过 handoffQueue 将这个链接分配给等待的线程。

  • ConcurrentBag 的 add() 与 remove() 方法
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
   sharedList.add(bagEntry);

   // 当有等待资源的线程时,将资源交到等待线程 handoffQueue 后才返回
   while (waiters.get() > 0 && bagEntry.getState() == STATE_NOT_IN_USE && !handoffQueue.offer(bagEntry)) {
      yield();
   }
}
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;
   }
   // 从sharedList中移出
   final boolean removed = sharedList.remove(bagEntry);
   if (!removed && !closed) {
      LOGGER.warn("Attempt to remove an object from the bag that does not exist: {}", bagEntry);
   }
   return removed;
}

同时ConcurrentBag经过提供的 borrow() 方法来获取一个空闲的数据库链接,并经过requite()方法进行资源回收,borrow() 的主要逻辑是:

  1. 查看线程本地存储 threadList 中是否有空闲链接,若是有,则返回一个空闲的链接;
  2. 若是线程本地存储中无空闲链接,则从共享队列 sharedList 中获取;
  3. 若是共享队列中也没有空闲的链接,则请求线程须要等待。
  • ConcurrentBag 的 borrow() 与 requite() 方法
// 该方法会从链接池中获取链接, 若是没有链接可用, 会一直等待timeout超时
public T borrow(long timeout, final TimeUnit timeUnit) throws InterruptedException
{
   // 首先查看线程本地资源threadList是否有空闲链接
   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;
      // 线程本地存储中的链接也能够被窃取, 因此须要用CAS方法防止重复分配
      if (bagEntry != null && bagEntry.compareAndSet(STATE_NOT_IN_USE, STATE_IN_USE)) {
         return bagEntry;
      }
   }
   // 当无可用本地化资源时,遍历所有资源,查看可用资源,并用CAS方法防止资源被重复分配
   final int waiting = waiters.incrementAndGet();
   try {
      for (T bagEntry : sharedList) {
         if (bagEntry.compareAndSet(STATE_NOT_IN_USE, STATE_IN_USE)) {
            // 由于可能“抢走”了其余线程的资源,所以提醒包裹进行资源添加
            if (waiting > 1) {
               listener.addBagItem(waiting - 1);
            }
            return bagEntry;
         }
      }

      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();
   }
}

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 {
         yield();
      }
   }
   // 不然,进行资源本地化处理
   final List<Object> threadLocalList = threadList.get();
   if (threadLocalList.size() < 50) {
      threadLocalList.add(weakThreadLocals ? new WeakReference<>(bagEntry) : bagEntry);
   }
}

borrow() 方法能够说是整个 HikariCP 中最核心的方法,它是咱们从链接池中获取链接的时候最终会调用到的方法。须要注意的是 borrow() 方法只提供对象引用,不移除对象,所以使用时必须经过 requite() 方法进行放回,不然容易致使内存泄露。requite() 方法首先将数据库链接状态改成未使用,以后查看是否存在等待线程,若是有则分配给等待线程;不然将该数据库链接保存到线程本地存储里。

ConcurrentBag 实现采用了queue-stealing的机制获取元素:首先尝试从ThreadLocal中获取属于当前线程的元素来避免锁竞争,若是没有可用元素则再次从共享的CopyOnWriteArrayList中获取。此外,ThreadLocal和CopyOnWriteArrayList在ConcurrentBag中都是成员变量,线程间不共享,避免了伪共享(false sharing)的发生。同时由于线程本地存储中的链接是能够被其余线程窃取的,在共享队列中获取空闲链接,因此须要用 CAS 方法防止重复分配。 

5、总结

Hikari 做为 SpringBoot2.0默认的链接池,目前在行业内使用范围很是广,对于大部分业务来讲,均可以实现快速接入使用,作到高效链接。

参考资料

  1. https://github.com/brettwooldridge/HikariCP

  2. https://github.com/alibaba/druid

做者:vivo 游戏技术团队