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 个连接,这两步每秒要被调用上万次,而且是很多线程同时调。

Tomcat 工作线程 ×200 每个请求:getConnection() 借      close() 还 借 borrow 还 requite HikariPool 里的 ConcurrentBag 10 个连接(PoolEntry) 每个都是一条 TCP 连接 + 一个 state MySQL / PostgreSQL 一次查询:毫秒级 有空闲:要快这条路每秒走上万次 没空闲:能等、能超时connectionTimeout 默认 30 s 还回来:叫醒等的人别让连接闲着、人干等 管家回收时不撞车空闲超时、保活检查
ConcurrentBag 是 HikariPool 存放连接的容器,借(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);                       // 拿锁 → 放 → 唤醒一个 → 放锁
}
借的、还的,全挤在一把锁前 借 还 借 借 还 锁 队列 c0 c1 c2… 同一时刻只有一个线程在锁里。 核越多、借还越频繁,排在锁前的人越多。 想要先来先得,还连接时就得叫醒一个 睡着的线程,每次都是一次线程切换。
实测只测借还本身:拿到连接马上还,每秒能借还多少次
线程数(池子 10 个)141040200
HikariCP ConcurrentBag800 万1294 万1291 万1014 万869 万
ArrayBlockingQueue(一把非公平锁)927 万790 万847 万761 万676 万
synchronized + wait/notify571 万245 万209 万214 万299 万
一把锁 + 严格先来先得递交912 万378 万278 万41 万27 万

单位:次/秒。最后一行是我按 Tomcat JDBC FairBlockingQueue 的思路写的:还回来的连接直接塞给排在最前面的人。40 线程那一列跑了 3 次取中位数,其余各 1 次。这台机器只有 2 核,同一时刻真正在跑的只有 2 个线程,所以“一把锁”只慢了两到四成;核更多时差距会更大,这里测不出来,不给数字。

痛 1热路径上人人过锁

单线程时几种写法一次借还都在 110~175 ns。一有并发,锁的写法就掉下来,因为借和还都要串行地过同一个临界区。

痛 2要公平就要付线程切换的钱

严格先来先得,意味着还连接时必须把它交到一个正在睡觉的线程手里。对方醒来要几微秒,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.55FairBlockingQueue:一个 LinkedList + 一个等待者链表要,一把锁排进等待者链表,还连接时直接塞给链表头fairQueue 默认 true,是
Druid 1.2.28一个数组当栈用,从尾巴取(LIFO)要,一把 ReentrantLock(默认构造是非公平锁)在 notEmpty 条件上 await不保证
HikariCP 6.3.3货架 CopyOnWriteArrayList + 每线程一个口袋有空闲时不要,只 CAS 一个 state停在 SynchronousQueue(true).poll 上等人递等的人之间是,但新来的人能插队
要严格先来先得

比如批处理里不能容忍个别请求等很久:Tomcat JDBC 的 fairQueue 是现成的。

要 SQL 监控、防火墙

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,什么也不存。

线程 A 口袋:[c0] 线程 B 口袋:[c1] 线程 W 口袋空,货架上也没有空闲的 货架 sharedList CopyOnWriteArrayList<PoolEntry> c00 c11 c21 c31 所有连接都在这里,按加入的先后排 借的时候从头扫,CAS 第一个 state = 0 的 (c1 在 B 的口袋里,但现在被别人借着) 递交窗口 handoffQueue SynchronousQueue<>(true),容量 0 W 停在 poll 上 还连接的人看到 waiters > 0, 就 offer 给最早停下的那位; 没人在 poll,offer 直接失败 1 2 3 实在没有:waiters+1,停在窗口上等人递 归属只看 state: 对 c0 做 compareAndSet(0, 1) 成功的那个线程,c0 才是它的。口袋、货架、窗口里放的都只是引用。
borrow() 按 1 → 2 → 3 的顺序找:先翻自己的口袋(ThreadLocal 里的列表,最多记 16 个),再扫货架,最后停在递交窗口上等。图里 A 的口袋记着 c0,c0 正空着,A 借的时候 1 就命中,连货架都不用看。
NOT_IN_USE0 · 空闲 IN_USE1 · 借出 RESERVED-2 · 管家占住 REMOVED-1 · 下架 borrow:CAS 0→1 requite:setState(0) reserve:CAS 0→-2 unreserve:-2→0 remove:CAS 1→-1 remove:CAS -2→-1 四个常量写在接口 IConcurrentBagEntry 里,PoolEntry 用 AtomicIntegerFieldUpdater 改它的 volatile int state requite 直接 setState(0)(此时只有借的人自己拿着它),其余转换都是 CAS,谁先成功算谁的
管家(HouseKeeper)要关掉一个闲太久的连接,先 reserve 把 0 改成 -2,和借的人抢同一个 CAS:业务线程抢到了,管家就跳过它;管家抢到了,业务线程扫货架时看到 -2,接着扫下一个。两边都不用锁,也不会撞车。

一次完整的借还 池子里只有 1 个连接,A 借两次,B 来晚了要等

第 0 / 10 步

开始:货架上只有 c0,两个线程的口袋都是空的。点“下一步”。

递交只是“叫醒 + 推荐”,不是“交到手里”

线程 A 线程 W setState(0) offer(c0) 成功 马上又借:扫货架 CAS 成功 停在 poll 上睡着 被唤醒,等 CPU 调度(几微秒) CAS 失败 重新 poll 这一段里 c0 的 state 一直是 0:货架上谁扫到,谁就能 CAS 拿走 实测:池子 1 个连接、W 在等,A 还完立刻再借,1000 次里 A 抢回 949~981 次(⑤ 第 4 段)。中间隔 1 ms 再借,只剩 4~5 次。
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 之后再查一遍、再抢回来递,代码更复杂
实测20 个线程抢 10 个连接,每次持有 1 ms,还完马上又借:每次借连接等了多久
≤1 µs10 µs100 µs1.0 ms10 ms100 ms1.00 sP50≈01.1 msP90≈01.1 msP9928 ms1.6 msP99.9185 ms2.7 ms最长532 ms4.1 msHikariCP 原版(谁先扫到谁拿)改成独占递交(先来先得)平均都是 1.05~1.09 ms
横轴是对数刻度。平均等待两边一样(利特尔法则管着:总吞吐一样,平均就一样),差别全在分布上。HikariCP 约 97% 的借用等了不到 20 µs,因为都是还完立刻扫货架抢回来的;剩下的人被一再插队,3 次运行里最长等了 0.45~0.62 秒。独占递交每个人都老老实实等 1 ms 左右。各跑了 3 次,HikariCP 的 P99 在 26.7~27.5 ms 之间,独占递交在 1.2~1.6 ms 之间。
实测40 个线程、10 个连接、拿到马上还:每秒能借还多少次
0200 万400 万600 万800 万1000 万HikariCP ConcurrentBag1014 万次/秒ArrayBlockingQueue(一把锁)761 万次/秒synchronized + wait/notify214 万次/秒一把锁 + 严格先来先得递交41 万次/秒ConcurrentBag 改成独占递交34.5 万次/秒
只测借还本身的开销,3 次取中位数。一旦开始有人排队,严格递交的写法每次都要等对方醒来,吞吐掉到原来的 3%~4%;HikariCP 让正在跑的线程直接拿走,等待者白醒一回,整体不停。

决定胜负的约束:叫醒一个睡着的线程要微秒级,正在 CPU 上的线程 CAS 拿走只要几十纳秒;而且一个配得合理的连接池,大部分时候根本不用等。HikariCP 因此选了吞吐和简单,把严格公平让了出去。代价落在“有人在等”的那一小段时间里:等待者的尾延迟。

货架为什么用 CopyOnWriteArrayList

借连接时要扫,每秒上万次;加、删连接只发生在建新连接和回收时,几秒一次。扫的时候不加锁,拿的是当时数组的快照;改的时候复制一份,慢一点也无所谓。

为什么要每线程一个口袋

同一个线程还完再借,大概率拿回刚还的那个。口袋命中时,整个 borrow 只碰两样东西:自己线程的列表、这个连接的 state,连 waiters 计数都不改。

递交窗口为什么容量是 0

只有真有人停在 poll 上,offer 才会成功。没人等就不存,连接不会同时躺在货架和另一个队列里,也就不会有人从队列里捞到一个早被别人拿走的连接。true 表示等的人之间按先后来。

最少的 API:HikariPool 只用到这几个方法

APIHikariPool.java 里对 connectionBag 的调用(6.3.3)
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 的小对象放进去,就能看到口袋、偷连接、递交、插队这四件事。

1依赖只要 HikariCP 和它依赖的 slf4j-api,用 Maven 就是一行
<dependency><groupId>com.zaxxer</groupId><artifactId>HikariCP</artifactId><version>6.3.3</version></dependency>
2BagPeek.java单文件,JDK 11+ 可以直接 java BagPeek.java 运行
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");
  }
}
3运行jar 在 ~/.m2/repository/com/zaxxer/HikariCP/6.3.3/ 和 org/slf4j/slf4j-api/ 下
$ 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 次递交只是叫醒 + 推荐
到这里已经能交差了:面试被问“HikariCP 借连接怎么做的、公平吗”,可以拿这四段输出说清楚;排查“平均不高却有人等超时”时,也知道该怀疑插队。
已知还没处理的细节
  • 插队在真实池子里有多严重、什么写法会放大它:⑥ 坑 1、坑 2
  • 一个线程同时要两个连接会怎样:⑥ 坑 3
  • 虚拟线程下口袋还管不管用:⑥ 坑 5
  • 源码逐行、口袋上限、WeakReference、各版本改动:⑥ 第三圈

⑥细节:从会踩的坑,到一行行源码

按圈往里走:第一圈是不知道就会出事的坑,第二圈是团队里该怎么写,第三圈读源码、自己当一回线程调度器、看各版本改了什么。

第一圈

注意事项

坑 1平均等待不高,却总有几个请求等到超时← ④ 递交只是推荐
现象
200 个线程抢 10 个连接、每次持有 1 ms,connectionTimeout 设 1 s:6 秒里 HikariCP 超时 457 次,P99 等了 467 ms;同样负载下严格先来先得 0 次超时,最长 31 ms。实测
根源
递过去的连接在对方醒来前 state 还是 0,正在跑的线程扫货架就拿走了。被抢的人回去重新 poll,排到最后,再被抢,再排到最后。
做法
让“等连接”很少发生:按并发和持有时间算池子、缩短持有时间;connectionTimeout 按接口的延迟预算设;盯 hikaricp.connections.timeout 和 acquire 的最大值,不只看平均。
坑 2不开事务的循环查询,一路插队← ④ 递交只是推荐
现象
不在事务里时,JdbcTemplate 每次调用、MyBatis 每次 Mapper 调用都借一次、还一次。循环 100 次就是“还完马上又借”100 次。20 线程实测:立刻再借时 97% 的借用是插队拿到的,P99 27.5 ms;隔 100 µs 再借,插队降到 14%,P99 2.8 ms。实测
根源
还的人正在 CPU 上,被叫醒的人还在路上,还的人扫货架总是更快。
做法
一次借、做完再还:把循环放进一个事务,或用 ConnectionCallback;能合并就改成一条 IN 查询或批量语句。
坑 3一个线程同时要两个连接,池子满时互相卡死← ④ 归属只看 state
现象
外层事务拿着一个连接,里面又 REQUIRES_NEW、或者查另一个用同一个池子的 DAO。10 个线程各拿 1 个、各等第 2 个,10 个连接全被“等别人”的人占着,全部等到 connectionTimeout。
根源
借出去的连接只有借的人能改回 0,池子不会从谁手里收回来;borrow 没有空闲的就只能等。
做法
别让一个线程同时持有两个连接。实在要,池子至少开到 HikariCP wiki《About Pool Sizing》给的 Tn × (Cm − 1) + 1(Tn 是线程数,Cm 是一个线程同时最多拿几个)。
坑 4借了不还,连接永远是 IN_USE← ④ 归属只看 state
现象
忘了 close(),或异常路径没关。active 只涨不跌,最后人人等超时。类注释原话:borrowed but never "requited" will result in a memory leak。
根源
借出时连接没从货架上拿下来,只是 state 变成 1。没有人调用 requite,它就一直是 1。
做法
一律 try-with-resources,或交给 JdbcTemplate / MyBatis 管。测试环境开 leakDetectionThreshold(默认 0,即关闭),它只打一条带堆栈的警告,不会帮你收回连接。
坑 5换成虚拟线程,口袋几乎不再命中← ④ 口袋按线程分
现象
每个任务一条新虚拟线程、只借一次:口袋命中 0%,全靠扫货架。一个任务里借 3 次:后两次能命中,约 67%。实测
根源
口袋是 ThreadLocal,新线程的口袋是空的,而且每条借过连接的虚拟线程都会建一个 16 格的列表,线程结束就成了垃圾。
做法
不用改配置,功能是对的,只是少了最快那条路。6.3.3 和 7.0.2 源码里都没有针对虚拟线程的分支。另外,等待会从 Tomcat 线程池挪到连接池里,pending 会变大,见上一页。
坑 6看指标时误读 pending 和 idle← ④ API 和指标
现象
不缺连接时 pending 偶尔也是 1;压测时 idle 显示有空闲,却有人在等。
根源
pending 就是 waiters:口袋没命中的线程扫货架之前就 +1 了。idle 数的是 state = 0 的连接,递交途中的那个也算。指标值由 PoolStats 缓存,最多 1 秒刷新一次。
做法
看 pending 的趋势和持续时间,配合 timeout 计数和 acquire 的最大值判断,不要拿某一秒的瞬时值下结论。
第二圈

使用规范

借连接一律 try-with-resources,或交给框架

手写 JDBC 时,Connection、Statement、ResultSet 都放进 try (...)。

连接永远停在 state = 1(坑 4)

拿着连接时不做远程调用、不等锁、不睡

调接口、发 MQ、等分布式锁,都放到事务外面。

持有时间变长 → 等待变常态 → 插队和超时被放大(坑 1)

循环查询合并成一次借,或改成批量

放进同一个事务,或者用 IN、batchUpdate。

一路“还完马上又借”插到别人前面(坑 2)

一个线程不同时拿两个连接,REQUIRES_NEW 慎用

非用不可时,按 Tn × (Cm − 1) + 1 开池子,并压测验证。

池子满时互相卡死(坑 3)

告警看 timeout 次数和 acquire 最大值

hikaricp.connections.timeout、hikaricp.connections.acquire 的 max,加上 pending 持续多久。

平均值正常,掩盖了被反复插队的少数请求(坑 1、坑 6)

业务代码别直接用 ConcurrentBag

6.3.3 的 javadoc 已标 @hidden;6.3.0 还把字段 threadList 改名成了 threadLocalList。本页 ⑤ 直接用它,只是为了观察。

升级 HikariCP 时编译不过或反射失效

第三圈

读源码:borrow 和 requite 一行行看

1先找调用方,再看被调用的

先在 HikariPool 里搜 connectionBag.,只有 ④ 末尾那几处。知道谁在什么时候调,读 ConcurrentBag 时才知道每个方法在防什么。

2先看状态,再看流程

四个常量 0、1、-1、-2 和谁能改它,就是这个类的全部“不变式”。流程里每一个 CAS,都是在维护这张状态图。

3复制出来加计数器,信代码不信注释

把类复制一份、每条路径加一个 LongAdder,跑一遍就知道哪条路最常走,下面的“计数器实测”就是这么来的。类注释从 2.7.9 到 7.0.2 都还写着用 AbstractQueuedLongSynchronizer 发信号,代码里用的是 SynchronousQueue。

borrowConcurrentBag.javaHikariCP 6.3.3,行号和源码一致(Apache License 2.0,© Brett Wooldridge)
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。
requiteConcurrentBag.java还连接:Connection.close() → PoolEntry.recycle() → HikariPool.recycle() → 这里
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 里谁先跑由调度器决定,这里由你决定:点“按剧本走一步”看设计好的交错,也可以直接点某个线程的“走一步”,试试别的顺序。日志里的行号对应上面的源码。

先在上面选一个猜测,再开始点。

池子那一侧:借到之后、等的时候、闲下来时

getConnectionHikariPool.java6.3.3
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 的计时器。
补连接addBagItem / shouldContinueCreating
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      }
管家HouseKeeper 回收空闲连接
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。
  • 3values(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%000≈0
20 个平台线程,还完立刻再借0.01%0.02%97.3%2.6%0.9727.5 ms
20 个平台线程,隔 100 µs 再借0.02%0.04%14.3%85.7%0.142.8 ms
200 个平台线程,立刻再借,超时 1 s0.03%0.01%95.7%3.5%0.96467 ms
虚拟线程,每个任务借 1 次0%97.6%2.4%003 µs
虚拟线程,每个任务借 3 次66.7%32.8%0.5%003 µs

200 线程那一行另有 0.8% 的借用超时。虚拟线程两行的并发是 8,池子够用,所以不用等;“有人在等时从货架拿到”在这里只是别的线程恰好也在扫货架,并没有真的抢谁。计数包含 1 秒预热。

版本差异 对着各版本的 sources jar 逐个 diff

HikariCPConcurrentBag 的变化谁带的这个版本
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 标了 @hiddenSpring Boot 3.5.16 是 6.3.3
7.0.1 起requite() 和 unreserve() 的自旋:等的人多时更早 parkNanosSpring 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