HikariCP 了解一下

作者 | 黄永灿

后端开发攻城狮,关注服务端技术与性能优化。

前言

在我们的工作中,免不了要和数据库打交道,而要想和数据库打好交道,选择一款合适的数据库连接池就至关重要,目前业界广为人知的数据库连接池有 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.* 提供了五个代理类

  1. ProxyConnection (proxy class for java.sql.Connection)

  2. ProxyStatement (proxy class for java.sql.Statement)

  3. ProxyPreparedStatement (proxy class for java.sql.PreparedStatement)

  4. ProxyCallableStatement (proxy class for java.sql.CallableStatement)

  5. 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 方法的主要逻辑是:

  1. 首先查看线程本地存储是否有空闲连接,如果有,则返回一个空闲的连接;

  2. 如果线程本地存储中无空闲连接,则从共享队列中获取。

  3. 如果共享队列中也没有空闲的连接,则请求线程需要等待。

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 主要做了两个地方的优化:

  1. get(int index) 方法去掉对 index 参数进行越界检查,因为 HikariCP 能保证不越界,只会在 for 循环里面用到

  2. remove(Object element) 方法由顺序遍历查找改为逆序遍历查找

第一个优化大家应该很容易理解,能少执行一个判断,当调用很频繁时,其效果就很明显了。第二个优化跟数据库连接池的业务有很大关系,当我们执行完 SQL 后,按照规范,需要关闭 Connection 和 Statement,而关闭 Statement 时需要将 Statement 从 Connection 以逆序的方式移除,如果按照 ArrayList 的 remove 方式,将 n 个 Statement 移除总共要 n + n-1 + ... + 1 次,而如果改为逆序,则只需要 n 次。

总结

除了以上说的几个主要优化之外,HikariCP 还做了若干细节上的优化,包括优化拦截器、对耗时超过一个 CPU 时间片的方法优化等,当然,写本文的目的也不是为了推广 HikariCP 数据库连接池,而是希望学习其中的思想,毕竟 Druid 虽然性能不如 HikariCP,但自带各种监控功能不香嘛,选择一个适合业务的才是最重要的。

参考

  1. HikariCP 官网

  2. Java并发编程实战

全文完


以下文章您可能也会感兴趣:

我们正在招聘 Java 工程师,欢迎有兴趣的同学投递简历到 rd-hr@xingren.com 。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值