频繁 new JedisCluster 之后,对象池为什么回不去了?

一、现象: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。生产形态抽象成两点:

  1. 一个常驻"锚点"池 ------ 让共享 eviction 调度器始终存活(模拟服务里一直有连接);
  2. 循环 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 版本决定:

  1. 升级 jedis 到依赖 commons-pool2 ≥ 2.6.0 的版本;

  2. 或在构建里显式钉住 commons-pool2 版本 (dependencyManagement / 直接依赖覆盖):

    xml 复制代码
    <dependency>
      <groupId>org.apache.commons</groupId>
      <artifactId>commons-pool2</artifactId>
      <version>2.6.0</version>   <!-- 或更高维护版本 -->
    </dependency>
  3. 暂时不能升级的规避:确认 timeBetweenEvictionRunsMillis 是 -1(Jedis 默认即关闭 eviction,别为"空闲回收"把它调大);避免"常驻连接 + 高频创建/销毁集群"并存。

八、总结与启发

  1. "对象池残留"先问 evictorGenericObjectPool 开 eviction 后,池和全局调度器 存在双向引用,close 若没把调度任务摘干净,池就回不去------这类"全局单例持引用"的 泄漏,dump 里对象看着"正常活着",其实是孤儿任务钉死的;
  2. "某某版本修复了"要用实测钉边界:别信嘴上的版本号,把受影响版本和修复版本 一个个跑出来,升级决策才有依据;
  3. 反编译是定位库 bug 的利器javap -c 直接看 schedule/cancel 的实现, "入队 Runnable 却按 TimerTask 取消"这种签名错位一眼现形;
  4. 对照组让你不被误导:关掉 eviction 后 2.4.3 也不泄漏,才敢下结论"就是 evictor 任务的问题",否则很容易误判成 close 的 bug。
相关推荐
掘金者阿豪13 分钟前
给你的AI龙虾打造一间像素办公室!Star Office UI部署+公网访问完整教程
后端
AKA__Zas23 分钟前
文件 I/O(速通版
java·intellij-idea·学习方法
学长毕业设计24 分钟前
基于SpringBoot的咖啡馆管理系统的设计与实现(源码+文档+讲解视频)
java·spring boot·后端
骇客野人27 分钟前
SpringBoot业财一体化系统设计和落地实施方案
java·spring boot·后端
学长毕业设计30 分钟前
基于SpringBoot的教学资源推荐系统的设计与实现(源码+文档+讲解视频)
java·spring boot·后端
用户8508692761536 分钟前
一次线上文件上传 Bug 引发的区块链分布式存储深度排查
后端
名字还没想好☜38 分钟前
Java Cleaner 实战:替代废弃的 finalize() 做堆外资源清理,以及强引用导致永不回收的坑
java·后端·spring
LaughingZhu39 分钟前
Product Hunt 每日热榜 | 2026-09-02
java·ide·intellij-idea
旧梦95271 小时前
Java EnumMap 详解:原理、用法与实战
java·开发语言