HikariCP数据库连接池原理解析
文章内容来源:SpringBoot 2.0 中 HikariCP 数据库连接池原理解析
HikariCP数据库连接池原理解析
数据库连接池的核心作用
避免数据库连接频繁创建和销毁,节省系统开销。因为数据库连接是有限且代价昂贵,创建和释放数据库连接都非常耗时,频繁地进行这样的操作将占用大量的性能开销,进而导致网站的响应速度下降,甚至引起服务器崩溃
SpringBoot2将HikariCP设置为默认的数据库连接池
HikariCP连接池简介
- 字节码精简 :优化代码,编译后的字节码量极少,使得CPU缓存可以加载更多的程序代码;
HikariCP在优化并精简字节码上也下了功夫,使用第三方的Java字节码修改类库Javassist来生成委托实现动态代理.动态代理的实现在ProxyFactory类,速度更快,相比于JDK Proxy生成的字节码更少,精简了很多不必要的字节码。
- 优化代理和拦截器:减少代码,例如HikariCP的Statement proxy只有100行代码,只有BoneCP的十分之一;
- 自定义数组类型(FastStatementList)代替ArrayList:避免ArrayList每次get()都要进行range check,避免调用remove()时的从头到尾的扫描(由于连接的特点是后获取连接的先释放);
- 自定义集合类型(ConcurrentBag):提高并发读写的效率;
- 其他针对BoneCP缺陷的优化,比如对于耗时超过一个CPU时间片的方法调用的研究。
当然作为一个数据库连接池,不能说快就会被消费者所推崇,它还具有非常好的健壮性及稳定性。HikariCP从15年推出以来,已经经受了广大应用市场的考验,并且成功地被SpringBoot2.0作为默认数据库连接池进行推广,在可靠性上面是值得信任的。其次借助于其代码量少,占用cpu和内存量小的优点,使得它的执行率非常高
最后,Spring配置HikariCP和druid基本没什么区别,迁移过来非常方便,这些都是为什么HikariCP目前如此受欢迎的原因。
字节码精简、优化代理和拦截器、自定义数组类型
FastList是如何优化性能的?
数据库操作的步骤:
通过数据源获取一个数据库连接;创建 Statement;执行 SQL;通过 ResultSet 获取 SQL 执行结果;释放 ResultSet;释放 Statement;释放数据库连接。
当前所有数据库连接池都是严格地根据这个顺序来进行数据库操作的,为了防止最后的释放操作,各类数据库连接池都会把创建的 Statement 保存在数组 ArrayList 里,来保证当关闭连接的时候,可以依次将数组中的所有 Statement 关闭。HiKariCP 在处理这一步骤中,认为 ArrayList 的某些方法操作存在优化空间,因此对List接口的精简实现,针对List接口中核心的几个方法进行优化,其他部分与ArrayList基本一致
get()方法
public T get(int index)
{// ArrayList 在此多了范围检测 rangeCheck(index);return elementData[index];
}
当前所有数据库连接池都是严格地根据这个顺序来进行数据库操作的,为了防止最后的释放操作,各类数据库连接池都会把创建的 Statement 保存在数组 ArrayList 里,来保证当关闭连接的时候,可以依次将数组中的所有 Statement 关闭。HiKariCP 在处理这一步骤中,认为 ArrayList 的某些方法操作存在优化空间,因此对List接口的精简实现,针对List接口中核心的几个方法进行优化,其他部分与ArrayList基本一致 。
首先是get()方法,ArrayList每次调用get()方法时都会进行rangeCheck检查索引是否越界,FastList的实现中去除了这一检查,是因为数据库连接池满足索引的合法性,能保证不会越界,此时rangeCheck就属于无效的计算开销,所以不用每次都进行越界检查。省去频繁的无效操作,可以明显地减少性能消耗
ArrayList 的检查开销:每次 get()
需执行一次条件判断和可能的异常抛出逻辑,在高并发场景下累积开销显著
可以理解成,我省去检查的开销,宁愿我的性能更快,越界抛出异常就好了,怕啥
同时hikaricp的底层也会对保证索引合法
- 关闭连接时,按列表长度
size
遍历所有 Statement(for (int i = 0; i < size; i++)
),索引必然在合法范围内。 - 池中 Statement 的数量由连接池自身管理,不会出现外部非法访问
remove()方法
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);}//如果角标是最后面的 直接初始化为nullelementData[--size] = null;return true;}}return false;
}
其次是remove方法,当通过 conn.createStatement() 创建一个 Statement 时,需要调用 ArrayList 的 add() 方法加入到 ArrayList 中,这个是没有问题的;
但是当通过 stmt.close() 关闭 Statement 的时候,需要调用 ArrayList 的 remove() 方法来将其从 ArrayList 中删除
ArrayList的remove(Object)方法是从头开始遍历数组
而FastList是从数组的尾部开始遍历,因此更为高效
假设一个 Connection 依次创建 6 个 Statement,分别是 S1、S2、S3、S4、S5、S6,而关闭 Statement 的顺序一般都是逆序的,从S6 到 S1,而 ArrayList 的 remove(Object o) 方法是顺序遍历查找,逆序删除而顺序查找,这样的查找效率就太慢了
因此FastList对其进行优化,改成了逆序查找
如下代码为FastList 实现的数据移除操作,相比于ArrayList的 remove()代码, FastList 去除了检查范围 和 从头到尾遍历检查元素的步骤,其性能更快
连接池是顺序添加元素,逆序删除元素的,如果从前往后遍历的话会耗费一些时间,所以FastList将remove()方法修改成从后往前遍历
通过上述源码分析,FastList 的优化点还是很简单的。相比ArrayList仅仅是去掉了rage检查,扩容优化等细节处,删除时数组从后往前遍历查找元素等微小的调整,从而追求性能极致。当然FastList 对于 ArrayList 的优化,我们不能说ArrayList不好
所谓定位不同、追求不同
ArrayList作为通用容器,更追求安全、稳定,操作前rangeCheck检查,对非法请求直接抛出异常,更符合 fail-fast(快速失败)机制,而FastList追求的是性能极致
ConcurrentBag实现原理
当前主流数据库连接池实现方式,大都用两个阻塞队列来实现
一个用于保存空闲数据库连接的队列 idle
一个用于保存忙碌数据库连接的队列 busy
获取连接时将空闲的数据库连接从 idle 队列移动到 busy 队列,而关闭连接时将数据库连接从 busy 移动到 idle
这种方案将并发问题委托给了阻塞队列,实现简单,但是性能并不是很理想
因为 Java SDK 中的阻塞队列是用锁实现的,而高并发场景下锁的争用对性能影响很大。
HiKariCP 并没有使用 Java SDK 中的阻塞队列,而是自己实现了一个叫做 ConcurrentBag 的并发容器
在连接池(多线程数据交互)的实现上具有比LinkedBlockingQueue和LinkedTransferQueue更优越的性能
ConcurrentBag的关键属性
// 存放共享元素,用于存储所有的数据库连接
private final CopyOnWriteArrayList<T> sharedList;// 在 ThreadLocal 缓存线程本地的数据库连接,避免线程争用
private final ThreadLocal<List<Object>> threadList;// 等待数据库连接的线程数
private final AtomicInteger waiters;// 接力队列,用来分配数据库连接
private final SynchronousQueue<T> handoffQueue;
ConcurrentBag 中最关键的属性有 4 个
分别是:
- 用于存储所有的数据库连接的共享队列 sharedList
- 线程本地存储 threadList
- 等待数据库连接的线程数 waiters
- 分配数据库连接的工具 handoffQueue
其中,handoffQueue 用的是 Java SDK 提供的 SynchronousQueue,SynchronousQueue 主要用于线程之间传递数据
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()");}// 新添加的资源优先放入sharedListsharedList.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 保证了全部的资源均只能通过 add() 方法进行添加,当线程池创建了一个数据库连接时,通过调用 ConcurrentBag 的 add() 方法加入到 ConcurrentBag 中,并通过 remove() 方法进行移出
下面是 add() 方法和 remove() 方法的具体实现,添加时实现了将这个连接加入到共享队列 sharedList 中,如果此时有线程在等待数据库连接,那么就通过 handoffQueue 将这个连接分配给等待的线程
同时ConcurrentBag通过提供的 borrow() 方法来获取一个空闲的数据库连接,并通过requite()方法进行资源回收,borrow() 的主要逻辑是:
- 查看线程本地存储 threadList 中是否有空闲连接,如果有,则返回一个空闲的连接;
- 如果线程本地存储中无空闲连接,则从共享队列 sharedList 中获取;
- 如果共享队列中也没有空闲的连接,则请求线程需要等待
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 方法防止重复分配
总结
本文首先对为什么使用数据库连接池,以及常见的数据库连接池的功能及性能进行了简单介绍
通过分析HiKariCP官网介绍及其源码,可以发现HiKariCP主要通过对字节码进行精简、优化代理和拦截器、自定义数组类型 FastList 及自定义并发集合类型 ConcurrentBag 等内容进行优化
文中重点讲解了FastList 与ConcurrentBag 的优化原理(FastList 适用于逆序删除场景;而 ConcurrentBag 本质上是通过 ThreadLocal 将连接池中的连接按照线程做一次预分配,避免直接竞争共享资源,减少并发CAS带来的CPU CACHE的频繁失效,从而提高性能,非常适合池化资源的分配)
达到显著提升数据库连接池性能的效果
需要注意的是threadLocal可能带来连接池关闭时引用还存在的情况,有可能导致内存泄露
因此一定要使用requite()方法来进行资源回收处理。
Hikari 作为 SpringBoot2.0默认的连接池,目前在行业内使用范围非常广,对于大部分业务来说,都可以实现快速接入使用,做到高效连接
HikariCP简单总结
主要有三点
- FastList代替ArrayList
- 自定义数组类型ConcurrentBag
- 字节码精简,使用第三方的Java字节码修改类库Javassist来生成委托实现动态代理.动态代理的实现在ProxyFactory类,速度更快,相比于JDK Proxy生成的字节码更少,精简了很多不必要的字节码
FastList
FastList的get()方法减少了一个数组越界校验,性能更高
ArrayList的remove(Object)方法是从头开始遍历数组,而FastList是从数组的尾部开始遍历,因此更为高效
ArrayList 的检查开销:每次 get()
需执行一次条件判断和可能的异常抛出逻辑,在高并发场景下累积开销显著
可以理解成,我省去检查的开销,宁愿我的性能更快,越界抛出异常就好了,怕啥
同时hikaricp的底层也会对保证索引合法
- 关闭连接时,按列表长度
size
遍历所有 Statement(for (int i = 0; i < size; i++)
),索引必然在合法范围内。 - 池中 Statement 的数量由连接池自身管理,不会出现外部非法访问
ConcurrentBag
当前主流数据库连接池实现方式,大都用两个阻塞队列来实现
一个用于保存空闲数据库连接的队列 idle
一个用于保存忙碌数据库连接的队列 busy
获取连接时将空闲的数据库连接从 idle 队列移动到 busy 队列,而关闭连接时将数据库连接从 busy 移动到 idle
这种方案将并发问题委托给了阻塞队列,实现简单,但是性能并不是很理想
因为 Java SDK 中的阻塞队列是用锁实现的,而高并发场景下锁的争用对性能影响很大。
HiKariCP 并没有使用 Java SDK 中的阻塞队列,而是自己实现了一个叫做 ConcurrentBag 的并发容器
4个属性
- 用于存储所有的数据库连接的共享队列 sharedList
- 线程本地存储 threadList
- 等待数据库连接的线程数 waiters
- 分配数据库连接的工具 handoffQueue
add()和remove()
ConcurrentBag 保证了全部的资源均只能通过 add() 方法进行添加,当线程池创建了一个数据库连接时,通过调用 ConcurrentBag 的 add() 方法加入到 ConcurrentBag 中,并通过 remove() 方法进行移出
ConcurrentBag通过提供的 borrow() 方法来获取一个空闲的数据库连接,并通过requite()方法进行资源回收
borrow()和requite()是add和remove的底层
borrow()和requite()
borrow() 的执行流程:
- 查看线程本地存储 threadList 中是否有空闲连接,如果有,则返回一个空闲的连接;
- 如果线程本地存储中无空闲连接,则从共享队列 sharedList 中获取;
- 如果共享队列中也没有空闲的连接,则请求线程需要等待
流程细节:
- 引用传递而非移除:borrow () 仅返回连接对象的引用,连接仍保留在池中。若不调用
requite()
放回,会导致连接状态未重置,进而引发内存泄漏(连接无法被其他线程使用)。 - 无锁设计前提:HikariCP 通过
ConcurrentBag
的数据结构设计,避免了传统连接池的锁竞争,使得 borrow () 能高效获取连接 - 状态重置:将连接状态设为
STATE_IDLE
,允许其他线程获取。 - 等待线程处理:若存在等待获取连接的线程,通过
notEmpty.signal()
唤醒等待者,避免线程饥饿。 - 线程本地存储:若无等待线程,将连接存入
threadLocalBag
(ThreadLocal 实现),供当前线程下次快速获取,减少锁竞争
queue-stealing 机制(抢队列,使用CAS机制防止重复分配)
- 步骤 1:本地优先
线程先从threadLocalBag
获取连接(O (1) 时间复杂度,无锁)。 - 步骤 2:共享窃取
若本地列表为空,从sharedList
末尾通过 CAS 获取连接(避免遍历整个列表)
- 无锁化设计:通过 ThreadLocal 和 CAS 替代传统锁,减少线程阻塞。
- 本地优先策略:优先从线程本地列表获取连接,降低跨线程竞争概率。
- queue-stealing 机制:当本地无连接时,从共享列表尾部窃取,减少遍历开销。
- 状态精确管理:
requite()
确保连接状态正确重置,避免内存泄漏和资源浪费