你好,我是 yes。
今天给大家剖析下一个叫 ConcurrentBag 的并发集合类,对 C# 熟悉的同学应该听过这个名字,不过我今天介绍的是 HikariCP 中的 ConcurrentBag。
我们知道 SpringBoot 默认连接池就是 HikariCP,而 HikariCP 就是以快著称的,而这个快离不开 ConcurrentBag。
如果你看过很多源码你就会发现好多框架都会自定义集合类,因为 JDK 通用的集合需要照顾到很多场景,而定制化肯定优于普适化。
像 HikariCP 就没有用 ArrayList 而是定义了一个 FastList,因为 ArrayList 每次 get 都会有范围检查,并且 remove 是从前往后遍历的。
而在 HikariCP 这个场景每次 get 范围检查没有必要,并且 remove 的时候从后往前遍历更好,所以就定制化了。
HikariCP 还有很多优化,这篇文章我们就谈谈其中之一,也就是今天的主角就是 ConcurrentBag 。
不过今天的目的不是为了分析 HikariCP ,而只是介绍这个集合类。
从它身上找点优化的思路,到时候像面试官问你如何设计一个连接池的时候就可以搬出来:“哎呀,我有个优化思路。”
ConcurrentBag
一般而言我们设计一个连接池的初始想法是用锁来保证线程安全,或者用一些线程安全的并发容器来存储连接。
而 HikariCP 不满足于此,它专门设计了 ConcurrentBag 用来存数据库连接,当 HikariPool#getConnection 的时候就是去 ConcurrentBag 拿连接。
ConcurrentBag 整体就是无锁设计,有三个重要的成员变量:
- ThreadLocal 缓存,加快本地连接获取速度
- CopyOnWriteArrayList,写时拷贝List
- SynchronousQueue,无存储的等待队列
获取数据库连接基本流程如下:
- 当取连接的时候会先去 ThreadLocal 去找以前用过的连接,如果找到连接状态是可以使用的话拿直接返回。
(ThreadLocal 是本地资源,每个线程都优先去自己本地去找,所以竞争也更少,需要遍历的连接也更少,所以速度就更快) - 找不到再去 sharedList 这个共享的写时复制列表中查找可用连接。
- 如果再找不到,则通过 handoffQueue 等待可用的连接,如果超过一定时间则返回 null。
其实这种思想很简单。
每个线程一开始本地资源肯定是空的,然后每个线程把自己用过的连接存起来,之后优先用存着的链接。
久而久之每个线程都会有自己的本地存储的连接,这样大家都用自己的就少了竞争,那速度不就快了?
我们再来看下取连接的源码,里面还是有一些细节的。
其实应该叫借连接,因为要还的,而且也不是把连接从 ConcurrentBag 移除,只是返回一个引用罢了。
细节已经在代码上标注了,这里强调一下借连接不是移除连接,别的线程还是能通过 sharedList 找到这个连接的,无非这个连接如果被占用则状态是 STATE_IN_USE
,这样别的线程就不会用这个连接了。
总体思路就是从本地找,没有的话再去每个线程都能访问的 sharedList 找,再没有就等着。
这里还有个窃取的概念,其实没什么花头,就是充分利用连接。
无非就是本来属于某个线程的本地连接,当它归还连接的时,恰巧有另一个线程从 sharedList 遍历找到这个连接,这时候连接的状态是 STATE_NOT_IN_USE
,那么这个连接就会被另一个线程也保存到 ThreadLocal 中了。
这就是窃取,我们再来看下归还连接的代码,连接就是在这里保存到 ThreadLocal 中的。
我在《HikariCP数据库连接池实战》这本书中看到,归还连接的代码在 HikariCP 2.6.0 是长下面这个样子的
先停下来想想看有没有啥问题?
当前归还连接的线程需要等这个连接被其他线程取走时或者没有等待线程时才能摆脱这个循环。
但是会出现一种情况:在设置连接为可用时,这个连接已经被其他线程借走了,然后当前线程还傻傻的执行循环,而恰巧等待线程一直有,但是每次 handoffQueue.offer 就是没线程取,然后 yield ,如此往复。
这就造成明明连接已经归还了,而归还的线程还做无用功的自旋操作,所以就做优化成上面的代码,如果bagEntry.getState() != STATE_NOT_IN_USE
说明已经被别的线程借去用了,所以直接 return。
再提一提 CopyOnWriteArrayList 吧。
连接池是一个典型的读多写少的场景,所以写时复制用在此处再合适不过了。
简单的说:写操作的时候会复制当前的 list 来做修改,等修改完了再替换老的 list。
在替换之前读的线程读取的是老的 list 的数据,这样就能做到读的时候是无锁的。
写时复制的缺点就是内存的占用,因为需要拷贝一份数据,如果数据很大的话那就需要考虑内容的占用量了。
比如操作系统进程的 fork 操作也会用到写时复制,子进程和父进程一开始共享数据,当有修改的时候就会拷贝一份。
在 Redis 的 BGSAVE 命令或者 BGREWRITEAOF 命令的过程中就会 fork 子进程来进行后台操作,而此时 Redis 的哈希表扩容的负载因子就会变大,来避免 fork 期间不必要的内存写入操作 (扩容)。
最后
所以 ConcurrentBag 的优化思路就是本地缓存有的去本地缓存找连接,找不到就去公共的 sharedList 去找,还找不到就等着。
通过将连接本地存储化来减少竞争,又根据连接池读多写少的特性用 CopyOnWriteArrayList 来实现 sharedList 。
当然还有像上面 borrow 和 requite 的一些细节也值得品味,追求极致速度就需要扣细节。
更多文章可看我的文章汇总:https://github.com/yessimida/yes 欢迎 star !
我是 yes,从一点点到亿点点,欢迎在看、转发、留言,我们下篇见。
巨人的肩膀
《HikariCP数据库连接池实战》