1. 为什么值得花时间啃 OkHttp 4.x 的源码移动端网络请求库这个赛道OkHttp 算是绕不开的一座山。但凡做过 Android 或者 JVM 后端开发的同学大概率都在项目里直接或间接用过它——Retrofit 底层走的是它Glide 加载图片走的是它连很多推送 SDK 内部也悄悄依赖它。可大多数人停留在“会调 API”的层面OkHttpClient一 newRequest一 buildenqueue一扔回调里拿结果完事。真遇到连接泄漏、线程池打满、拦截器顺序错乱、超时设置不生效这类问题时就只能靠猜。我这次决定把 OkHttp 4.x 的源码从头到尾捋一遍起因很简单线上一个长连接复用率始终上不去抓包看每次请求都在重新握手。排查了两天最后发现是自定义拦截器里对Connection头做了手脚破坏了连接池的复用判断。这件事让我意识到不把源码里的连接管理、拦截器链、线程调度这几块吃透遇到问题永远只能停留在“试错”阶段。OkHttp 4.x 相比 3.x 最大的变化是用 Kotlin 重写了一遍代码量精简了不少可读性反而更高。它解决的问题很明确在 HTTP/1.1 和 HTTP/2 之上提供一个高效、可复用连接、支持拦截器扩展、线程安全的网络请求客户端。适合谁来读我觉得有三类人最该看一是被网络问题反复折磨的 Android 开发二是想自己写网络框架、需要参考成熟设计的中高级工程师三是对连接池、责任链、线程调度这些通用设计模式感兴趣想找个真实项目练手的同学。这篇文章不会逐行翻译源码那种事交给 IDE 就行。我想做的是把 OkHttp 4.x 里几个核心模块的设计意图、关键流程、以及我踩过的坑讲清楚让你看完能自己顺着源码往下读而不是被一堆类名劝退。2. 整体架构与核心模块拆解2.1 一张图看懂 OkHttp 的分层设计OkHttp 的整体结构可以粗暴地分成四层从外到内依次是用户 API 层OkHttpClient、Request、Response、Call这是我们日常打交道的部分。拦截器链层Interceptor接口和RealInterceptorChain负责把一次请求拆成多个可插拔的处理环节。连接管理层ConnectionPool、RealConnection、RouteSelector、Exchange负责连接的建立、复用、回收。I/O 与协议层Http1ExchangeCodec、Http2ExchangeCodec、Socket、BufferedSource/Sink负责真正的字节读写和协议编解码。这四层不是随便分的。用户 API 层要保证线程安全和易用性拦截器层要保证可扩展性和顺序可控连接层要保证复用率和并发安全I/O 层要保证高效和低拷贝。每一层各司其职层与层之间通过明确的接口通信这样任何一层的改动都不会轻易波及全局。我特别喜欢 OkHttp 的一点是它把“一次请求”抽象成了Exchange这个概念。Exchange代表一次具体的 HTTP 交换它持有连接、编解码器、事件监听器。一个Call可能对应多次Exchange比如重定向、认证重试但同一时刻只有一个活跃的Exchange。这个抽象让重试逻辑变得非常干净。2.2 拦截器链责任链模式的教科书级实现OkHttp 的拦截器链是整个库的灵魂。它把一次请求的处理拆成七个内置拦截器按顺序依次执行client.interceptors用户自定义最先执行RetryAndFollowUpInterceptor负责重试和重定向BridgeInterceptor负责补全请求头、处理 gzip、CookieCacheInterceptor负责缓存读写ConnectInterceptor负责建立或复用连接client.networkInterceptors用户自定义网络层CallServerInterceptor负责真正发送请求、读取响应为什么用责任链而不是模板方法因为责任链能让每个环节独立决策“要不要继续往下走”。比如缓存拦截器命中缓存后直接返回响应后面的连接拦截器根本不会执行。这种“短路”能力是模板方法做不到的。RealInterceptorChain的proceed方法里有个细节值得注意它每次调用都会创建一个新的RealInterceptorChain实例把index 1传进去。这样做是为了保证线程安全和状态隔离——每个拦截器拿到的链对象都是独立的不会互相干扰。我一开始觉得这样频繁创建对象会不会有性能问题后来实测下来一次请求也就创建七八个对象对 JVM 来说完全在可接受范围内换来的是代码的清晰和线程安全这笔买卖很划算。2.3 连接池复用才是性能的关键HTTP 性能的大头在连接建立。一次 TCP 三次握手加上 TLS 握手在移动网络下动辄几百毫秒。OkHttp 的连接池就是为解决这个问题而生的。ConnectionPool内部维护了一个ArrayDequeRealConnection所有空闲连接都放在里面。当需要新连接时ExchangeFinder会先调用pool.callAcquirePooledConnection尝试复用。复用的判断条件很严格主机名、端口、代理、TLS 配置、协议版本必须全部匹配而且连接不能是noNewExchanges状态。连接池的清理策略也很有意思。它有一个后台线程cleanupRunnable每隔一段时间默认 5 分钟跑一次把空闲时间超过keepAliveDuration默认 5 分钟的连接或者超过maxIdleConnections默认 5 个的连接清理掉。注意这里的“空闲”是指连接上没有任何活跃的Exchange而不是最后一次使用时间。这个区别很关键我见过有人误以为连接一用完就开始计时结果发现连接被提前回收了。提示连接池的maxIdleConnections默认是 5在高并发场景下可能偏小。如果你的应用同时有大量请求打到同一台服务器可以适当调大这个值但不要无脑调太大否则会占用过多文件描述符。3. 核心流程源码级剖析3.1 一次同步请求的完整生命周期我们从call.execute()开始跟一遍同步请求的完整流程。假设你已经 build 好了Request和OkHttpClient。第一步RealCall.execute()会先检查executed标志防止同一个 Call 被执行两次。然后调用getResponseWithInterceptorChain()。这个方法里会创建一个ArrayListInterceptor把用户拦截器、内置拦截器、网络拦截器按顺序加进去最后构造RealInterceptorChain并调用proceed(0, request, ...)。第二步链式调用开始。RetryAndFollowUpInterceptor会进入一个while(true)循环这是重试和重定向的核心。它先调用chain.proceed往下走拿到响应后判断是否需要重定向3xx或者认证重试401/407。如果需要就更新 request 然后继续循环如果不需要就返回响应。第三步BridgeInterceptor补全请求头。它会加上Host、Connection: Keep-Alive、Accept-Encoding: gzip、User-Agent、Cookie等。如果用户没有设置Content-Length但 body 不为空它还会帮忙算出来。响应回来时如果发现是 gzip 压缩的它会用GzipSource包一层自动解压。第四步CacheInterceptor查缓存。如果缓存命中且新鲜直接返回缓存响应后面的拦截器全部跳过。如果缓存过期它会加If-None-Match或If-Modified-Since头然后往下走。服务器返回 304 时它会把缓存响应和 304 响应合并返回合并后的结果。第五步ConnectInterceptor建立连接。它调用ExchangeFinder.find()这个方法会尝试复用连接池里的连接找不到就新建。新建连接的过程涉及 DNS 解析、路由选择、TCP 连接、TLS 握手每一步都有超时控制。第六步CallServerInterceptor发送请求。它通过Exchange拿到ExchangeCodec写入请求头、请求体然后读取响应头、响应体。如果是 HTTP/2还会处理流控和帧的编解码。整个流程走下来一次请求可能经过七八个拦截器每个拦截器只做一件事职责非常清晰。这种设计的好处是你想加功能就加拦截器想改行为就改对应拦截器完全不用动核心逻辑。3.2 连接复用的判断逻辑与参数计算连接复用是 OkHttp 性能的核心但它的判断逻辑比想象中复杂。RealConnection.isEligible方法里有一长串条件fun isEligible(address: Address, routes: ListRoute?): Boolean { // 1. 连接上不能有活跃的 Exchange if (exchanges.isNotEmpty()) return false // 2. 不能是 noNewExchanges 状态 if (noNewExchanges) return false // 3. 主机名必须匹配 if (route.address.url.host ! address.url.host) return false // 4. 端口必须匹配 if (route.address.url.port ! address.url.port) return false // 5. 代理配置必须匹配 if (route.address.proxy ! address.proxy) return false // 6. TLS 配置必须匹配如果走 HTTPS if (address.sslSocketFactory ! null) { if (route.address.sslSocketFactory ! address.sslSocketFactory) return false if (route.address.hostnameVerifier ! address.hostnameVerifier) return false } // 7. HTTP/2 连接可以复用给不同主机如果证书允许 if (protocol Protocol.HTTP_2) { // 检查证书是否覆盖目标主机 return supportsUrl(address.url) } return true }这里面最容易被忽略的是第 7 条。HTTP/2 支持多路复用一个连接可以同时承载多个请求而且如果 TLS 证书覆盖了多个域名这个连接还能复用给其他域名。这就是为什么 HTTP/2 下连接复用率远高于 HTTP/1.1。连接池的清理参数也值得算一算。默认maxIdleConnections 5keepAliveDuration 5 分钟。假设你的应用平均每秒发起 10 个请求每个请求耗时 200ms那么同时活跃的连接大约是 2 个。空闲连接会在 5 分钟后被清理所以池子里最多也就 5 个连接。但如果你有突发流量比如每秒 100 个请求那 5 个空闲连接可能不够用会频繁新建连接。这时候可以把maxIdleConnections调到 20 左右实测能明显降低连接建立次数。注意调大maxIdleConnections会增加文件描述符占用。Android 上每个进程的文件描述符上限通常是 1024如果连接数太多可能会触发Too many open files错误。建议根据实际并发量调整不要盲目调大。3.3 线程调度与异步请求的实现细节异步请求走的是call.enqueue()它把请求交给Dispatcher处理。Dispatcher内部维护了三个队列readyAsyncCalls等待执行的异步请求runningAsyncCalls正在执行的异步请求runningSyncCalls正在执行的同步请求Dispatcher的核心参数是maxRequests默认 64和maxRequestsPerHost默认 5。当runningAsyncCalls的大小小于maxRequests且同一主机的请求数小于maxRequestsPerHost时请求才会被真正执行。否则就放在readyAsyncCalls里排队。为什么要有maxRequestsPerHost这个限制因为 HTTP/1.1 下同一主机的连接数是有限的如果同时发起太多请求大部分都会在连接池里排队等连接反而降低效率。限制在 5 个可以保证连接被充分利用又不会造成过多排队。Dispatcher的线程池配置也很有意思private var executorServiceOrNull: ExecutorService? null val executorService: ExecutorService get() { if (executorServiceOrNull null) { executorServiceOrNull ThreadPoolExecutor( 0, Int.MAX_VALUE, 60, TimeUnit.SECONDS, SynchronousQueue(), threadFactory(OkHttp Dispatcher, false) ) } return executorServiceOrNull!! }核心线程数是 0最大线程数是Int.MAX_VALUE队列是SynchronousQueue。这意味着每个任务都会直接创建新线程执行执行完 60 秒后线程被回收。这种配置适合短任务、高并发的场景但缺点是线程创建开销大。不过 OkHttp 通过maxRequests限制了并发数所以实际创建的线程数不会失控。我实测过在maxRequests 64的情况下峰值线程数大概在 60 左右对 Android 来说是可以接受的。但如果你把maxRequests调到几百线程数就会飙升可能触发 OOM。所以这个参数不要乱改。4. 实操验证与常见问题排查4.1 用事件监听器观察连接复用情况光看源码不够直观我习惯用EventListener来验证连接复用。OkHttp 提供了EventListener抽象类可以监听连接建立、请求发送、响应读取等事件。下面是我常用的一个简易实现class ConnectionMonitor : EventListener() { private val callStartTime mutableMapOfCall, Long() private val connectStartTime mutableMapOfCall, Long() override fun callStart(call: Call) { callStartTime[call] System.nanoTime() } override fun connectStart(call: Call, inetSocketAddress: InetSocketAddress, proxy: Proxy) { connectStartTime[call] System.nanoTime() println(开始建立连接: ${inetSocketAddress.hostName}) } override fun connectEnd(call: Call, inetSocketAddress: InetSocketAddress, proxy: Proxy, protocol: Protocol?) { val cost (System.nanoTime() - (connectStartTime[call] ?: 0)) / 1_000_000 println(连接建立完成: ${inetSocketAddress.hostName}, 耗时 ${cost}ms, 协议 $protocol) } override fun connectionAcquired(call: Call, connection: Connection) { println(复用连接: ${connection.route().address.url.host}) } override fun callEnd(call: Call) { val cost (System.nanoTime() - (callStartTime[call] ?: 0)) / 1_000_000 println(请求结束, 总耗时 ${cost}ms) } }把它挂到OkHttpClient.Builder.eventListener()上跑几次请求就能看到连接是新建的还是复用的。如果每次都是“开始建立连接”说明复用没生效需要检查请求头或者连接池配置。4.2 常见问题速查表问题现象可能原因排查方法解决方案连接复用率低请求头带了Connection: close用 EventListener 看 connectStart 频率移除自定义 Connection 头请求超时频繁连接池太小或 DNS 慢看 connectStart 到 connectEnd 耗时调大 maxIdleConnections换 DNS线程数飙升maxRequests 设置过大dump 线程栈看 OkHttp Dispatcher 线程数把 maxRequests 调回 64 或更小缓存不生效响应头没有 Cache-Control看 CacheInterceptor 日志让服务端加缓存头或自定义 Cache重定向死循环重定向次数超过 20看 RetryAndFollowUpInterceptor 日志检查服务端重定向逻辑响应体读取慢没有用 gzip 压缩看响应头 Content-Encoding服务端开启 gzip客户端加 Accept-Encoding这张表里的每一条都是我实际踩过的坑。特别是第一条我见过有人在拦截器里手动加了Connection: close结果所有连接用完就关连接池形同虚设。排查了半天才发现是这行代码。4.3 拦截器顺序错乱导致的诡异问题拦截器的顺序非常关键但很多人不注意。我遇到过一个案例有人在addInterceptor里加了一个日志拦截器又在addNetworkInterceptor里加了一个重试拦截器。结果发现重试的时候日志拦截器只打印了一次但网络拦截器打印了两次。原因很简单addInterceptor的拦截器在RetryAndFollowUpInterceptor之前执行所以重试不会重新走用户拦截器而addNetworkInterceptor的拦截器在ConnectInterceptor之后执行每次重试都会重新走一遍。如果你希望重试时也重新执行某个逻辑就必须放在网络拦截器里。提示用户拦截器适合做日志、鉴权、公共参数网络拦截器适合做重试、压缩、流量统计。放错位置会导致行为不符合预期。4.4 连接泄漏的排查思路连接泄漏是 OkHttp 里最隐蔽的问题之一。表现是连接池里的连接数持续增长最终耗尽文件描述符。常见原因有三个一是响应体没有关闭。ResponseBody必须调用close()否则连接不会归还到连接池。用try-with-resources或者 Kotlin 的use可以避免。二是拦截器里提前返回了响应但没有关闭原始响应。比如缓存拦截器命中缓存后如果直接返回缓存响应必须把网络响应关掉。三是自定义的EventListener抛了异常导致连接没有正常释放。这种情况比较少见但一旦发生就很难排查。排查方法很简单在EventListener的connectionReleased里打印连接池大小如果发现只增不减就说明有泄漏。然后逐个检查响应体的关闭情况。5. 从源码中学到的设计经验5.1 责任链模式的适用边界OkHttp 用责任链把请求处理拆成多个环节这个设计非常优雅。但责任链不是万能的它适合“每个环节只做一件事且环节之间可以独立决策是否继续”的场景。如果环节之间有复杂的数据依赖或者需要共享大量状态责任链反而会让代码更难维护。我在自己的项目里尝试过用责任链处理订单流程结果发现订单状态在各个节点之间传来传去最后代码比原来还乱。后来改回状态机模式清晰多了。所以模式的选择要看场景不要为了用而用。5.2 连接池设计的通用思路OkHttp 的连接池设计可以抽象成一个通用的资源池模型维护一个空闲资源队列提供 acquire 和 release 接口后台线程定期清理过期资源。这个模型不仅适用于网络连接也适用于数据库连接、线程池、对象池等。关键参数有三个最大空闲数、空闲存活时间、清理间隔。这三个参数的取值需要根据资源创建成本和并发量来权衡。创建成本高、并发量大的场景可以适当调大最大空闲数和存活时间反之则调小避免资源浪费。5.3 线程调度的取舍OkHttp 的 Dispatcher 用了一个“无核心线程、无界最大线程、同步队列”的线程池配置。这种配置适合短任务、高并发的场景但缺点是线程创建开销大。如果任务执行时间较长线程数会迅速膨胀可能导致 OOM。我在实际项目里更倾向于用有界队列加固定线程数的配置比如ThreadPoolExecutor(4, 16, 60s, LinkedBlockingQueue(64))。这样既能控制线程数又能缓冲任务。当然具体配置要看任务特性没有银弹。5.4 源码阅读的方法论最后分享一点读源码的方法。我一开始读 OkHttp 源码时试图从第一个类开始逐行读结果读了三天还在OkHttpClient里打转。后来改变策略从一次具体的请求入手用 debug 模式跟一遍完整流程把涉及的类和方法记下来再逐个深入。这样效率高很多而且能快速建立整体认知。另外读源码一定要带着问题读。比如“连接是怎么复用的”“重定向是怎么处理的”“线程池是怎么调度的”带着问题去源码里找答案比漫无目的地读有效得多。读完一个模块后最好自己画一张流程图或者写一篇笔记把理解固化下来。我这次写这篇文章其实也是对自己理解的一次梳理。OkHttp 4.x 的源码里还有很多细节值得挖掘比如 HTTP/2 的流控、TLS 的握手优化、缓存的磁盘写入策略等。这些内容一篇文章讲不完后面有机会再单独展开。如果你也在读 OkHttp 源码欢迎一起交流踩坑经验。
阅读完成 · 觉得有帮助?