对象池把「分配」换成了「借还」。分配走线程私有的 TLAB,借还抢所有线程共用的队列。
收益只能来自免掉构造与初始化,代价是同步。构造越贵,这笔交换越划算;构造越便宜,省下的越少,而同步开销照付。
三种构造成本(1 KiB 数组、DirectByteBuffer(1 KiB)SecureRandom 初始化)乘三种取法(每次 new、ThreadLocal 池、ReentrantLock 池),在 1、8、16 线程下各计时 1 秒,JDK 21 与 JDK 25 都跑,找出池化从赚到亏的那条线。

一、池化把什么换成了什么

new 是一条线程私有路径。HotSpot 给每个线程一块 TLAB,分配对象就是把指针往前挪一段,再零初始化。

池化把这条路径改成:从共享队列取一个对象、重置它的状态、用完还回去。取和还都要同步。

两种路径随线程数的走势是反的。分配的成本是常数:TLAB 是线程私有的,16 个线程同时分配与 1 个线程分配,每个线程付的价钱一样。借还的成本随线程数上升:同一个队列、同一把锁,线程越多,等在锁上的时间越长。

划不划算,写成一条不等式:C_new 是分配加初始化的成本,C_borrow(n) 是 n 个线程下每次借还的成本,C_reset 是重置成本:

1
C_new > C_borrow(n) + C_reset

右边随 n 增长,左边不随 n 变化。容易看错的是 C_new:它看着像一次指针加法,很多真实对象的构造过程却在抢全局资源。DirectByteBuffer 的构造函数里有三个全局原子操作和一次 static synchronized 调用,这类对象的分配成本不随核数下降。

二、实验设计与局限

三种取法

策略 取对象 还对象 同步
每次 new new X() 交给 GC
ThreadLocal 池 ThreadLocal.get() 重置后留在本线程 无锁,一次 map 查找
有锁池 ReentrantLock 保护的 ArrayDeque.poll() 入队 每次借还各抢一次锁

ThreadLocal 池是零同步基线:对象放在线程自己身上,用来量免掉分配与重置值多少钱。有锁池就是通常说的「对象池」,容量上限 64,队空时补一个 new。两个池都在归还前调用对象的 reset()

三种构造成本

对象 构造做什么 使用做什么
Cheap new byte[1024] 写两个字节、读回来
ExpDirect ByteBuffer.allocateDirect(1024) 往 native 内存写一个 long、读回来
ExpRng new SecureRandom() 取种子构造 Random random.nextLong()

ExpRng 把成本放在构造上、把便宜留在使用上,用来隔离构造成本这一个变量。

度量口径

  • 吞吐:工作线程在 1 秒窗口内完成的 op 数除以墙钟。
  • 分配字节:com.sun.management.ThreadMXBean.getCurrentThreadAllocatedBytes() 在窗口前后的差,只算堆。
  • GC:GarbageCollectorMXBeancollectionCountcollectionTime 在窗口前后的差,默认 G1。
  • 每个配置先跑 400 ms 预热,再用同一份代码路径计时 1 000 ms。
  • 环境:Apple M1 Pro(10 核,8 性能核 + 2 能效核),macOS 26.6.2,JDK 21.0.8 与 JDK 25,-Xms512m -Xmx512m -XX:MaxDirectMemorySize=4g -XX:+UseG1GC

另一条独立的测量:单线程探针

除了并发基准,另有一个单线程探针,它只回答一个问题:这些对象构造一次要分配多少字节。按 20 000 次一组统计,只数 ThreadMXBean 的分配字节数,不受 JIT 优化影响(这点用 -Xint 验证过,数值不变):

构造一次 JDK 21 JDK 25
new SecureRandom() 2 025 B 370 B
SecureRandom.getInstance("NativePRNG") 2 016 B 360 B
SecureRandom.getInstance("DRBG") 737 B 634 B
new java.util.Random() 56 B 56 B
Provider.getService("SecureRandom", "NativePRNG") 0 B 0 B

探针同时测了耗时,但它的循环迭代次数不足以让这些方法走到 C2 编译,时间数字偏保守。引用的耗时全部来自基准那一列,探针只用来做分配量的横向比较。

局限

计时循环在全局锁内单独运行,同一时刻没有别的计时任务在抢 CPU,机器上仍有其它非计时负载。每个配置只跑一次,读的是量级。16 线程在 10 核上属于超订,8 线程那一档才对应性能核的数量。另外只测了「借出、使用、归还」这个内循环,容量管理、失效检测、空闲超时回收都不在内,加上它们只会让池更贵。

一个测量坑:出口放在共享缓存行上

为了让 JIT 不能把分配优化掉,循环里必须让对象真的逃逸。第一轮我用一个共享的 static volatile 字段装它。8 线程以上,这条缓存行成了所有线程的共同瓶颈:ThreadLocal 池的吞吐从单线程的 212 M op/s 掉到 95 M,看起来像「无锁池也不扩展」。

把出口改成每线程独占的槽位(SLOTS[i * 64],间隔 512 字节)之后,同一份代码在 8 线程下跑到 894 M op/s。下面所有数字都来自改好的这一版,第一轮的原始记录留在日志里。测量钩子自己会变成瓶颈,而且只在并发那一档露出来。

三、便宜对象:1 KiB 数组

策略 线程 JDK 21 JDK 25 每 op 分配字节 GC 次数(JDK 25,1 s 窗口)
每次 new 1 10.0 M op/s 9.5 M op/s 1,064 32
每次 new 8 42.0 M op/s 44.0 M op/s 1,064 147
每次 new 16 43.7 M op/s 44.4 M op/s 1,064 149
ThreadLocal 池 1 185.4 M op/s 212.0 M op/s 0 0
ThreadLocal 池 8 899.6 M op/s 894.2 M op/s 0 0
ThreadLocal 池 16 886.6 M op/s 941.1 M op/s 0 0
有锁池 1 38.5 M op/s 38.7 M op/s 0 0
有锁池 8 25.6 M op/s 24.7 M op/s 0 0
有锁池 16 24.1 M op/s 24.4 M op/s 0 0

三种取法在 1/8/16 线程下的吞吐与 GC 次数

单线程那三行:new 9.5 M op/s(每次 105.7 ns,1 秒里触发 32 次 GC),有锁池 38.7 M(25.9 ns),ThreadLocal 池 212 M(4.7 ns)。池化赢 4 倍。一个 1 KiB 数组的分配、清零加上随之而来的 GC,单价 105 ns;一次没有竞争的 ReentrantLock 借还只要 26 ns。这三行里,用同步成本换分配成本合算。

8 线程那三行反转:new 44.0 M,有锁池 24.7 M,new 快 1.8 倍。16 线程是 44.4 M 对 24.4 M,还是 new 快。

有锁池的吞吐几乎不随线程数变化,1 → 8 → 16 是 38.7 M、24.7 M、24.4 M,加线程反而慢。它每秒能完成的借还有上限,约 2500 万次,因为所有线程排在同一条临界区上。把窗口摊到每个线程:16 线程时一次借还的墙钟代价是 655 ns,单线程时是 25.9 ns,多出来的约 630 ns 全在等锁。

new 那一侧是另一个形状:9.5 M → 44.0 M → 44.4 M。前一段随核数扩展,8 线程之后撞上内存带宽:44.4 M op/s 乘以 1064 字节是 47 GB/s 的分配速率,1 秒触发 149 次 GC,光 GC 就占掉 87 ms。分配能并行,回收不能。

ThreadLocal 池给出免掉分配与重置的上界:212 M、894 M、941 M。它不分配、不抢锁,只付一次 map 查找,代价是每个线程按一个实例的内存。

GC 次数是这一节最干净的证据:new 在 1 秒窗口里触发 32、147、149 次 GC,两个池全是 0。分配字节一栏,new 每次 1 064 字节,两个池是 0。

把总吞吐摊到每个线程

策略 1 线程 8 线程(每线程) 16 线程(每线程) 总量 1 → 16 线程
每次 new 9.5 M 5.5 M 2.8 M 涨 4.7 倍
ThreadLocal 池 212.0 M 111.8 M 58.8 M 涨 4.4 倍
有锁池 38.7 M 3.1 M 1.5 M 降 37%

三种取法的每线程吞吐都在掉,掉的原因不一样。

new 的每线程从 9.5 M 掉到 2.8 M,总量却涨了 4.7 倍。分配与清零是并行工作,8 个性能核各干各的,开销落在共享的内存总线上;到 8 线程时总量已经撞顶,再加线程只是把同一块带宽分得更细。

ThreadLocal 池从 212 M 掉到 58.8 M,总量涨 4.4 倍后停在 900 M 附近,同样的形状,只是天花板高得多,因为它不产生垃圾,只读写线程自己的那 1 KiB。

有锁池的每线程从 38.7 M 掉到 1.5 M,总量也在降。它的天花板是临界区:所有线程串在同一条队列上,线程数只是把固定的一块吞吐切得更碎,再加上争用带来的额外开销,总量比单线程还低。池的吞吐是一条水平线,new 是一条爬升后走平的线,两条线的交点就是该不该池化的分界。

为什么 1 KiB 这么便宜的对象也能赢 4 倍

单线程下 new 每次 105.7 ns,拆开是分配、清零、以及 32 次 GC 分摊下来的 18 ms。一次没有竞争的 ReentrantLock 借还 25.9 ns。整数缓存那个量级(几十字节的对象、TLAB 里挪指针)不值得池化,但 1 KiB 的零初始化已经是一块真实的内存写入,再加上随之而来的 GC 扫描与复制,单价就上了 100 ns。分配、清零、回收,三笔加起来才是池化要抵掉的开销。

一个 100 ns 级构造的对象,池化只在 1 到 4 线程之间划算,交叉点在 2 到 4 线程:1 线程是 38.7 对 9.5,8 线程是 24.7 对 44.0。

四、贵对象:DirectByteBuffer(1 KiB)

策略 线程 JDK 21 JDK 25 每 op 分配字节 GC 次数 / GC 毫秒(JDK 25)
每次 new 1 1.4 M op/s 1.3 M op/s 152 2 / 614 ms
每次 new 8 1.9 M op/s 1.5 M op/s 152 8 / 536 ms
每次 new 16 1.7 M op/s 2.3 M op/s 152 5 / 371 ms
ThreadLocal 池 1 145.5 M op/s 132.4 M op/s 0 0 / 0 ms
ThreadLocal 池 8 719.5 M op/s 601.9 M op/s 0 0 / 0 ms
ThreadLocal 池 16 558.3 M op/s 635.6 M op/s 0 3 / 143 ms
有锁池 1 38.9 M op/s 39.0 M op/s 0 0 / 0 ms
有锁池 8 24.4 M op/s 24.2 M op/s 0 0 / 0 ms
有锁池 16 22.7 M op/s 24.7 M op/s 0 0 / 0 ms

new ByteBuffer.allocateDirect(1024) 这一行没有扩展性:1 线程 1.30 M op/s,8 线程 1.49 M,16 线程 2.29 M,单次 436 到 769 ns。同期有锁池稳定在 22 到 39 M op/s,16 线程时快 10.8 倍。16 个线程一起跑 new,每个线程每秒只能完成 14.3 万次,单次摊到 7.0 µs;单线程时是 769 ns。线程越多,每次分配越慢,这是抢全局资源的形状。

代价不全在堆上。每 op 只分配 152 字节堆内存,1 KiB 在 native 那边。看 GC 时间:JDK 25 的三个窗口里分别有 614、536、371 ms 花在 GC 上。对照第三节,1 KiB 数组每 op 分配 1064 字节,一次 young GC 只要 0.6 ms(149 次合计 87 ms)。这一组每 op 的堆分配少 7 倍,单次 young GC 却要 74 ms(5 次合计 371 ms)。差在引用处理:每个 direct buffer 挂着一个 Cleaner 幻象引用,清理要逐条走引用处理器并释放 native 内存。

构造函数里的四步

java.nio.DirectByteBuffer 只有这一个入口,JDK 25 DirectByteBuffer.java:102-137

1
2
3
4
5
6
7
8
9
10
11
12
13
DirectByteBuffer(int cap) {                   // package-private
...
Bits.reserveMemory(size, cap);
long base = 0;
try {
base = UNSAFE.allocateMemory(size);
} catch (OutOfMemoryError x) {
Bits.unreserveMemory(size, cap);
throw x;
}
UNSAFE.setMemory(base, size, (byte) 0);
...
cleaner = Cleaner.create(this, new Deallocator(base, size, cap));

四步里两步是全局串行的。

Bits.reserveMemory 是记账,实现在 java.nio.Bits.java:188-203,一个 CAS 循环抢三个全局原子量:

1
2
3
4
5
6
7
8
9
10
11
private static boolean tryReserveMemory(long size, long cap) {
long totalCap;
while (cap <= MAX_MEMORY - (totalCap = TOTAL_CAPACITY.get())) {
if (TOTAL_CAPACITY.compareAndSet(totalCap, totalCap + cap)) {
RESERVED_MEMORY.addAndGet(size);
COUNT.incrementAndGet();
return true;
}
}
return false;
}

UNSAFE.allocateMemory 走底层分配器,UNSAFE.setMemory(base, size, (byte) 0) 把这块内存清零,1 KiB 就是 1 KiB 的内存写。最后一步 Cleaner.create 落到 jdk/internal/ref/Cleaner.java:76

1
private static synchronized Cleaner add(Cleaner cl) {

每个 direct buffer 的分配都要从这把监视器上过一遍。释放一侧同样:DirectByteBuffer.java:80-82Deallocator.run() 调用 Cleaner.removeCleaner.java:85 上写着 private static synchronized boolean remove(Cleaner cl)。执行这段代码的是引用处理器线程。

堆上每 op 152 字节、native 每 op 1 KiB,加上两次全局监视器和三个全局原子操作,所以这条路径不随核数扩展。池化在这里做的事是把「每次分配都过一遍全局资源」降成「每次借还抢一次锁」,后者的临界区只有队列操作加一次状态重置。

配额抢不到时的慢路径

Bits.reserveMemory 还有一条退避路径,Bits.java:109-171。抢不到配额时它先等引用处理,再强制一次 GC,然后按 1、2、4 一直到 256 ms 的间隔重试,最多 9 次(MAX_SLEEPS = 9),约 0.5 秒后抛 OutOfMemoryError

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
            // trigger VM's Reference processing
System.gc();
...
long sleepTime = 1;
int sleeps = 0;
while (true) {
if (tryReserveMemory(size, cap)) {
return;
}
if (sleeps >= MAX_SLEEPS) {
break;
}
try {
if (!jlra.waitForReferenceProcessing()) {
Thread.sleep(sleepTime);
sleepTime <<= 1;
sleeps++;

这条路只在 direct 内存接近上限时才会走到,本次实验把上限放宽到 4 GB,没有触发。池化在 direct buffer 上因此还有第二笔收益:池里的对象数固定,reserveMemory 的调用次数从「每次分配」降成「池容量」那么多,分配密集的时段不会把进程顶到 GC 加退避重试那一侧。

五、初始化最贵的一档:SecureRandom

策略 线程 JDK 21 JDK 25 每 op 分配字节 GC 次数(JDK 25)
每次 new 1 0.8 M op/s 4.6 M op/s 408 6
每次 new 8 1.3 M op/s 2.8 M op/s 408 3
每次 new 16 1.2 M op/s 2.7 M op/s 408 3
ThreadLocal 池 1 62.4 M op/s 62.1 M op/s 0 0
ThreadLocal 池 8 376.0 M op/s 409.2 M op/s 0 0
ThreadLocal 池 16 405.1 M op/s 408.9 M op/s 0 0
有锁池 1 31.7 M op/s 31.0 M op/s 0 0
有锁池 8 20.4 M op/s 20.7 M op/s 0 0
有锁池 16 16.5 M op/s 19.9 M op/s 0 0

这一组里池化赢得最多,也暴露出一个跨版本的坑。

JDK 25 上 new 每次 219 到 365 ns,池化后 19.9 M op/s,快 7.3 倍。JDK 21 上同一次 new 要 846 到 1198 ns,池化快 13.9 倍。同一个算法,两个 LTS 差 5 倍。

查过三件事。默认算法:两个版本都是 NativePRNGSecureRandom.getAlgorithm() 直接打印)。provider service 的实现类:两个版本都是 sun.security.provider.NativePRNG。JIT 有没有把 SecureRandom 消掉:加 -Xint 后分配量不变,JDK 21 每次仍是 2025 字节,JDK 25 仍是 370 字节,所以差异在执行路径里,不在编译器优化里。

再往下拆:SecureRandom.getInstance("NativePRNG") 单独测也是 2016 字节对 360 字节,同样的差距;而 provider 的服务查找 Provider.getService("SecureRandom", "NativePRNG") 两个版本上都分配 0 字节。同样的写法换成 SecureRandom.getInstance("DRBG"),两个版本是 737 与 634 字节,接近。差距只出现在 NativePRNG 这条线上(JDK 25 把 SecureRandomSpi 的构造函数改成接收 SecureRandomParameters,这是两版源码里可见的变化之一),具体是哪一层,本次没有定位到源码行。

不要假设同一个类在两个 LTS 之间的构造成本不变new 那一列的 5 倍差距就出在这里,它会直接改变池化划算与否的判断。用池化的代码通常会把「构造贵」写进设计说明,而构造成本这个前提会随版本漂移。

池化还吃掉了版本差异:JDK 21 与 JDK 25 的有锁池吞吐几乎一样(16.5 M 对 19.9 M),ThreadLocal 池也一样(405.1 M 对 408.9 M)。一旦构造只发生在池的填充阶段,new 那 5 倍的差距就从热路径上消失,剩下的成本只有借还与使用。

六、边界在哪

把三组的构造成本与借还成本并排放:

对象 new 单线程 new 16 线程 有锁池 1 线程 有锁池 16 线程 16 线程谁赢
1 KiB 数组 105.7 ns 22.5 ns 25.9 ns 41.0 ns new 快 1.8 倍
DirectByteBuffer(1 KiB) 768.7 ns 435.9 ns 25.7 ns 40.5 ns 池快 10.8 倍
SecureRandom(JDK 25) 219.1 ns 364.5 ns 32.3 ns 50.3 ns 池快 7.3 倍
SecureRandom(JDK 21) 1197.8 ns 846.7 ns 31.5 ns 60.8 ns 池快 13.9 倍

借还成本这一侧几乎不随对象变化,那是锁的价格:单线程 26 到 32 ns,16 线程 40 到 61 ns。它只随线程数变,涨得不快:临界区太短,争用大多消耗在排队上。

构造成本这一侧跨了近两个数量级(22 ns 到 1200 ns),池化的收益跟着它走。

边界因此由两件事决定:

  1. 构造贵到几百纳秒以上,池化就赚。 DirectByteBufferSecureRandom 都在这一侧,而且它们的 new 路径本身不扩展,线程越多亏得越狠。
  2. 构造在 100 ns 级,池化换来的是同步成本。 1 KiB 数组这一组的交叉点在 2 到 4 线程之间,之后每加一个线程都在给锁还债。

反过来看同一份代码:new 在 1 线程下比池慢 4 倍,在 16 线程下比池快 1.8 倍。脱离线程数谈谁快谁慢没有意义。

池化没测到的三笔账

上面的数字只覆盖「借出、使用、归还」这个内循环。真实的池还有三笔支出,它们不在 ns/op 里,但会决定池能不能用。

容量。 容量定小了,队空时补 new,池退化成「每次分配,外加两次加锁」,比不池化更慢。定大了,空闲对象在堆上驻留,被 GC 反复扫过,还可能被晋升到老年代:一个 64 个实例、每个 1 KiB 的池是 64 KiB,看着不多;1 000 个连接的连接池按每连接几 MB 的驱动缓冲算,就是几 GB。容量应当由外部资源的上限推导(并发请求数、连接数、MaxDirectMemorySize),不能靠调优时试出来。

泄漏。 借了不还,池会一路补 new 补到上限,然后永久阻塞或者退化。JVM 不会报错,只会看到吞吐下降到某个水平后不再恢复。有锁池的公平性设置还会放大这个问题:new ReentrantLock(true) 在争用时按队列顺序放行,代价是吞吐再降一截,换来的是不会有线程被无限插队。

脏状态。 归还时漏掉一个字段,下一个借用者读到的就是上一个用户的残留。这类 bug 在单线程测试里不出现,在生产上表现为随机错误。防守办法是把重置放在构造里(每次借出时重置),代价是每 op 多一次重置;或者让对象不可变,那就回到了享元。共享不可变的做法见《享元:共享不可变状态》

三笔账都不进计时器,但都会把「池化在 100 ns 级对象上净亏 1.8 倍」这种结论进一步推向同一侧。

七、JDK 自己怎么做池

JDK 里有现成的池化实现,都是缓存的形态:IntegerCacheByte/Short/Long/Character 的缓存、Boolean.TRUE、字符串常量池。共同点是缓存的都不可变:不需要重置与归还,也不会被借用者改脏。

IntegerCache 是最典型的一个,JDK 25 Integer.java:932-937

1
2
3
4
5
6
private static final class IntegerCache {
static final int low = -128;
static final int high;

@Stable
static final Integer[] cache;

入口在 Integer.java:1003-1007

1
2
3
4
5
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}

缓存的建立方式:上界可以用 java.lang.Integer.IntegerCache.high 属性调(下限固定 -128,assert IntegerCache.high >= 127 保证 [-128,127] 一定被缓存),这份数组还随 CDS 归档打进镜像:启动时从共享归档恢复,不重新构造。一个每次调用都要走的缓存,成本被挪到了构建期。

ThreadLocalMap 是另一种形态,ThreadLocal 池就是它。每个 Thread 对象上挂一个 Map,键是 ThreadLocal 实例,值是缓存的对象,ThreadLocal.java:358-368

1
2
3
4
5
6
7
8
9
static class ThreadLocalMap {
/**
* The entries in this hash map extend WeakReference, using
* its main ref field as the key (which is always a
* ThreadLocal object). ...
*/
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;

查找是一条线性探测,ThreadLocal.java:485-491

1
2
3
4
5
6
7
8
private Entry getEntry(ThreadLocal<?> key) {
int i = key.threadLocalHashCode & (table.length - 1);
Entry e = table[i];
if (e != null && e.refersTo(key))
return e;
else
return getEntryAfterMiss(key, i, e);
}

两个设计决定:键是弱引用,ThreadLocal 对象一死,条目会在后续访问里被清掉;值是强引用,只要线程活着、条目没被清掉,缓存的对象就一直被线程按着。线程池里的线程恰好活得很久,「ThreadLocal 缓存昂贵对象」这个反模式的机制就在这里。

虚拟线程把这个机制放大了一层。平台线程上这是「每线程一个实例」,虚拟线程可以到百万级,同一个池会变成「每虚拟线程一个实例」,内存随并发线性增长。JDK 的应对是 ThreadLocal.getCarrierThreadLocalScopedValue:前者把值挂到载体线程(数量有界),后者把生命周期绑到作用域上。

真实世界的池长什么样

生产代码里最出名的对象池是 Netty 的 PooledByteBufAllocator,池化的正是 DirectByteBuffer 这类对象:direct buffer。

它按线程分片。默认 Arena 数量是 2 * 可用处理器,源码里的注释直接写着理由:「如果选更小的值,会撞上热点,因为分配与释放需要在 PoolArena 上同步」(PooledByteBufAllocator.java 的静态初始化块,Netty 4.1)。线程通过 PoolThreadLocalCache 绑定到某个 Arena,把「有锁池」那一列的结构改成「ThreadLocal 池」那一列。

它还有一层按尺寸分级的线程本地缓存:smallCacheSize 默认 256、normalCacheSize 默认 64,缓存对象上限 32 KiB(maxCachedBufferCapacity),每 8192 次分配做一次 trim。这一层是为「频繁借还同一批尺寸」准备的,避免每次都回到 Arena 的队列上。

它还按尺寸分池,因为容量上限对 tiny/small/normal 三种规格的意义不同,混在一个队列里会让碎片与容量都失控。

三条设计对应前面测出的三点:构造成本高(native 内存加 Cleaner),池化收益就是十倍量级;借用频率高,按线程分片避开锁;对象尺寸差异大,按尺寸分池,给常用的几档开线程本地缓存。

八、什么时候用,什么时候不用

用池的场合:

  • 构造成本在几百纳秒以上,或者构造过程要过全局资源。 本次两组贵对象都是这个形状,池化收益 7 到 14 倍,而且随线程数变大。
  • 对象数量天然有界。 连接池、线程池、缓冲池的上限来自外部资源,池容量是硬件约束的体现,不是拍脑袋的数字。
  • 重置是常数时间。 buffer.clear()cursor = 0 这类一行重置。

不用池的场合:

  • 便宜对象加高并发。 1 KiB 数组这一组,8 线程以上池化净亏,加线程越多亏越多。
  • 重置比构造还贵。 需要清零整块内存、清空集合、重置加密上下文的对象,把重置成本代入不等式右边,多数会翻到负号一侧。
  • 对象持有线程亲和的资源。 ThreadLocal 池在虚拟线程下按每实例一份内存放大,这类场景要用载体线程局部变量或者作用域变量。
  • 只为「少分配」而池化。 分配本身是并行的,也便宜,短命对象交给 GC 就行。池化省下来的分配时间经常小于它引入的同步时间。

在线上怎么判断池已经变成负优化

三个信号,都能从现成的指标里读出来。

借出等待时间随并发上升,而构造成本没变。 这说明已经越过交叉点。ReentrantLock.getQueueLength()、HikariCP 的 pendingusage、Netty 的 PooledByteBufAllocatorMetric 都是现成的入口。等待时间从几十纳秒涨到几百纳秒的时候,池已经不再带来收益。

并发翻倍而吞吐不动。 池的吞吐是一条水平线。压测时把并发从 8 拉到 16,如果吞吐不涨、锁等待涨,池正在还债。

池化之后 GC 次数没降。 池化的预期收益之一是少产生垃圾。如果池化前后 young GC 次数一样,说明热点分配的对象不在这批池化的实例里,池白做了。

最直接的办法是 A/B:把池换成 new 跑一轮,比较峰值并发下的吞吐。前面那套基准就是模板,一次运行几分钟,比任何设计讨论都快。

一条判断顺序:先量构造一次的纳秒数;超过几百就往下走,否则直接 new。再看构造里有没有全局资源,有的话池化收益会随并发放大。最后确认重置是常数时间、池容量有外部依据,两个条件缺一个,先把池换成 new,量一轮再决定。

总结

  • 池化把分配成本换成同步成本。分配走线程私有 TLAB,可扩展;借还抢共享队列,不可扩展。判据是 C_new > C_borrow(n) + C_reset
  • 1 KiB 数组(1 064 B/op):new 单线程 9.5 M op/s(105.7 ns),有锁池 38.7 M(25.9 ns),池化赢 4 倍。
  • 同一个对象 8 线程时反转:new 44.0 M 对池 24.7 M;16 线程 44.4 M 对 24.4 M。交叉点在 2 到 4 线程之间。
  • 有锁池的吞吐几乎不随线程数变化(38.7 → 24.7 → 24.4 M op/s)。16 线程时每次借还摊 655 ns,单线程 25.9 ns,差在等锁。
  • new 1 KiB 数组在 8 线程后撞天花板:44.4 M op/s 乘以 1 064 字节是 47 GB/s 分配速率,1 秒 149 次 GC,占用 87 ms。分配并行,回收不并行。
  • ThreadLocal 池是零同步上界:212 → 894 → 941 M op/s,两个 JDK 一致,1 秒窗口里 0 次 GC。
  • ByteBuffer.allocateDirect(1024)new 没有扩展性(1.30 → 1.49 → 2.29 M op/s),16 线程时每次分配摊到 7.0 µs;有锁池快 10.8 倍。
  • 贵对象 new 的 GC 时间不正常:每 op 只分配 152 字节堆内存,单次 young GC 却是 74 ms,而 1 KiB 数组那组每 op 分配 1 064 字节、单次 GC 只要 0.6 ms。差在 Cleaner 的引用处理。
  • 源码层面的原因是四步构造里两步全局串行:Bits.reserveMemory 的 CAS 循环抢三个全局原子量(Bits.java:188-203),Cleaner.createprivate static synchronized Cleaner addjdk/internal/ref/Cleaner.java:76)。释放侧对应 static synchronized remove:85)。
  • new SecureRandom() 的构造成本在两个 LTS 之间差 5 倍:JDK 21 每次 846 到 1198 ns、分配 2 016 字节,JDK 25 是 219 到 365 ns、408 字节。算法相同(都是 NativePRNG),-Xint 下差距不变,所以差在实例化路径,不在 JIT。
  • 有锁池的借还成本几乎与对象无关(单线程 26 到 32 ns,16 线程 40 到 61 ns),它只随线程数变;构造成本决定了池化的收益方向。
  • JDK 自己的池只缓存不可变对象:IntegerCache 缓存 [-128,127]Integer.java:932-937:1003-1007),数组随 CDS 归档,成本挪到构建期;ThreadLocalMap 把缓存挂在 Thread 上,键弱引用、值强引用,线程活得越久按得越牢。
  • 测量侧的一个坑:把反逃逸的出口放在共享 volatile 上,8 线程以上这条缓存行就成了所有策略的共同瓶颈,ThreadLocal 池从 212 M 掉到 95 M;改成每线程独占槽位后同一份代码跑到 894 M。

参考资料

系列索引:设计模式系列