一、现象:OOM 之前,堆里堆满了"对象池"
某个用 JedisCluster 的服务告警 OOM。看监控:堆内存阶梯式上涨、永不回落;dump 一看, 里面躺着成千上万个 GenericObjectPool / JedisPool ------而业务代码明明每次都 new JedisCluster(...) 用完就 close()。
java
// 业务形态:频繁创建集群连接,用完关闭
public void doSomething() {
JedisCluster cluster = new JedisCluster(hostAndPorts, config); // 内含多个 JedisPool
try {
// ... 用 redis ...
} finally {
cluster.close(); // 池 close()
}
}
二、复现设计:怎么把"残留"量化出来
JedisCluster 内部为每个节点建一个 JedisPool(继承 GenericObjectPool),所有节点池 共享 commons-pool2 的全局 EvictionTimer。生产形态抽象成两点:
- 一个常驻"锚点"池 ------ 让共享 eviction 调度器始终存活(模拟服务里一直有连接);
- 循环 4000 次:建一个开 eviction 的池 → close() → 丢弃强引用。
残留怎么数?对每个建过的池持有一个 WeakReference,GC 后看还有多少没被回收------ close 之后还"活着"的池,就是泄漏。
java
List<WeakReference<GenericObjectPool<Object>>> refs = new ArrayList<>();
GenericObjectPool<Object> anchor = newPool(); // 常驻锚点池(开eviction)
for (int i = 0; i < 4000; i++) {
GenericObjectPool<Object> p = newPool(); // timeBetweenEvictionRunsMillis=100
refs.add(new WeakReference<>(p));
p.close(); // 用完即关
}
// 多次 System.gc() 后统计 refs 中仍 get()!=null 的数量
三、结果:2.4.3 全残留,2.6.0 起全回收
4000 次"创建+关闭",锚点池存活、eviction 开启(100ms 一轮):
| 已创建数 | 2.4.3 残留池 | 2.4.3 堆 | 2.6.0+ 残留池 | 2.6.0+ 堆 |
|---|---|---|---|---|
| 500 | 500 | 6 MB | 0 | 1 MB |
| 1000 | 1000 | 12 MB | 0 | 1 MB |
| 2000 | 2000 | 23 MB | 0 | 1 MB |
| 3000 | 3000 | 34 MB | 0 | 2 MB |
| 4000 | 4000 / 4000 | 44 MB | 0 / 4000 | 2 MB |
2.4.3 的残留数和创建数一比一、堆线性上涨------和线上 OOM 的形态完全一致; 2.6.0 及之后干净利落,0 残留。
再用 jcmd <pid> GC.class_histogram 看堆里到底堆了什么(创建 3000 个后):
| 类 | 2.4.3 | 2.6.0+ |
|---|---|---|
GenericObjectPool |
3001(应为 1) | 1(仅锚点池) |
BaseGenericObjectPool$Evictor |
6001 | 1 |
BaseGenericObjectPool$StatsStore |
9003 | 3 |
2.4.3 里每个泄漏的池都带着自己的 evictor 和两份统计对象,链路钉得死死的。
四、反编译看机制:2.4.3 的 cancel 是"假取消"
为什么 close() 了还回收不掉?看字节码(commons-pool2 2.4.3 EvictionTimer):
java
// schedule ------ 把池的 Evictor(内部类,强引用 outer 池)直接排进共享线程池
executor.scheduleWithFixedDelay(evictor, delay, delay); // 入队的是 Runnable
// cancel ------ 签名却按 java.util.TimerTask 处理!
void cancel(TimerTask task, long timeout, TimeUnit unit) {
task.cancel(); // TimerTask.cancel() 只是把标志位置位
// 对 ScheduledThreadPoolExecutor 里已排队的任务没有任何作用!
if (--usageCount == 0) { executor.shutdown(); ... executor = null; }
}
问题就出在这个"签名错位"上:
- 入队的是
Runnable,被线程池包装成自己的任务对象; - 取消却只调
TimerTask.cancel()------它只会改一个内部布尔标志,不会取消、更不会 移除线程池里已排队的周期任务; - 于是只要共享调度器还活着(有锚点池/别的池在用,
usageCount不为 0), 每个已 close 的池的 evictor 任务都会永远留在队列里 ,evictor 强引用 outer 池 →GenericObjectPool永远无法回收 → 越积越多 → OOM。
五、修复边界:2.6.0 起已修复
对 2.4.3 之后的多个版本逐一实测,受影响范围与修复版本非常清晰:
| 版本 | 2.4.3 | 2.5.0 | 2.6.0 | 2.8.0 | 2.10.0 |
|---|---|---|---|---|---|
| 残留池/4000 | 4000 | 4000 | 0 | 0 | 0 |
修复点在 2.6.0:2.4.x 与 2.5.0 泄漏,2.6.0 起全部修复(后续版本延续该修复)。
机制上也对得上------2.6.0 的 EvictionTimer.cancel 签名变成了 cancel(BaseGenericObjectPool.Evictor, ...),让 evictor 持有自己的 ScheduledFuture 并在 cancel 时真正取消 (还开了 setRemoveOnCancelPolicy(true) 从队列移除):
java
// 2.6.0+
executor.setRemoveOnCancelPolicy(true);
evictor.setScheduledFuture(executor.scheduleWithFixedDelay(...));
// cancel → evictor.cancel() → scheduledFuture.cancel(true) // 任务真正取消
六、对照组:证明就是 evictor 的锅
把 eviction 关掉(timeBetweenEvictionRunsMillis = -1)再跑 2.4.3:
ini
FINAL retainedPools=0 / 4000 heapMB=1
关闭 eviction 后 2.4.3 也完全不残留。 这坐实了:问题不在 close() 本身, 而在"开启 eviction 时排进共享调度器的任务取消不掉"。
七、Jedis 场景的升级与规避
Jedis 里 commons-pool2 是传递依赖,由 jedis 版本决定:
-
升级 jedis 到依赖 commons-pool2 ≥ 2.6.0 的版本;
-
或在构建里显式钉住 commons-pool2 版本 (dependencyManagement / 直接依赖覆盖):
xml<dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-pool2</artifactId> <version>2.6.0</version> <!-- 或更高维护版本 --> </dependency> -
暂时不能升级的规避:确认
timeBetweenEvictionRunsMillis是 -1(Jedis 默认即关闭 eviction,别为"空闲回收"把它调大);避免"常驻连接 + 高频创建/销毁集群"并存。
八、总结与启发
- "对象池残留"先问 evictor :
GenericObjectPool开 eviction 后,池和全局调度器 存在双向引用,close 若没把调度任务摘干净,池就回不去------这类"全局单例持引用"的 泄漏,dump 里对象看着"正常活着",其实是孤儿任务钉死的; - "某某版本修复了"要用实测钉边界:别信嘴上的版本号,把受影响版本和修复版本 一个个跑出来,升级决策才有依据;
- 反编译是定位库 bug 的利器 :
javap -c直接看schedule/cancel的实现, "入队 Runnable 却按 TimerTask 取消"这种签名错位一眼现形; - 对照组让你不被误导:关掉 eviction 后 2.4.3 也不泄漏,才敢下结论"就是 evictor 任务的问题",否则很容易误判成 close 的 bug。