大家好,欢迎来到IT知识分享网。
前言
在我们的工作中,免不了要和数据库打交道,而要想和数据库打好交道,选择一款合适的数据库连接池就至关重要,目前业界广为人知的数据库连接池有 Tomcat JDBC Connection Pool、c3p0、DBCP、BoneCP、Druid 等,而我们这次要介绍的主角是 HiKariCP,HiKariCP 号称业界跑得最快的数据库连接池,近几年发展的风生水起,更是被 Spring Boot 2.0 选中作为其默认数据库连接池,基本上是坐实了江湖一哥的地位,今天咱们就来分析一下为什么它能跑得这么快。
HiKariCP 全称 HiKari Connection Pool,HiKari 源自日语 – 光,你可以这样读 hi-ka-le
有多快
以下数据摘自 HikariCP 官方,可以看到,不管是获取-关闭数据库连接还是执行语句,其速度均远高于其他产品。
什么是数据库连接池
在揭 HiKari 老底之前,我们先简单介绍(回顾)一下什么是数据库连接池。我们都知道在 Java 里面,所有的线程创建和调度都是委托给操作系统的,也就是说 Java 里面的线程是和操作系统的线程一一对应的,这样做的好处是稳定可靠,因为操作系统在这方面非常成熟,但缺点也是显而易见的,创建成本太高了,所以我们想办法弄出了各种各样的线程池,而本质上,数据库连接池和线程池一样,都属于池化资源,作用都是避免重量级资源的频繁创建和销毁,只不过对于数据库连接池来说,这个重量级资源不是线程而是数据库连接。
当我们使用数据库连接池后,在程序运行时连接池会保有一定数量的数据库连接,当需要执行 SQL 时,并不是直接创建一个数据库连接,而是从连接池中获取一个,当 SQL 执行完,再把这个数据库连接归还给连接池。
为什么这么快
为什么 HiKariCP 能跑这么快?实际上 JDBC 连接池的实现并不复杂,主要是对 JDBC 中几个核心对象 Connection、Statement、PreparedStatement、CallableStatement 以及 ResultSet 的封装与动态代理,能够优化的空间不大,HiKariCP 究竟是有什么本领能在极少的代码量(仅两千多行)上做到比其他数据库连接池快那么多呢?
1.优化字节码
HikariCP 对 java.sql.* 提供了五个代理类
- ProxyConnection (proxy class for java.sql.Connection)
- ProxyStatement (proxy class for java.sql.Statement)
- ProxyPreparedStatement (proxy class for java.sql.PreparedStatement)
- ProxyCallableStatement (proxy class for java.sql.CallableStatement)
- ProxyResultSet (proxy class for java.sql.ResultSet)
然后再提供了一个 ProxyFactory 来获得这几个代理类,但当我们查看 ProxyFactory 源码时会发现,其方法体里面都是直接抛异常,而没有具体实现,以 getProxyStatement 举例。
static Statement getProxyStatement(final ProxyConnection connection, final Statement statement) { // Body is replaced (injected) by JavassistProxyFactory // 方法 body 中的代码在编译时调用 JavassistProxyFactory 生成 throw new IllegalStateException("You need to run the CLI build and you need target/classes in your classpath to run."); }
HiKariCP 觉得用 JDK 代理和 CGLIB 代理还是太慢了,所以利用了一个第三方的 Java 字节码修改类库 Javassist 来生成委托实现动态代理,其生成出来的字节码更少更精简,动态代理性能大概是 CGLIB 代理的五倍,JDK 代理(jdk1.8之前)的十倍。具体性能对比可以参考这篇博客动态代理方案性能对比。
2.自定义并发容器 ConcurrentBag
在介绍 ConcurrentBag 容器前,我们先来想一下,如果让来实现一个数据库连接池,我们会采用何种数据结构?比较简单的办法就是使用两个阻塞队列 a 和 b 分别存储空闲的数据库连接和使用中的数据库连接,getConnection 时将数据库连接从 a 队列移至 b 队列,connection.close 时再将数据库连接从 b 移至 a。这种方案实现起来简单,但是性能并不是很理想,因为 Java 里面的阻塞队列是用锁实现的,而高并发场景下锁的争用对性能影响很大。
ConcurrentBag 是 HiKariCP 专门为连接池设计的一个 lock-less 集合,实现了比阻塞队列更好并发性能,它的核心思想是使用 ThreadLocal 来避免一定的并发问题,其主要结构如下。
public class ConcurrentBag<T extends IConcurrentBagEntry> implements AutoCloseable { // 用于存储所有的数据库连接 private final CopyOnWriteArrayList<T> sharedList; // 线程本地存储中的数据库连接 private final ThreadLocal<List<Object>> threadList; // 用于存在资源等待线程时的第一手资源交接 private final SynchronousQueue<T> handoffQueue; }
CopyOnWriteArrayList 是 juc 包里面的一个线程安全的集合,基于 Copy-On-Write 思想,读操作完全无锁,写操作时加锁复制一份数据出来修改,再替换原先的数据,在写加锁期间,并不会影响读操作,只不过读操作读的依旧是老数据。
SynchronousQueue 是一个是一个无存储空间的阻塞队列,非常适合做交换工作,经常用于生产者的线程和消费者的线程同步以传递某些信息、事件或者任务。因为是无存储空间的,所以与其他阻塞队列实现不同的是,这个阻塞 peek 方法直接返回 null,无任何其他操作,其他的方法与阻塞队列一致。这个队列的特点是,必须先调用 take 或者 poll 方法,才能使用 offer 和 put 方法,感兴趣可以去看下源码。
当数据库连接池初始化或扩容的时候,会创建一个新的数据库连接(即 T bagEntry)并调用 ConcurrentBag 的 add 方法将其添加到 sharedList 中,如果这时有线程正在等待获取数据库连接,则通过 handoffQueue 将这个连接分配给等待的线程。
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 // 如果有等待连接的线程,则通过 handoffQueue 直接分配给等待的线程 while (waiters.get() > 0 && bagEntry.getState() == STATE_NOT_IN_USE && !handoffQueue.offer(bagEntry)) { yield(); } }
当我们想从连接池获取一个数据库连接时,HiKari 会调用 ConcurrentBag 的 borrow 方法,borrow 方法的主要逻辑是:
- 首先查看线程本地存储是否有空闲连接,如果有,则返回一个空闲的连接;
- 如果线程本地存储中无空闲连接,则从共享队列中获取。
- 如果共享队列中也没有空闲的连接,则请求线程需要等待。
public T borrow(long timeout, final TimeUnit timeUnit) throws InterruptedException { // Try the thread-local list first // 先查看线程本地存储是否有空闲连接 final List<Object> list = threadList.get(); for (int i = list.size() - 1; i >= 0; i--) { final Object entry = list.remove(i); // weakThreadLocals 是用于判断 ThreadLocal 里面存的是连接的弱引用还是强引用 // 可以通过配置项 com.zaxxer.hikari.useWeakReferences 设置,但官方不推荐覆盖 // 当 ConcurrentBag 的类加载器和系统的类加载器不一样时是 true,默认是 false 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; } } // 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)) { return bagEntry; } } // 共享队列中没有连接,则需要等待 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); // 超时没有获取到连接,返回 null return null; } finally { waiters.decrementAndGet(); } }
当我们执行完 SQL 释放数据库连接时,会调用 ConcurrentBag 的 requite 方法,该方法的逻辑很简单,首先将数据库连接状态更改为 STATENOTIN_USE,之后查看是否存在等待线程,如果有,则分配给等待线程;如果没有,则将该数据库连接保存到线程本地存储里。
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); } }
这里解释一下为什么说 ThreadLocal 存储的数据库连接是可以被其他线程窃取的,因为在调用 borrow 方法时,如果当前线程自己的 ThreadLocal 没有空闲的连接,则会去 sharedList 里面去找空闲的连接,这个连接有可能已经在其他线程的 ThreadLocal 里面,然后在调用 requite 方法时,如果没有等待的线程,当前线程会把这个连接加入到自己的 ThreadLocal 里面,也就是说一个数据库连接可能被多个线程的 ThreadLocal 引用。
3.自定义数组 FastList
FastList 是 HikariCP 自己实现的用来替代 ArrayList 的一个集合,主要用在 ConcurrentBag 的 threadList 上和 Connection 存储 Statement 上,因为他们觉得 ArrayList 性能不够好,没错,就是性能不满意。FastList 主要做了两个地方的优化:
- get(int index) 方法去掉对 index 参数进行越界检查,因为 HikariCP 能保证不越界,只会在 for 循环里面用到
- remove(Object element) 方法由顺序遍历查找改为逆序遍历查找
第一个优化大家应该很容易理解,能少执行一个判断,当调用很频繁时,其效果就很明显了。第二个优化跟数据库连接池的业务有很大关系,当我们执行完 SQL 后,按照规范,需要关闭 Connection 和 Statement,而关闭 Statement 时需要将 Statement 从 Connection 以逆序的方式移除,如果按照 ArrayList 的 remove 方式,将 n 个 Statement 移除总共要 n + n-1 + … + 1 次,而如果改为逆序,则只需要 n 次。
总结
除了以上说的几个主要优化之外,HikariCP 还做了若干细节上的优化,包括优化拦截器、对耗时超过一个 CPU 时间片的方法优化等,当然,写本文的目的也不是为了推广 HikariCP 数据库连接池,而是希望学习其中的思想,毕竟 Druid 虽然性能不如 HikariCP,但自带各种监控功能不香嘛,选择一个适合业务的才是最重要的。
参考
- HikariCP 官网
- Java并发编程实战
免责声明:本站所有文章内容,图片,视频等均是来源于用户投稿和互联网及文摘转载整编而成,不代表本站观点,不承担相关法律责任。其著作权各归其原作者或其出版社所有。如发现本站有涉嫌抄袭侵权/违法违规的内容,侵犯到您的权益,请在线联系站长,一经查实,本站将立刻删除。 本文来自网络,若有侵权,请联系删除,如若转载,请注明出处:https://yundeesoft.com/52979.html