HIKARICP 6.3.3 · CONCURRENTBAG · THREADLOCAL · COPYONWRITEARRAYLIST · SYNCHRONOUSQUEUE · CAS
借连接不加锁,代价是不保证先来先得
ConcurrentBag 只认连接身上的一个 state 字段:谁用 CAS 把它从 0 改成 1,连接就归谁。线程自己的口袋(ThreadLocal)、公共的货架(sharedList)、递交窗口(SynchronousQueue)都只是去哪找连接的路子,所以借和还都不用拿锁。代价是:还回来、递给等待者的连接,在对方醒来之前谁扫到都能拿走,排在前面的人可能一直被插队。
假设场景面试被问“HikariCP 为什么快?借连接要不要加锁?等连接是先来先得吗?”,又想弄清上一页压测里那件怪事:平均只等 338 ms,却有 1808 次等满 1 秒超时。所以本页偏源码和设计取舍,⑤ 很短,篇幅给 ④ 和 ⑥ 的第三圈,⑥ 末尾有面试题。源码以 Spring Boot 3.5 带的 HikariCP 6.3.3 为准;标“实测”的数字在本机跑过:JDK 21,2 核 ARM 虚拟机。
①为什么需要:池子里最热的两行代码
每个请求至少借一次连接、还一次。Tomcat 有 200 个线程,池子只有 10 个连接,这两步每秒要被调用上万次,而且是很多线程同时调。
borrow)和还(requite)都落在它身上。它要同时满足图下面这四件事。②没有它会怎样:用会的东西写一个池子
只用 JUC 现成的东西,最顺手的写法是一个阻塞队列:借就是 poll,还就是 offer。commons-pool2(DBCP2 底下那层)就是这个思路。它能用,问题出在“所有人都要过同一把锁”。
// 10 个连接放进一个有界阻塞队列
var idle = new ArrayBlockingQueue<Conn>(10);
Conn borrow(long ms) throws InterruptedException {
return idle.poll(ms, MILLISECONDS); // 拿锁 → 取 → 放锁
}
void giveBack(Conn c) {
idle.offer(c); // 拿锁 → 放 → 唤醒一个 → 放锁
}
| 线程数(池子 10 个) | 1 | 4 | 10 | 40 | 200 |
|---|---|---|---|---|---|
HikariCP ConcurrentBag | 800 万 | 1294 万 | 1291 万 | 1014 万 | 869 万 |
ArrayBlockingQueue(一把非公平锁) | 927 万 | 790 万 | 847 万 | 761 万 | 676 万 |
synchronized + wait/notify | 571 万 | 245 万 | 209 万 | 214 万 | 299 万 |
| 一把锁 + 严格先来先得递交 | 912 万 | 378 万 | 278 万 | 41 万 | 27 万 |
单位:次/秒。最后一行是我按 Tomcat JDBC FairBlockingQueue 的思路写的:还回来的连接直接塞给排在最前面的人。40 线程那一列跑了 3 次取中位数,其余各 1 次。这台机器只有 2 核,同一时刻真正在跑的只有 2 个线程,所以“一把锁”只慢了两到四成;核更多时差距会更大,这里测不出来,不给数字。
单线程时几种写法一次借还都在 110~175 ns。一有并发,锁的写法就掉下来,因为借和还都要串行地过同一个临界区。
严格先来先得,意味着还连接时必须把它交到一个正在睡觉的线程手里。对方醒来要几微秒,40 线程时每秒只剩 41 万次,是 ConcurrentBag 的 4%。
ConcurrentBag 的精华:有空闲连接时不碰任何锁(翻自己口袋、CAS 抢货架),没空闲时谁正在 CPU 上跑谁先拿,不为了公平去等一个睡着的线程。
不懂这一点的典型误用:以为“换成 HikariCP 慢 SQL 就快了”(池子开销是百纳秒级,查询是毫秒级),或者以为“等连接是排队的,先来先得”,结果被少数请求等到超时吓一跳,见 ⑥ 坑 1。
③相邻方案:四个池子怎么放连接
Java 里常见的几个连接池,借还的写法各不相同。下表每一格都对着各自的源码核过(版本见表头下)。
| 池子 | 空闲连接放在哪 | 借、还要不要拿锁 | 没有空闲时怎么等 | 先来先得吗 |
|---|---|---|---|---|
| commons-pool2 2.12.1 DBCP2 用它 | LinkedBlockingDeque,默认 lifo=true,还回来放队头 | 要,一把 ReentrantLock | 在 notEmpty 条件上等 | fairness 默认 false,不保证 |
| Tomcat JDBC 10.1.55 | FairBlockingQueue:一个 LinkedList + 一个等待者链表 | 要,一把锁 | 排进等待者链表,还连接时直接塞给链表头 | fairQueue 默认 true,是 |
| Druid 1.2.28 | 一个数组当栈用,从尾巴取(LIFO) | 要,一把 ReentrantLock(默认构造是非公平锁) | 在 notEmpty 条件上 await | 不保证 |
| HikariCP 6.3.3 | 货架 CopyOnWriteArrayList + 每线程一个口袋 | 有空闲时不要,只 CAS 一个 state | 停在 SynchronousQueue(true).poll 上等人递 | 等的人之间是,但新来的人能插队 |
比如批处理里不能容忍个别请求等很久:Tomcat JDBC 的 fairQueue 是现成的。
Druid 的强项在 StatFilter、WallFilter 这些外围功能,不在借还速度。
Spring Boot 2.0 起默认就是 HikariCP。本页学它的 ConcurrentBag。
选择规则:借还速度极少是瓶颈,先按“要不要严格公平、要不要监控”选池子。本页学 HikariCP 的 ConcurrentBag,因为它把“公平”换成了“不过锁”,这个取舍正是面试和调优要说清的那一层。
④原理:三个地方找连接,一个字段定归属
先记住一句话:连接放在哪不重要,state 是几才重要。下面的口袋、货架、递交窗口都只存“引用”,同一个连接可以同时出现在好几个地方;真正决定它归谁的,只有它自己身上那个 volatile int state。
先知道这些
CAS
compareAndSet(0, 1):如果现在是 0 就改成 1,返回成没成功。CPU 一条指令完成,两个线程同时做,只有一个能成功。不用锁。
ThreadLocal
每个线程各有一份的变量。线程 A 往自己那份里放东西,线程 B 看不见,所以读写都不用加锁。
CopyOnWriteArrayList
读的时候直接遍历当前数组,不加锁;写(加、删)时复制一份新数组再替换。适合读极多、写极少。
SynchronousQueue
容量为 0 的队列。offer(x) 只有在恰好有线程停在 poll 上时才成功,直接交到对方手里;没人等就返回 false,什么也不存。
borrow() 按 1 → 2 → 3 的顺序找:先翻自己的口袋(ThreadLocal 里的列表,最多记 16 个),再扫货架,最后停在递交窗口上等。图里 A 的口袋记着 c0,c0 正空着,A 借的时候 1 就命中,连货架都不用看。reserve 把 0 改成 -2,和借的人抢同一个 CAS:业务线程抢到了,管家就跳过它;管家抢到了,业务线程扫货架时看到 -2,接着扫下一个。两边都不用锁,也不会撞车。一次完整的借还 池子里只有 1 个连接,A 借两次,B 来晚了要等
开始:货架上只有 c0,两个线程的口袋都是空的。点“下一步”。
递交只是“叫醒 + 推荐”,不是“交到手里”
requite() 先把 state 改成 0,再看有没有人在等、递给谁。递交成功后,连接仍然挂在货架上,state 仍是 0,直到收到的人醒来、自己 CAS 成功。正在 CPU 上跑的线程比刚被叫醒的线程快得多,所以“还完马上又借”几乎总能把连接抢回来。被抢的人回去重新 poll,在 SynchronousQueue 的公平队列里排到了最后。源码在扫货架那段写着注释:If we may have stolen another waiter's connection, request another bag add.为什么非得先改 state、再看 waiters
HikariCP 的顺序
- 还的人:① 写
state = 0,② 读waiters - 借的人:① 写
waiters + 1,② 读state(扫货架) - 两边都是“先写自己的,再读对方的”,而且都是 volatile / 原子操作。不管怎么交错,至少有一边能看到对方写的值:要么借的人扫到 state = 0 自己拿走,要么还的人看到 waiters > 0 去递交
被放弃的顺序 先递交,递不出去再改 0
- 还的人:① 读
waiters= 0,② 写state = 0 - 如果借的人正好在 ① 和 ② 之间加了 waiters、扫了货架(这时 state 还是 1),他就会停到窗口上等
- 还的人看到的是旧的 waiters = 0,改完 state 就走了。结果:连接空着,人在干等,没人去叫他。这叫丢失唤醒
⑥ 的调度实验台里有这个场景,可以自己点出来。在实验台用的同一个模拟器里随机交错各跑 2 万次:HikariCP 的顺序 0 次丢失唤醒;被放弃的顺序 672 次。
为什么这样设计:公平和吞吐只能选一个
“被放弃的设计”是我把 HikariCP 6.3.3 的 requite 改了几行:先 offer,state 保持 1 交到对方手里,递不出去才改回 0;对方醒来不用再 CAS。这样谁也插不了队,严格先来先得。它的丢失唤醒问题见上一节,这里只看公平换来了什么、付出了什么。
HikariCP:谁正在跑谁先拿
- 没人等时,借还全程无锁;有人等时,还的人不用等对方醒
- 一个
state管住所有并发,代码短,管家回收也用同一套 CAS - 不公平:同样平均等 1 ms,有的人等 0,有的人被反复插队
独占递交:排在前面的先拿
- 每个人等的时间都差不多,P99 只比 P50 高一点
- 只要有人在等,每次还连接都必须叫醒一个睡着的线程,线程切换的成本落在每一次借还上
- 要补上丢失唤醒,还得在改 0 之后再查一遍、再抢回来递,代码更复杂
决定胜负的约束:叫醒一个睡着的线程要微秒级,正在 CPU 上的线程 CAS 拿走只要几十纳秒;而且一个配得合理的连接池,大部分时候根本不用等。HikariCP 因此选了吞吐和简单,把严格公平让了出去。代价落在“有人在等”的那一小段时间里:等待者的尾延迟。
借连接时要扫,每秒上万次;加、删连接只发生在建新连接和回收时,几秒一次。扫的时候不加锁,拿的是当时数组的快照;改的时候复制一份,慢一点也无所谓。
同一个线程还完再借,大概率拿回刚还的那个。口袋命中时,整个 borrow 只碰两样东西:自己线程的列表、这个连接的 state,连 waiters 计数都不改。
只有真有人停在 poll 上,offer 才会成功。没人等就不存,连接不会同时躺在货架和另一个队列里,也就不会有人从队列里捞到一个早被别人拿走的连接。true 表示等的人之间按先后来。
最少的 API:HikariPool 只用到这几个方法
PoolEntry e = connectionBag.borrow(timeout, MILLISECONDS); // getConnection(),超时返回 null
connectionBag.requite(e); // Connection.close() → recycle()
connectionBag.add(newEntry); // 建连接的线程建好后上架
connectionBag.reserve(e); // 管家 / 保活检查:先占住(0 → -2)
connectionBag.unreserve(e); // 检查完还活着,放回(-2 → 0)
connectionBag.remove(e); // 下架(1 或 -2 → -1),然后关掉真实连接
connectionBag.getWaitingThreadCount(); // = 指标 hikaricp.connections.pending
connectionBag.getCount(STATE_IN_USE); // = active;STATE_NOT_IN_USE 就是 idle
口袋最多记几个、用不用 WeakReference、还连接时怎么自旋、各版本改了什么,这些是细节,放到 第⑥步。
⑤亲眼看到:直接拿 HikariCP 的 ConcurrentBag 来玩
不用连数据库。ConcurrentBag 是 public 类,自己写一个带 state 的小对象放进去,就能看到口袋、偷连接、递交、插队这四件事。
<dependency><groupId>com.zaxxer</groupId><artifactId>HikariCP</artifactId><version>6.3.3</version></dependency>
import com.zaxxer.hikari.util.ConcurrentBag;
import com.zaxxer.hikari.util.ConcurrentBag.IConcurrentBagEntry;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
// 运行:java -cp HikariCP-6.3.3.jar:slf4j-api-2.0.x.jar BagPeek.java(JDK 11+)
// 直接用 HikariCP 里的 ConcurrentBag,不连数据库:Conn 只是一个带 state 的小对象
public class BagPeek {
static class Conn implements IConcurrentBagEntry {
final String id; final AtomicInteger state = new AtomicInteger(); // 0 空闲 1 借出
Conn(String id) { this.id = id; }
public boolean compareAndSet(int e, int n) { return state.compareAndSet(e, n); }
public void setState(int s) { state.set(s); }
public int getState() { return state.get(); }
public String toString() { return id; }
}
static ConcurrentBag<Conn> bag(int n) {
var b = new ConcurrentBag<Conn>(waiting -> {}); // 不补新连接
for (int i = 0; i < n; i++) b.add(new Conn("c" + i));
return b;
}
public static void main(String[] args) throws Exception {
// ① 口袋:A、B 各借一次再还,c0、c1 分别进了 A、B 的 ThreadLocal 口袋
var bag = bag(3);
var a = Executors.newSingleThreadExecutor(r -> new Thread(r, "A"));
var b = Executors.newSingleThreadExecutor(r -> new Thread(r, "B"));
Conn ca = a.submit(() -> bag.borrow(1, TimeUnit.SECONDS)).get();
Conn cb = b.submit(() -> bag.borrow(1, TimeUnit.SECONDS)).get();
a.submit(() -> bag.requite(ca)).get();
b.submit(() -> bag.requite(cb)).get();
System.out.println("① B 再借:" + b.submit(() -> { var c = bag.borrow(1, TimeUnit.SECONDS); bag.requite(c); return c; }).get()
+ "(c0 也空着,B 先翻自己的口袋)");
// ② 偷:新线程 C 口袋是空的,扫货架(sharedList)拿走 c0;A 口袋里那条 c0 就过期了
var stolen = Executors.newSingleThreadExecutor(r -> new Thread(r, "C")).submit(() -> bag.borrow(1, TimeUnit.SECONDS)).get();
System.out.println("② C 拿到:" + stolen + ";A 再借:" + a.submit(() -> bag.borrow(1, TimeUnit.SECONDS)).get()
+ "(口袋里的 c0 已被 C 借走,CAS 失败,改去扫货架)");
a.shutdown(); b.shutdown();
// ③ 递交:只有 1 个连接,W 在等;main 一还,就 offer 给停在 poll 上的 W
var one = bag(1);
Conn held = one.borrow(1, TimeUnit.SECONDS);
var w = CompletableFuture.supplyAsync(() -> { try { long t = System.nanoTime(); var c = one.borrow(2, TimeUnit.SECONDS);
return "W 拿到 " + c + ",等了 " + (System.nanoTime() - t) / 1_000_000 + " ms"; } catch (InterruptedException e) { return "?"; } });
Thread.sleep(200);
System.out.println("③ 还之前 waiters = " + one.getWaitingThreadCount());
one.requite(held);
System.out.println(" " + w.get());
// ④ 插队:还完马上又借,W 被唤醒前,还的人自己把连接抢了回去
int trials = 1000, robbed = 0;
for (int i = 0; i < trials; i++) {
var single = bag(1);
Conn mine = single.borrow(1, TimeUnit.SECONDS);
var waiter = CompletableFuture.supplyAsync(() -> { try { return single.borrow(1, TimeUnit.SECONDS); } catch (InterruptedException e) { return null; } });
while (single.getWaitingThreadCount() == 0) Thread.onSpinWait();
Thread.sleep(1); // 让 W 停进 poll
single.requite(mine); // 还:递给 W
Conn again = single.borrow(0, TimeUnit.MILLISECONDS); // 马上又借
if (again != null) { robbed++; single.requite(again); } // 再还一次,W 这回才拿到
waiter.get();
}
System.out.println("④ 还完马上又借,1000 次里抢回来 " + robbed + " 次;这些次里 W 白醒一回,CAS 失败后重新 poll");
}
}
$ java -cp HikariCP-6.3.3.jar:slf4j-api-2.0.18.jar BagPeek.java
SLF4J(W): No SLF4J providers were found. ← 没放日志实现,忽略
SLF4J(W): Defaulting to no-operation (NOP) logger implementation
SLF4J(W): See https://www.slf4j.org/codes.html#noProviders for further details.
① B 再借:c1(c0 也空着,B 先翻自己的口袋)
② C 拿到:c0;A 再借:c1(口袋里的 c0 已被 C 借走,CAS 失败,改去扫货架)
③ 还之前 waiters = 1
W 拿到 c0,等了 200 ms
④ 还完马上又借,1000 次里抢回来 959 次;这些次里 W 白醒一回,CAS 失败后重新 poll
| 段 | 看到了什么 | 对应 ④ 的哪一条 |
|---|---|---|
| ① | 货架上 c0 排在前面而且空着,B 拿到的却是 c1:它先翻了自己的口袋 | 三个地方找连接的第 1 步 |
| ② | A 的口袋里明明记着 c0,C 照样从货架上拿走了;A 再借时 CAS 失败,转去货架拿到 c1(那是 B 口袋里记着的) | 归属只看 state,口袋只是线索 |
| ③ | W 等了 200 ms,main 一还就到了 W 手里,没有经过谁的口袋 | waiters > 0 时走递交窗口 |
| ④ | 多次运行在 949~981 次之间。只要还完到再借之间隔 1 ms,就只剩 4~5 次 | 递交只是叫醒 + 推荐 |
⑥细节:从会踩的坑,到一行行源码
按圈往里走:第一圈是不知道就会出事的坑,第二圈是团队里该怎么写,第三圈读源码、自己当一回线程调度器、看各版本改了什么。
注意事项
- 现象
- 200 个线程抢 10 个连接、每次持有 1 ms,
connectionTimeout设 1 s:6 秒里 HikariCP 超时 457 次,P99 等了 467 ms;同样负载下严格先来先得 0 次超时,最长 31 ms。实测 - 根源
- 递过去的连接在对方醒来前 state 还是 0,正在跑的线程扫货架就拿走了。被抢的人回去重新
poll,排到最后,再被抢,再排到最后。 - 做法
- 让“等连接”很少发生:按并发和持有时间算池子、缩短持有时间;
connectionTimeout按接口的延迟预算设;盯hikaricp.connections.timeout和 acquire 的最大值,不只看平均。
- 现象
- 不在事务里时,
JdbcTemplate每次调用、MyBatis 每次 Mapper 调用都借一次、还一次。循环 100 次就是“还完马上又借”100 次。20 线程实测:立刻再借时 97% 的借用是插队拿到的,P99 27.5 ms;隔 100 µs 再借,插队降到 14%,P99 2.8 ms。实测 - 根源
- 还的人正在 CPU 上,被叫醒的人还在路上,还的人扫货架总是更快。
- 做法
- 一次借、做完再还:把循环放进一个事务,或用
ConnectionCallback;能合并就改成一条IN查询或批量语句。
- 现象
- 外层事务拿着一个连接,里面又
REQUIRES_NEW、或者查另一个用同一个池子的 DAO。10 个线程各拿 1 个、各等第 2 个,10 个连接全被“等别人”的人占着,全部等到connectionTimeout。 - 根源
- 借出去的连接只有借的人能改回 0,池子不会从谁手里收回来;
borrow没有空闲的就只能等。 - 做法
- 别让一个线程同时持有两个连接。实在要,池子至少开到 HikariCP wiki《About Pool Sizing》给的
Tn × (Cm − 1) + 1(Tn 是线程数,Cm 是一个线程同时最多拿几个)。
- 现象
- 忘了
close(),或异常路径没关。active只涨不跌,最后人人等超时。类注释原话:borrowed but never "requited" will result in a memory leak。 - 根源
- 借出时连接没从货架上拿下来,只是 state 变成 1。没有人调用
requite,它就一直是 1。 - 做法
- 一律 try-with-resources,或交给
JdbcTemplate/ MyBatis 管。测试环境开leakDetectionThreshold(默认 0,即关闭),它只打一条带堆栈的警告,不会帮你收回连接。
- 现象
- 每个任务一条新虚拟线程、只借一次:口袋命中 0%,全靠扫货架。一个任务里借 3 次:后两次能命中,约 67%。实测
- 根源
- 口袋是
ThreadLocal,新线程的口袋是空的,而且每条借过连接的虚拟线程都会建一个 16 格的列表,线程结束就成了垃圾。 - 做法
- 不用改配置,功能是对的,只是少了最快那条路。6.3.3 和 7.0.2 源码里都没有针对虚拟线程的分支。另外,等待会从 Tomcat 线程池挪到连接池里,
pending会变大,见上一页。
- 现象
- 不缺连接时
pending偶尔也是 1;压测时idle显示有空闲,却有人在等。 - 根源
pending就是waiters:口袋没命中的线程扫货架之前就 +1 了。idle数的是 state = 0 的连接,递交途中的那个也算。指标值由PoolStats缓存,最多 1 秒刷新一次。- 做法
- 看
pending的趋势和持续时间,配合 timeout 计数和 acquire 的最大值判断,不要拿某一秒的瞬时值下结论。
使用规范
手写 JDBC 时,Connection、Statement、ResultSet 都放进 try (...)。
连接永远停在 state = 1(坑 4)
调接口、发 MQ、等分布式锁,都放到事务外面。
持有时间变长 → 等待变常态 → 插队和超时被放大(坑 1)
放进同一个事务,或者用 IN、batchUpdate。
一路“还完马上又借”插到别人前面(坑 2)
REQUIRES_NEW 慎用非用不可时,按 Tn × (Cm − 1) + 1 开池子,并压测验证。
池子满时互相卡死(坑 3)
hikaricp.connections.timeout、hikaricp.connections.acquire 的 max,加上 pending 持续多久。
平均值正常,掩盖了被反复插队的少数请求(坑 1、坑 6)
6.3.3 的 javadoc 已标 @hidden;6.3.0 还把字段 threadList 改名成了 threadLocalList。本页 ⑤ 直接用它,只是为了观察。
升级 HikariCP 时编译不过或反射失效
读源码:borrow 和 requite 一行行看
先在 HikariPool 里搜 connectionBag.,只有 ④ 末尾那几处。知道谁在什么时候调,读 ConcurrentBag 时才知道每个方法在防什么。
四个常量 0、1、-1、-2 和谁能改它,就是这个类的全部“不变式”。流程里每一个 CAS,都是在维护这张状态图。
把类复制一份、每条路径加一个 LongAdder,跑一遍就知道哪条路最常走,下面的“计数器实测”就是这么来的。类注释从 2.7.9 到 7.0.2 都还写着用 AbstractQueuedLongSynchronizer 发信号,代码里用的是 SynchronousQueue。
131 public T borrow(long timeout, final TimeUnit timeUnit) throws InterruptedException132 {133 // Try the thread-local list first134 final var list = threadLocalList.get();135 for (var i = list.size() - 1; i >= 0; i--) {136 final var entry = list.remove(i);❶137 @SuppressWarnings("unchecked")138 final T bagEntry = useWeakThreadLocals ? ((WeakReference<T>) entry).get() : (T) entry;139 if (bagEntry != null && bagEntry.compareAndSet(STATE_NOT_IN_USE, STATE_IN_USE)) {❷140 return bagEntry;141 }142 }143144 // Otherwise, scan the shared list ... then poll the handoff queue145 final var waiting = waiters.incrementAndGet();❸146 try {147 for (T bagEntry : sharedList) {148 if (bagEntry.compareAndSet(STATE_NOT_IN_USE, STATE_IN_USE)) {❹149 // If we may have stolen another waiter's connection, request another bag add.150 if (waiting > 1) {151 listener.addBagItem(waiting - 1);❺152 }153 return bagEntry;154 }155 }156157 listener.addBagItem(waiting);❻158159 timeout = timeUnit.toNanos(timeout);160 do {161 final var start = currentTime();162 final T bagEntry = handoffQueue.poll(timeout, NANOSECONDS);❼163 if (bagEntry == null || bagEntry.compareAndSet(STATE_NOT_IN_USE, STATE_IN_USE)) {❽164 return bagEntry;165 }166167 timeout -= elapsedNanos(start);168 } while (timeout > 10_000);❾169170 return null;171 }172 finally {173 waiters.decrementAndGet();❿174 }175 }
- 1从口袋尾部往前取,取一个删一个。最后还的那个最先被拿到,它最可能还热、还活着;从尾部删,
FastList不用挪数组。没命中的过期线索也顺手扔掉了。 - 2口袋里的只是线索:它可能已被别人借走、被管家 reserve、已经下架,所以必须 CAS。走 WeakReference 时,
get()还可能是 null(被 GC 清了)。 - 3口袋没命中,先把
waiters加 1,再去扫货架。顺序不能反,原因见 ④ 先写自己的、再读对方的。口袋命中时根本走不到这里,所以不碰这个共享计数。 - 4从头扫,CAS 第一个 state = 0 的。货架是
CopyOnWriteArrayList,遍历的是开始时那份数组的快照,不加锁。 - 5作者自己写明了插队:
waiting > 1说明还有别人在等,我这次扫到的可能本该是递给他的,于是请池子再补一个连接。只有没到maximumPoolSize时才真会建,池子已满就什么也不发生。 - 6货架上也没有,就让池子补连接(异步,见下面的
addBagItem),自己去窗口等。 - 7停在
SynchronousQueue.poll上,线程在这里睡着。jstack 里那一大片ConcurrentBag.borrow就停在这一行(上一页实测 190 个)。 - 8超时返回 null;递来了也要 CAS。CAS 失败就是被插队了:用剩下的时间回到 7 重新
poll。 - 9剩下的时间不到 10 µs 就不再等了。
- 10不管拿没拿到,出门前
waiters减 1。
186 public void requite(final T bagEntry)187 {188 bagEntry.setState(STATE_NOT_IN_USE);❶189190 for (var i = 0; waiters.get() > 0; i++) {❷191 if (bagEntry.getState() != STATE_NOT_IN_USE || handoffQueue.offer(bagEntry)) {❸192 return;193 }194 else if ((i & 0xff) == 0xff) {❹195 parkNanos(MICROSECONDS.toNanos(10));196 }197 else {198 Thread.yield();❺199 }200 }201202 final var threadLocalEntries = this.threadLocalList.get();203 if (threadLocalEntries.size() < 16) {❻204 threadLocalEntries.add(useWeakThreadLocals ? new WeakReference<>(bagEntry) : bagEntry);205 }206 }
- 1第一件事就把 state 改成 0。从这一刻起,任何扫货架的人都能拿走它。
- 2只要还有人在等,就一直试着递出去。
- 3state 已经不是 0,说明有人从货架上拿走了,不用递了;否则
offer,成功就交给最早停在 poll 上的人。offer失败,是因为等的人还在扫货架、还没走到poll。 - 4每转 256 圈睡 10 µs,其余时候
Thread.yield()。7.0.1 起多了一个条件:等的人多时(i % waiting == 0)也睡一下,少烧点 CPU。 - 5没人等了,就放进还的这个线程自己的口袋。
- 6口袋最多记 16 个,满了就不记,反正货架上一直有。6.2.1 及以前是 50 个,6.3.0 改成 16。
add()(新连接上架)和 unreserve()(管家检查完放回)结尾也有同样的“有人等就递、递不出去就 yield”循环,所以新建好的连接也能直接递到等待者手里。
实验台:你来当线程调度器 先猜,再一步步点
每个线程的代码按上面的源码拆成几个原子步骤。真实的 JVM 里谁先跑由调度器决定,这里由你决定:点“按剧本走一步”看设计好的交错,也可以直接点某个线程的“走一步”,试试别的顺序。日志里的行号对应上面的源码。
池子那一侧:借到之后、等的时候、闲下来时
158 var timeout = hardTimeout;159 do {160 var poolEntry = connectionBag.borrow(timeout, MILLISECONDS);❶161 if (poolEntry == null) {162 break; // We timed out... break and throw exception163 }164165 final var now = currentTime();166 if (poolEntry.isMarkedEvicted() || (elapsedMillis(poolEntry.lastAccessed, now) > aliveBypassWindowMs && isConnectionDead(poolEntry.connection))) {❷167 closeConnection(poolEntry, poolEntry.isMarkedEvicted() ? EVICTED_CONNECTION_MESSAGE : DEAD_CONNECTION_MESSAGE);168 timeout = hardTimeout - elapsedMillis(startTime);❸169 }170 else {171 metricsTracker.recordBorrowStats(poolEntry, startTime);172 if (isRequestBoundariesEnabled) {173 try {174 poolEntry.connection.beginRequest();175 } catch (SQLException e) {176 logger.warn("beginRequest Failed for: {}, ({})", poolEntry.connection, e.getMessage());177 }178 }179 return poolEntry.createProxyConnection(leakTaskFactory.schedule(poolEntry));❹180 }181 } while (timeout > 0L);182183 metricsTracker.recordBorrowTimeoutStats(startTime);184 throw createTimeoutException(startTime);
- 1先从 bag 借。
timeout就是connectionTimeout,默认 30 秒。 - 2借到的连接如果被标记要驱逐,或者上次用完已经超过
aliveBypassWindowMs(系统属性,默认 500 ms),就先检查一下还活不活着。500 ms 内刚用过的直接跳过检查,这又是一处用“大多数时候”换速度的地方。 - 3坏的就关掉(
remove把 1 改成 -1),用剩下的时间再借一次。 - 4包成代理连接交出去。
leakTaskFactory.schedule就是leakDetectionThreshold的计时器。
341 public void addBagItem(final int waiting)342 {343 if (waiting > addConnectionExecutor.getQueue().size())❶344 addConnectionExecutor.submit(poolEntryCreator);345 }⋮784 private synchronized boolean shouldContinueCreating() {785 return poolState == POOL_NORMAL && getTotalConnections() < config.getMaximumPoolSize() &&786 (getIdleConnections() < config.getMinimumIdle() || connectionBag.getWaitingThreadCount() > getIdleConnections());❷787 }
832 final var notInUse = connectionBag.values(STATE_NOT_IN_USE);833 var maxToRemove = notInUse.size() - config.getMinimumIdle();834 for (PoolEntry entry : notInUse) {❸835 if (maxToRemove > 0 && elapsedMillis(entry.lastAccessed, now) > idleTimeout && connectionBag.reserve(entry)) {836 closeConnection(entry, "(connection has passed idleTimeout)");837 maxToRemove--;❹838 }839 }840 logPoolState("After cleanup ");
- 1borrow 里那两处
addBagItem走到这里:等的人比排着队要建的连接多,就再提交一个建连接任务。建连接在单独的线程里做,不占借连接的线程。 - 2真要建之前再判断一次:没到上限,并且空闲的少于
minimumIdle,或者等的人比空闲的多。默认minimumIdle等于maximumPoolSize,池子一开始就是满的,这里基本都返回 false。 - 3
values(STATE_NOT_IN_USE)返回的是倒序快照:管家从货架尾部开始看。借的时候从头扫,于是前面的连接越用越热,后面的闲下来被回收,池子自然缩回去。 - 4回收前先
reserve(0 → -2),和借的人抢同一个 CAS,抢不到就跳过,见 ④ 状态图。
计数器实测:各条路到底走了多少次
把 6.3.3 的 ConcurrentBag 复制一份,每条路径加一个 LongAdder,每次借到连接后持有 1 ms(模拟一次查询),跑 5 秒。“有人在等时从货架拿到”就是源码注释说的“可能抢了别人的”;“白醒”是递交收到了、CAS 却失败的次数。
| 场景(池子 10 个) | 口袋 | 货架 | 有人在等时从货架拿到 | 递交 | 白醒 / 每次借 | 等待 P99 |
|---|---|---|---|---|---|---|
| 8 个平台线程 | 99.98% | 0.02% | 0 | 0 | 0 | ≈0 |
| 20 个平台线程,还完立刻再借 | 0.01% | 0.02% | 97.3% | 2.6% | 0.97 | 27.5 ms |
| 20 个平台线程,隔 100 µs 再借 | 0.02% | 0.04% | 14.3% | 85.7% | 0.14 | 2.8 ms |
| 200 个平台线程,立刻再借,超时 1 s | 0.03% | 0.01% | 95.7% | 3.5% | 0.96 | 467 ms |
| 虚拟线程,每个任务借 1 次 | 0% | 97.6% | 2.4% | 0 | 0 | 3 µs |
| 虚拟线程,每个任务借 3 次 | 66.7% | 32.8% | 0.5% | 0 | 0 | 3 µs |
200 线程那一行另有 0.8% 的借用超时。虚拟线程两行的并发是 8,池子够用,所以不用等;“有人在等时从货架拿到”在这里只是别的线程恰好也在扫货架,并没有真的抢谁。计数包含 1 秒预热。
版本差异 对着各版本的 sources jar 逐个 diff
| HikariCP | ConcurrentBag 的变化 | 谁带的这个版本 |
|---|---|---|
| 2.7.9 | 口袋不设上限;remove() 不清自己口袋;add() 自旋时不看 state | 老项目 |
| 3.4.5 ~ 6.2.1 | 口袋上限 50;remove() 顺手清掉自己口袋里那条;add() 自旋前先看 state 还是不是 0。核心的 borrow / requite 逻辑和今天一样 | Spring Boot 2.7 是 4.0.3(支持 JDK 8 的最后一个大版本) |
| 6.3.0 ~ 7.0.0 | 口袋上限改成 16;字段 threadList 改名 threadLocalList;unreserve() 自旋也先看 state;6.3.3 的 javadoc 标了 @hidden | Spring Boot 3.5.16 是 6.3.3 |
| 7.0.1 起 | requite() 和 unreserve() 的自旋:等的人多时更早 parkNanos | Spring Boot 4.1.2 是 7.0.2 |
Spring Boot 的对应关系取自本机 spring-boot-dependencies 3.5.16 和 4.1.2 的 pom,2.7 那一条见连接池页。从 2.7.9 到 7.0.2,“先改 state、再看 waiters”和“递交只是推荐”这两点都没变过。
再往深一点
Spring Boot 可执行 jar 里,口袋存的是 WeakReference
- 构造时判断:加载 HikariCP 的类加载器不是系统类加载器,就用
WeakReference包一层,口袋换成普通ArrayList。看判断条件,是为应用服务器准备的:那里的线程比应用活得久,线程上挂着强引用,重新部署后旧的类加载器就回收不掉。 java -jar跑 Spring Boot 可执行 jar 时,HikariCP 是由 Boot 自己的类加载器加载的,按这条判断就走 WeakReference;在 IDE 里直接跑 main 则不走。可以用-Dcom.zaxxer.hikari.useWeakReferences=true/false强制,源码注释说这是没写进文档的开关。- 读出来的一个小毛刺:WeakReference 模式下,
remove()里的threadLocalList.get().remove(bagEntry)拿 PoolEntry 去比 WeakReference,永远对不上,口袋里那条删不掉。实测确认如此。无害,下次 borrow 时 CAS 失败就扔掉了。
为什么是 Dekker 式的“先写后读”
- 还的人写 state、读 waiters;借的人写 waiters、读 state。这是两线程互斥算法里最经典的一招:只要两边的写和读都是顺序一致的(Java 里 volatile 和原子类保证这一点),就不可能两边都读到旧值。
- 反过来,如果 state 用普通字段、或者两步之间有一边先读后写,就会出现 ④ 里那种“连接空着、人在干等”。
- 这也是 requite 里
setState(0)必须在循环之前的原因,不只是“早点让别人能用”。
还连接的线程会不会一直转圈
- 只在“有人在等,但没人停在 poll 上,连接也没被拿走”时转圈。那个等的人正在扫货架,下一刻要么扫到这个连接(转圈结束),要么停到 poll 上(offer 成功)。所以通常只转几圈。
- 大量线程同时在等时,转圈的线程会频繁
yield。7.0.1 让它在等的人多时更早 park 10 µs,少抢 CPU。 - 调度实验台第 5 个剧本就是这个场景。
从头扫、从尾回收,池子会自己缩
- borrow 从货架头部扫,管家从尾部看。负载降下来后,前几个连接一直有人用,
lastAccessed一直新;后面的连接闲置超过idleTimeout就被回收,直到剩minimumIdle个。 - 前提是
minimumIdle小于maximumPoolSize。默认两者相等,管家整段跳过(源码里那个if)。
面试官会怎么问
HikariCP 为什么快?
借还的热路径上没有锁:先翻 ThreadLocal 口袋,再对 CopyOnWriteArrayList 做无锁遍历加 CAS;没有空闲时用 SynchronousQueue 直接递交。此外还有字节码生成的代理类、用 FastList 代替 ArrayList 记录打开的 Statement 等。
但要补一句:池子本身的开销在百纳秒级,数据库调用在毫秒级,业务里真正慢的通常不是池子。
借连接要不要加锁?
有空闲连接时不要,只 CAS 连接的 state。没有空闲时线程停在 SynchronousQueue.poll 上,那里会 park。CopyOnWriteArrayList 写的时候有锁,但只在加、删连接时写,不在借还路径上。
等连接是先来先得的吗?
等待者之间是:SynchronousQueue 用的是公平模式。但新来的线程扫货架时能把刚还回、正在递交途中的连接 CAS 走,被抢的人重新排到队尾。源码注释自己写了 "we may have stolen another waiter's connection"。所以整体不保证先来先得,尾延迟会比平均高很多。
requite 为什么先 setState(0) 再看 waiters?
为了不丢唤醒。借的人先加 waiters 再扫货架,还的人先改 state 再读 waiters,两边至少有一边能看到对方:要么借的人扫到 0 自己拿走,要么还的人看到有人在等去递交。反过来写,借的人可能在“还的人读完 waiters、还没改 state”那一刻扫货架,什么也没扫到就去睡了,连接却空着。
下一圈
- HikariCP 的代理层:
JavassistProxyFactory生成的ProxyConnection,关闭时怎么回滚、重置状态 SynchronousQueue和LinkedTransferQueue的实现:配对、先自旋再 park(已写成下一页)- 管家 HouseKeeper、
maxLifetime的随机抖动和keepaliveTime - 用 JMH 正经地测借还开销:多线程基准、伪共享
- Java 内存模型:volatile 的顺序一致性,从 Dekker 算法看“先写后读”
- Druid 的
DruidDataSource:一把锁加两个 Condition 的生产者和消费者
↺回到第⑤步:改 BagPeek,验证第⑥步
每个练习都写了做对时会看到什么,不用对答案也能自己判断。
练习 1 · 隔一会儿再借
在 BagPeek 第 4 段 single.requite(mine) 后面加一行 Thread.sleep(1); 再借。
做对了会看到:抢回次数从 950 上下掉到个位数(本机 4~5 次)。W 从被叫醒到 CAS 远用不了 1 ms,所以它先拿到了。
用到:坑 2,④ 递交只是推荐
练习 2 · 量出插队的尾巴
开 20 个线程循环“借 → LockSupport.parkNanos(1_000_000) → 还”,池子 10 个,记下每次 borrow 等了多久,跑 5 秒算 P50 和 P99。再在还和借之间加 100 µs 跑一次。
做对了会看到:平均都在 1 ms 左右;立刻再借时 P50 ≈ 0、P99 几十毫秒;加了间隔后 P50 ≈ 0.9 ms、P99 两三毫秒。
用到:坑 1、坑 2,④ 设计对比
练习 3 · 亲手写出被放弃的设计
把 ConcurrentBag 复制成自己的类,requite 改成:有人等就直接 offer(state 保持 1),递不出去才 setState(0) 进口袋;borrow 里 poll 到了直接返回、不再 CAS。先在实验台第 6 个剧本里找出丢唤醒的顺序,再用练习 2 的程序测。
做对了会看到:P99 掉到 1.6 ms 左右,公平了;把持有时间改成 0、开 40 线程,每秒借还从约 1000 万次掉到约 35 万次。
用到:④ 先改 state 再看 waiters、为什么这样设计
1. 用一句话说出 ConcurrentBag 的精华。
连接归谁只看它的 state,口袋、货架、递交窗口都只是找它的路;所以有空闲时借还不用锁,代价是递交途中的连接谁都能抢,不保证先来先得。
← ② 精华
2. 用一个阻塞队列做池子,痛在哪?
借和还都要过同一把锁,热路径串行;想做到公平,每次还连接都得叫醒一个睡着的线程,等的人一多吞吐就掉到几十万次每秒。
← ② 实测表
3. “平均等待不高却有人超时”,根源是 ④ 里的哪一条?
递交只是叫醒加推荐:递过去的连接在对方醒来前 state 还是 0,正在跑的线程扫货架就能拿走,被抢的人回到队尾。
← ④ 递交只是推荐
4. 预测:Tomcat 200 个线程、池子 10 个、压到满载,借连接主要走哪条路?口袋还能命中吗?
几乎不走口袋。满载时 waiters 一直大于 0,还连接时会去递交,不会放进口袋;新来借的线程主要靠扫货架,抢走正在递交途中的连接,其余靠递交。计数器实测 200 线程那一行:口袋 0.03%,有人等时从货架拿到 95.7%。
← ④ 三个地方找连接 + requite 的流程
5. 预测:管家 reserve 一个空闲连接时,正好有业务线程扫货架扫到它,会怎样?
两边抢同一个 CAS:管家是 0 → -2,业务线程是 0 → 1,只有一个成功。业务线程赢了,管家这轮跳过它;管家赢了,业务线程看到 -2,接着扫下一个。没有锁,也不会两边都以为拿到了。
← ④ 状态图
6. 预测:如果把容量为 0 的 SynchronousQueue 换成容量 10 的 ArrayBlockingQueue,让 offer 总能成功,会怎样?
功能上还对,因为最后都靠 CAS 定归属;但连接会同时躺在货架和队列里。等的人还在扫货架、没走到 poll 时,offer 也成功了,连接进了队列;等的人从货架上拿走它之后,队列里还留着一条过期引用。后来的等待者 poll 到它、CAS 失败、白醒一回,队列里过期的引用越积越多。
← ④ 递交窗口为什么容量是 0
7. 预测:一个虚拟线程处理一个请求,请求里不开事务连查 5 次,口袋命中率大概多少?
约 80%。第一次口袋是空的,要扫货架;后 4 次借的是刚还进自己口袋的那个,只要没人在等就命中。实测每任务借 3 次时是 66.7%,正好是 2/3。
← ④ 口袋按线程分 + 坑 5