写点什么

【HikariCP 技术专题】核心源码分析(为什么那么快?)

发布于: 1 小时前
【HikariCP技术专题】核心源码分析(为什么那么快?)

前提概要

HikariCP 的源码轻巧且简单,读起来不会太吃力,所以,这次不会从头到尾地分析代码逻辑,更多地会分析一些设计巧妙的地方。

阅读 HikariCP 源码需要掌握:CopyOnWriteArrayList、AtomicInteger、SynchronousQueue、Semaphore、AtomicIntegerFieldUpdater 等工具。

注意:考虑篇幅和可读性,以下代码经过删减,仅保留所需部分 。

HikariCP 为什么快?


相比 DBCP 和 C3P0 等连接池,HikariCP 快主要有以下几个原因:


  • 通过代码设计和优化大幅减少线程间的锁竞争。这点主要通过 ConcurrentBag 来实现,下文展开。


  • 引入了更多 JDK 的特性,尤其是 concurrent 包的工具。DBCP 和 C3P0 出现时间较早,基于早期的 JDK 进行开发,也就很难享受到后面更新带来的福利;


  • 使用 javassist 直接修改 class 文件生成动态代理,精简了很多不必要的字节码,提高代理方法运行速度。相比 JDK 和 cglib 的动态代理,通过 javassist 直接修改 class 文件生成的代理类在运行上会更快一些(这是网上找到的说法,但是目前 JDK 和 cglib 已经经过了多次优化,在代理类的运行速度上应该不会差一个数量级)。


HikariCP 涉及 javassist 的代码在 JavassistProxyFactory 类中


重视代码细节对性能的影响。下文到的 fastPathPool 就是一个例子,仔细琢磨 HikariCP 的代码就会发现许多类似的细节优化,除此之外还有 FastList 等自定义集合类;


接下来,本文将在分析源码的过程中对以上几点展开讨论。

HikariCP 的架构

分析具体代码之前,这里先介绍下 HikariCP 的整体架构,和 DBCP2 的有点类似(可见 HikariCP 与 DBCP2 性能差异并不是由于架构设计)。

HikariCP 打交道,一般通过以下几个入口:

  • 通过 JMX 调用 HikariConfigMXBean 来动态修改配置(只有部分参数允许修改,在配置详解里有注明);

  • 通过 JMX 调用 HikariPoolMXBean 来获取连接池的连接数(活跃、空闲和所有)、获取等待连接的线程数、挂起和恢复连接池、丢弃未使用连接等;

  • 使用 HikariConfig 加载配置文件,或手动配置 HikariConfig 的参数,一般它会作为入参来构造 HikariDataSource 对象

使用 HikariDataSource 获取和丢弃连接对象,另外,因为继承了 HikariConfig,我们也可以通过 HikariDataSource 来配置参数,但这种方式不支持配置文件。


为什么 HikariDataSource 持有 HikariPool 的两个引用

在图中可以看到,HikariDataSource 持有了 HikariPool 的引用,看过源码的同学可能会问,为什么属性里会有两个 HikariPool,如下:

这里补充说明下,其实这里的两个 HikariPool 的不同取值代表了不同的配置方式:

  • 配置方式一:当通过有参构造 new HikariDataSource(HikariConfig configuration)来创建 HikariDataSource 时,fastPathPool 和 pool 是非空且相同的;

  • 配置方式二:当通过无参构造 new HikariDataSource()来创建 HikariDataSource 并手动配置时,fastPathPool 为空,pool 不为空(在第一次 getConnectionI() 时初始化),如下;

public Connection getConnection() throws SQLException{      // 判断数据源是否已经关闭      if (isClosed()) {         throw new SQLException("HikariDataSource " + this + " has been closed.");      }      if (fastPathPool != null) {         return fastPathPool.getConnection();      }      // 第二种配置方式会在第一次 getConnectionI() 时初始化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);               }               catch (PoolInitializationException pie) {                  if (pie.getCause() instanceof SQLException) {                     throw (SQLException) pie.getCause();                  }                  else {                     throw pie;                  }               }               LOGGER.info("{} - Start completed.", getPoolName());            }         }      }      return result.getConnection();}
复制代码

针对以上两种配置方式,其实使用一个 pool 就可以完成,那为什么会有两个?我们比较下这两种方式的区别:

	 private final T t1;   private volatile T t2;   	 public void method01(){      if (t1 != null) {         // do something      }   }      public void method02(){      T result = t2;      if (result != null) {         // do something      }   }
复制代码

上面的两个方法中,执行的代码几乎一样,但是 method02 在性能上会比 method01 稍差。当然,主要问题不是出在 method02 多定义了一个变量,而在于 t2 的 volatile 性质,正因为 t2 被 volatile 修饰,为了实现数据一致性会出现不必要的开销,所以 method02 在性能上会比 method01 稍差。pool 和 fastPathPool 的问题也是同理,所以,第二种配置方式不建议使用。


通过上面的问题就会发现,HiakriCP 在追求性能方面非常重视细节,怪不得能够成为最快的连接池!


HikariPool--管理连接的池塘

HikariPool 是一个非常重要的类,它负责管理连接,涉及到比较多的代码逻辑。这里先简单介绍下这个类,对下文代码的具体分析会有所帮助。

HikariPool 的几个属性说明如下:

  • HikariConfig config | 配置信息。

  • PoolBase.IMetricsTrackerDelegate metricsTracker | 指标记录器包装类。

  • HikariCP 支持 Metrics 监控。


本文不会涉及这一部分内容 | | Executor netTimeoutExecutor | 用于执行设置连接超时时间的任务。如果是 mysql 驱动,实现为 PoolBase.SynchronousExecutor,如果是其他驱动,实现为 ThreadPoolExecutor,为什么 mysql 不同,原因见:


https://bugs.mysql.com/bug.php?id=75615 | | DataSource dataSource | 用于获取原生连接对象的数据源。

一般我们不指定的话,使用的是 DriverDataSource | | HikariPool.PoolEntryCreator POOL_ENTRY_CREATOR | 创建新连接的任务,Callable 实现类。

一般调用一次创建一个连接 | | HikariPool.PoolEntryCreator POST_FILL_POOL_ENTRY_CREATOR | 创建新连接的任务,Callable 实现类。

一般调用一次创建一个连接,与前者区别在于它创建最后一个连接,会打印日志 | | Collection<![CDATA[]> addConnectionQueue | 等待执行 PoolEntryCreator 任务的队列 | | ThreadPoolExecutor addConnectionExecutor | 执行 PoolEntryCreator 任务的线程池。

以 addConnectionQueue 作为等待队列,只开启一个线程执行任务。 | | ThreadPoolExecutor closeConnectionExecutor | 执行关闭原生连接的线程池。只开启一个线程执行任务。

ConcurrentBag<![CDATA[]> connectionBag | 存放连接对象的包。用于 borrow、requite、add 和 remove 对象。 | | ProxyLeakTask leakTask | 报告连接丢弃的任务,Runnable 实现类。 | | SuspendResumeLock suspendResumeLock | 基于 Semaphore 包装的锁。

如果设置了 isAllowPoolSuspension 则会生效,默认 MAX_PERMITS = 10000 | | ScheduledExecutorService houseKeepingExecutorService | 用于执行 HouseKeeper(连接检测任务和维持连接池大小)和 ProxyLeakTask 的任务。

只开启一个线程执行任务。 | | ScheduledFuture<?> houseKeeperTask | houseKeepingExecutorService 执行 HouseKeeper(检测空闲连接任务)返回的结果,通过它可以结束 HouseKeeper 任务。

为了更清晰地理解上面几个字段的含义,我简单画了个图,不是很严谨,将就看下吧。在这个图中,PoolEntry 封装了 Connection 对象,在图中把它看成是连接对象会更好理解一些。我们可以看到 ConcurrentBag 是整个 HikariPool 的核心,其他对象都围绕着它进行操作,后面会单独讲解这个类。客户端线程可以调用它的 borrow、requite 和 remove 方法,houseKeepingExecutorService 线程可以调用它的 remove 方法,只有 addConnectionExecutor 可以进行 add 操作。


borrow 和 requite 对于 ConcurrentBag 而言是只读的操作,addConnectionExecutor 只开启一个线程执行任务,所以 add 操作是单线程的,唯一存在锁竞争的就是 remove 方法。接下来会具体讲解 ConcurrentBag。


ConcurrentBag--更少的锁冲突


在 HikariCP 中 ConcurrentBag 用于存放 PoolEntry 对象(封装了 Connection 对象,IConcurrentBagEntry 实现类),本质上可以将它就是一个资源池。

下面简单介绍下几个字段的作用:

CopyOnWriteArrayList sharedList 存放着状态为使用中、未使用和保留三种状态 PoolEntry 对象。

注意,CopyOnWriteArrayList 是一个线程安全的集合,在每次写操作时都会采用复制数组的方式来增删元素,读和写使用的是不同的数组,避免了锁竞争。 | | ThreadLocal]>> threadList | 存放着当前线程返还的 PoolEntry 对象。

如果当前线程再次借用资源,会先从这个列表中获取。注意,这个列表的元素可以被其他线程“偷走”。

SynchronousQueue handoffQueue | 这是一个无容量的阻塞队列,每个插入操作需要阻塞等待删除操作,而删除操作不需要等待,如果没有元素插入,会返回 null,如果设置了超时时间则需要等待。

AtomicInteger waiters | 当前等待获取元素的线程数 | | IBagStateListener listener | 添加元素的监听器,由 HikariPool 实现,在该实现中,如果 waiting - addConnectionQueue.size() >= 0,则会让 addConnectionExecutor 执行 PoolEntryCreator 任务 | | boolean weakThreadLocals | 元素是否使用弱引用。可以通过系统属性 com.zaxxer.hikari.useWeakReferences 进行设置 |

这几个字段在 ConcurrentBag 中如何使用呢,我们来看看 borrow 的方法:

public T borrow(long timeout, final TimeUnit timeUnit) throws InterruptedException   {      // 1. 首先从threadList获取对象      // 获取绑定在当前线程的List<Object>对象,注意这个集合的实现一般为FastList,      // 这是HikariCP自己实现的,后面会讲到      final List<Object> list = threadList.get();       // 遍历结合      for (int i = list.size() - 1; i >= 0; i--) {         // 获取当前元素,并将它从集合中删除         final Object entry = list.remove(i);         // 如果设置了weakThreadLocals,则存放的是WeakReference对象,否则为我们一开始设置的PoolEntry对象         @SuppressWarnings("unchecked")         final T bagEntry = weakThreadLocals ? ((WeakReference<T>) entry).get():(T) entry;          // 采用CAS方式将获取的对象状态由未使用改为使用中,如果失败说明其他线程正在使用它,这里可知,threadList上的元素可以被其他线程“偷走”。         if (bagEntry != null && bagEntry.compareAndSet(STATE_NOT_IN_USE, STATE_IN_USE)) {            return bagEntry;         }      }      // 2.如果还没获取到,会从sharedList中获取对象      // 等待获取连接的线程数+1      final int waiting = waiters.incrementAndGet();      try {         // 遍历sharedList         for (T bagEntry : sharedList) {            // 采用CAS方式将获取的对象状态由未使用改为使用中,如果当前元素正在使用,则无法修改成功,进入下一循环            if (bagEntry.compareAndSet(STATE_NOT_IN_USE, STATE_IN_USE)) {               // 通知监听器添加包元素。如果waiting - addConnectionQueue.size() >= 0,则会让addConnectionExecutor执行PoolEntryCreator任务               if (waiting > 1) {                  listener.addBagItem(waiting - 1);               }               return bagEntry;            }         }         // 通知监听器添加包元素。         listener.addBagItem(waiting);
// 3.如果还没获取到,会从轮训进入handoffQueue队列获取连接对象
timeout = timeUnit.toNanos(timeout); do { final long start = currentTime(); // 从handoffQueue队列中获取并删除元素。这是一个无容量的阻塞队列,插入操作需要阻塞等待删除操作,而删除操作不需要等待,如果没有元素插入,会返回null,如果设置了超时时间则需要等待 final T bagEntry = handoffQueue.poll(timeout, NANOSECONDS); // 这里会出现三种情况, // 1.超时,返回null // 2.获取到元素,但状态为正在使用,继续执行 // 3.获取到元素,元素状态未未使用,修改未使用并返回 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 { // 等待获取连接的线程数-1 waiters.decrementAndGet(); } }
复制代码


在以上方法中,唯一可能出现线程切换到就是 handoffQueue.poll(timeout, NANOSECONDS),除此之外,我们没有看到任何的 synchronized 和 lock。之所以可以做到这样主要由于以下几点:

元素状态的引入,以及使用 CAS 方法修改状态。

在 ConcurrentBag 中,使用使用中、未使用、删除和保留等表示元素的状态,而不是使用不同的集合来维护不同状态的元素。元素状态这一概念的引入非常关键,为后面的几点提供了基础。 ConcurrentBag 的方法中多处调用 CAS 方法来判断和修改元素状态,这一过程不需要加锁。

threadList 的使用。

当前线程归还的元素会被绑定到 ThreadLocal,该线程再次获取元素时,在该元素未被偷走的前提下可直接获取到,不需要去 sharedList 遍历获取;

采用 CopyOnWriteArrayList 来存放元素。

在 CopyOnWriteArrayList 中,读和写使用的是不同的数组,避免了两者的锁竞争,至于多个线程写入,则会加 ReentrantLock 锁。

sharedList 的读写控制。

borrow 和 requite 对 sharedList 来说都是不加锁的,缺点就是会牺牲一致性。用户线程无法进行增加元素的操作,只有 addConnectionExecutor 可以,而 addConnectionExecutor 只会开启一个线程执行任务,所以 add 操作不会存在锁竞争。至于 remove 是唯一会造成锁竞争的方法,这一点我认为也可以参照 addConnectionExecutor 来处理,在加入任务队列前把 PoolEntry 的状态标记为删除中。

其实,我们会发现,ConcurrentBag 在减少锁冲突的问题上,除了设计改进,还使用了比较多的 JDK 特性。

如何加载配置

在 HikariCP 中,HikariConfig 用于加载配置,具体的代码并不复杂,但相比其他项目,它的加载要更加简洁一些。我们直接从 PropertyElf.setTargetFromProperties(Object, Properties)方法开始看,如下:

	  // 这个方法就是将properties的参数设置到HikariConfig中   public static void setTargetFromProperties(final Object target, final Properties properties)   {      if (target == null || properties == null) {         return;      }      // 在这里会利用反射获取      List<Method> methods = Arrays.asList(target.getClass().getMethods());      // 遍历      properties.forEach((key, value) -> {         // 如果是dataSource.*的参数,直接加入到dataSourceProperties属性         if (target instanceof HikariConfig && key.toString().startsWith("dataSource.")) {            ((HikariConfig) target).addDataSourceProperty(key.toString().substring("dataSource.".length()), value);         }         else {            // 如果不是,则通过set方法设置            setProperty(target, key.toString(), value, methods);         }      });   }
复制代码

进入到 PropertyElf.setProperty(Object, String, Object, List<Method>)方法:

private static void setProperty(final Object target, final String propName, final Object propValue, final List<Method> methods)   {      // 拼接参数的setter方法名      String methodName = "set" + propName.substring(0, 1).toUpperCase(Locale.ENGLISH) + propName.substring(1);      // 获取对应的Method 对象      Method writeMethod = methods.stream().filter(m -> m.getName().equals(methodName) && m.getParameterCount() == 1).findFirst().orElse(null);      // 如果不存在,按另一套规则拼接参数的setter方法名      if (writeMethod == null) {         String methodName2 = "set" + propName.toUpperCase(Locale.ENGLISH);         writeMethod = methods.stream().filter(m -> m.getName().equals(methodName2) && m.getParameterCount() == 1).findFirst().orElse(null);      }      // 如果该参数setter方法不存在,则抛出异常,从这里可以看出,HikariCP 中不能存在配错参数名的情况      if (writeMethod == null) {         LOGGER.error("Property {} does not exist on target {}", propName, target.getClass());         throw new RuntimeException(String.format("Property %s does not exist on target %s", propName, target.getClass()));      }
// 接下来就是调用setter方法来配置具体参数了。 try { Class<?> paramClass = writeMethod.getParameterTypes()[0]; if (paramClass == int.class) { writeMethod.invoke(target, Integer.parseInt(propValue.toString())); } else if (paramClass == long.class) { writeMethod.invoke(target, Long.parseLong(propValue.toString())); } else if (paramClass == boolean.class || paramClass == Boolean.class) { writeMethod.invoke(target, Boolean.parseBoolean(propValue.toString())); } else if (paramClass == String.class) { writeMethod.invoke(target, propValue.toString()); } else { writeMethod.invoke(target, propValue); } } catch (Exception e) { LOGGER.error("Failed to set property {} on target {}", propName, target.getClass(), e); throw new RuntimeException(e); } }}
复制代码

我们会发现,相比其他项目(尤其是 druid),HikariCP 加载配置的过程非常简洁,不需要按照参数名一个个地加载,这样后期会更好维护。当然,这种方式我们也可以运用到实际项目中。

获取一个连接对象的过程

现在简单介绍下获取连接对象的过程,我们进入到 HikariPool.getConnection(long)方法:

public Connection getConnection(final long hardTimeout) throws SQLException   {  // 如果我们设置了allowPoolSuspension为true,则这个锁会生效      // 它采用Semaphore实现,MAX_PERMITS = 10000,正常情况不会用完,除非你挂起了连接池(通过JMX等方式),这时10000个permits会一次被消耗完      suspendResumeLock.acquire();      // 获取开始时间      final long startTime = currentTime();      try {         // 剩余超时时间         long timeout = hardTimeout;         PoolEntry poolEntry = null;         try {            // 循环获取,除非获取到了连接或者超时            do {               // 从ConcurrentBag中借出一个元素               poolEntry = connectionBag.borrow(timeout, MILLISECONDS);               // 前面说过,只有超时情况才会返回空,这时会跳出循环并抛出异常               if (poolEntry == null) {                  break;                }               final long now = currentTime();               // 如果元素被标记为丢弃或者空闲时间过长且连接无效则会丢弃该元素,并关闭连接               if (poolEntry.isMarkedEvicted() || (elapsedMillis(poolEntry.lastAccessed, now) > ALIVE_BYPASS_WINDOW_MS && !isConnectionAlive(poolEntry.connection))) {                  closeConnection(poolEntry, "(connection is evicted or dead)"); // Throw away the dead connection (passed max age or failed alive test)                  // 计算剩余超时时间                  timeout = hardTimeout - elapsedMillis(startTime);               }               else {                  // 这一步用于支持metrics监控,本文不涉及                  metricsTracker.recordBorrowStats(poolEntry, startTime);                  // 创建Connection代理类,该代理类就是使用Javassist生成的                  return poolEntry.createProxyConnection(leakTask.schedule(poolEntry), now);               }            } while (timeout > 0L);            // 不涉及            metricsTracker.recordBorrowTimeoutStats(startTime);         }         catch (InterruptedException e) {            // 获取连接过程如果中断,则回收连接并抛出异常            if (poolEntry != null) {               poolEntry.recycle(startTime);            }            Thread.currentThread().interrupt();            throw new SQLException(poolName + " - Interrupted during connection acquisition", e);         }      }      finally {         // 释放一个permit         suspendResumeLock.release();      }      // 抛出超时异常      throw createTimeoutException(startTime);   }
复制代码


以上就是获取连接对象的过程,没有太复杂的逻辑。这里需要注意,使用 HikariCP 最好不要开启 allowPoolSuspension ,否则每次连接都会有获取和释放 permit 的过程。

另外,HikariCP 默认 testOnBorrow,有点难以理解。


发布于: 1 小时前阅读数: 3
用户头像

🏆2021年InfoQ写作平台-签约作者 🏆 2020.03.25 加入

👑【酷爱计算机技术、醉心开发编程、喜爱健身运动、热衷悬疑推理的”极客狂人“】 🏅 【Java技术领域,MySQL技术领域,APM全链路追踪技术及微服务、分布式方向的技术体系等】 我们始于迷惘,终于更高水平的迷惘

评论

发布
暂无评论
【HikariCP技术专题】核心源码分析(为什么那么快?)