第3章 OkHttp连接池泄漏路径:Dispatcher未关闭、RealCall引用链、ConnectionPool持有

3.1 现象识别

连接池泄漏的外在表现与线程泄漏高度关联,但在堆栈分析中呈现出不同的引用链特征。常见现场包括:

  • OOM GC 压力:反复 Full GC,但堆中 okhttp3.ConnectionPool 实例数量持续增长,且每个实例均关联多个 RealConnection。
  • 线程爆炸:OkHttp Dispatcher 的 runningAsyncCalls 数超过预期,且 readyAsyncCalls 或 cancelledCalls 队列中堆积大量从未出队的 AsyncCall。
  • 连接数居高不下:通过 adb shell dumpsys meminfo 或 Runtime.getRuntime().totalMemory() 观察到 OkHttp ConnectionPool 内部的 Deque<RealConnection> 膨胀,即使应用已进入空闲状态。
  • MAT 泄漏疑点:在 Eclipse MAT 或 IntelliJ Profiler 中,从 GC Root 到 ConnectionPool 的路径中,Dispatcher 始终是强引用链的起点之一。
排查线索:若应用长期运行后出现“没网了”或“接口超时”,且堆转储中 RealConnection 数量远超 maxIdleConnections 默认值 5,优先检查 Dispatcher 是否被意外长期保留。

3.2 数据采集

收集泄漏路径所需的数据包括:

  1. 堆转储文件:在泄漏现场执行 jmap -dump:live,format=b,file=leak.hprof <PID>(Android 上使用 adb shell am dumpheap <包名> /data/local/tmp/heap.hprof)。
  2. 线程快照:jstack <PID> 观察 OkHttp Dispatcher 线程状态。
  3. 存活对象统计:用 MAT 或 jhat 分析 okhttp3.internal.connection.RealConnection 实例数,并查看其 GC Root 引用链。
# Android 堆转储命令示例
adb shell am dumpheap com.example.app /data/local/tmp/heap.hprof
adb pull /data/local/tmp/heap.hprof .
必备工具:
- Android Studio Profiler(直接观察连接池大小)
- Eclipse MAT 的 Leak Suspects 报告
- 命令行 jcmd <PID> GC.class_histogram 快速预览

3.3 原因定位

泄漏的根本路径是:Dispatcher 未正确关闭 → 队列中的 AsyncCall 持有 RealCall → RealCall 持有 Connection → Connection 被 ConnectionPool 持有 → 整个链路无法被 GC。下面从三个关键类分析每个环节。

3.3.1 Dispatcher 未关闭

当应用通过 OkHttpClient.Builder().dispatcher(dispatcher).build() 自定义 Dispatcher 时,若在 Activity/Fragment 销毁时未调用 dispatcher.executorService().shutdown(),则 ExecutorService 中的工作线程一直存活。即使 AsyncCall 已执行完毕,Dispatcher 的 readyAsyncCalls 和 cancelledCalls 队列仍可能因未及时清理而持有过时的 AsyncCall 引用。

// 典型泄漏代码:自定义 Dispatcher 后忘记关闭
Dispatcher dispatcher = new Dispatcher(new ThreadPoolExecutor(
        0, Integer.MAX_VALUE, 60, TimeUnit.SECONDS,
        new SynchronousQueue<>()));
OkHttpClient client = new OkHttpClient.Builder()
        .dispatcher(dispatcher)
        .build();
// ... 在 lifecycle 结束时未执行 dispatcher.executorService().shutdown();

3.3.2 RealCall 引用链

每个 AsyncCall 内部持有 RealCall 引用,而 RealCall 又持有 Transmitter,Transmitter 持有 ExchangeFinder,最终指向 RealConnection。当 Dispatcher 队列中的 AsyncCall 未被移除时,整条引用链被强根保留:

// 引用链示例(MAT 展示路径)
Dispatcher
  -> runningAsyncCalls (ArrayDeque)
    -> AsyncCall
      -> RealCall
        -> Transmitter
          -> ExchangeFinder
            -> RealConnection
              -> ConnectionPool (connections)

3.3.3 ConnectionPool 持有

即使 RealConnection 已经空闲,ConnectionPool 的 connections 双端队列仍会按 keepAliveDuration(默认5分钟)保留最大 maxIdleConnections(默认5)个连接。但这是正常行为。真正的问题在于 被 Dispatcher 队列强引用的 RealCall 阻止了 RealConnection 进入空闲清理流程——因为连接仍在被“使用中”(虽然实际上任务已结束)。

环节 持有对象 泄漏影响
Dispatcher AsyncCall(通过 running/ready/cancelled 队列) 线程无法退出,队列对象无法回收
AsyncCall → RealCall 强引用 RealCall 及旗下 All 连接被保留
RealCall → Connection Transmitter → ExchangeFinder → RealConnection 连接无法归还池或释放
ConnectionPool RealConnection(connections 队列) 池中连接永远无法被驱逐
关键判断规则:在 MAT 中分析 ConnectionPool 的 GC Root 路径。若最短路径经过 java.util.concurrent.ThreadPoolExecutor$Worker 或 OkHttp Dispatcher 的 runningAsyncCalls,则确认是 Dispatcher 未关闭 导致的泄漏;若路径来自 OkHttpClient 静态全局持有,则属于 长生命周期引用 泄漏。
连接池泄漏路径:Dispatcher 未关闭 → Heap OOM Dispatcher running/ready/cancelled 队列 线程未 shutdown RealCall Transmitter ExchangeFinder RealConnection Socket / 资源 keepAlive 计时 ConnectionPool connections Deque cleanupRunnable Heap OOM GC 无法回收 连接数无限增长 队列持有 AsyncCall Transmitter 引用 池持有连接引用 连接无法释放 🔍 关键发现 从 GC Root 到 ConnectionPool 的最短路径中,Dispatcher 的队列是主要的强引用源头。 修复重点:确保 Dispatcher 的 ExecutorService 在生命周期结束时 shutdown,并清空所有队列。

3.4 修复动作

针对三个泄漏节点进行修复:

3.4.1 关闭 Dispatcher

在 Activity/Fragment 的 onDestroy() 或 Service 的 onDestroy() 中,明确关闭自定义 Dispatcher:

// 方案一:直接在生命周期关闭
@Override
protected void onDestroy() {
    super.onDestroy();
    if (dispatcher != null) {
        dispatcher.executorService().shutdown(); // 不再接受新任务
        // 可选择 awaitTermination 等待现有任务完成
    }
}

若使用 OkHttpClient 默认 Dispatcher(通过 new OkHttpClient() 生成),则无需手动关闭——客户端本身会在 JVM 回收时清理。但若使用 OkHttpClient.Builder() 并显式设置 Dispatcher,则必须关闭。

3.4.2 清理队列中的悬空 AsyncCall

在关闭 ExecutorService 前,先清空 runningAsyncCalls、readyAsyncCalls 和 cancelledCalls,避免引用滞留:

// 方案二:完全重置 Dispatcher
dispatcher.cancelAll(); // 取消所有进行中的调用
dispatcher.executorService().shutdownNow(); // 中断工作线程

3.4.3 释放 ConnectionPool 中的空闲连接

虽然 ConnectionPool 有自动清理,但作为预防,可在关闭前主动驱逐空闲连接:

// 可选:强制清理空闲连接
connectionPool.evictAll(); // 清除所有空闲连接
最佳实践:将 OkHttpClient 声明为 Application 级别的单例,只创建一次,不进行 Dispatcher 自定义。若必须自定义 Dispatcher,请在 BaseApplication 的 onTerminate() 中统一关闭。

3.5 复测

修复完成后,进行以下验证:

  1. Heap 快照对比:在相同场景下(如连续切换 20 个 Activity + 网络请求)采集两次堆转储,对比 ConnectionPool 和 RealConnection 实例数。修复后应稳定在 maxIdleConnections(默认 5)附近。
  2. 线程数监控:通过 Thread.activeCount() 或 adb shell "ps -T <pid>" 观察 OkHttp 工作线程数量,不再随请求次数线性增加。
  3. MAT 路径验证:为每个 ConnectionPool 实例生成最短 GC Root 路径,确认不再经过 Dispatcher 的 runningAsyncCalls。
# 快速验证:查看连接池大小(Android API 29+)
adb shell dumpsys activity provider com.android.shell:suspend | grep -i connectionpool

3.6 预防

预防措施 说明 应用场景
单例 OkHttpClient 整个应用中只创建一次 OkHttpClient,复用 Dispatcher 和 ConnectionPool 所有 Android 项目
避免自定义 Dispatcher 除非有明确的隔离需求(如不同 baseUrl 使用不同线程池),否则使用默认 Dispatcher 大多数普通项目
生命周期绑定 若必须自定义,将 Dispatcher 的生命周期绑定到 Application 而非 Activity 大型组件化项目
弱引用回调 在 Callback 中使用 WeakReference 持有 Context 或 View,避免长生命周期的 Call 间接持有短生命周期对象 Retrofit + OkHttp 混合使用
定期监控 在 CI 流程中加入堆转储分析脚本,对 ConnectionPool 数量设置阈值报警 线上质量监控
避坑提醒:使用 Retrofit 时,若 Service 接口方法返回 Call<T> 且未在生命周期结束时调用 call.cancel(),则该 Call 同样会进入 Dispatcher 队列导致泄漏。建议将 Call 绑定到 CompositeDisposable(RxJava 场景)或 Disposable(Coroutine 场景)统一取消。