大概半年前我接手了一套基于 JDK 17 写的网关服务,压测刚开始就发现到后端服务的 TCP 连接数一路往上飙,几百个并发请求硬是打出了上千条连接,TIME_WAIT 状态堆了一地。第一反应是给 JDK17 HttpClient 的 ConnectionPool 配一个连接数上限,结果翻遍了java.net.http组件,发现它压根没暴露这类 API。后来把jdk.internal.net.http.ConnectionPool的源码翻了个底朝天,才彻底搞明白 JDK 内置 HttpClient 的连接池到底是怎么工作的——以及在 JDK 的语境下,“最大连接数”和“每路由连接数”这两个问题应该怎么理解、怎么落地。
这篇文章就把我这段排查和折腾的过程完整写出来。内容包括 ConnectionPool 的内部工作机制、它与 Apache HttpClient / OkHttp 连接池的差异、JDK 官方给了哪些和连接池相关的配置开关,以及几种真正能限制连接数的可行方案。适合那些正在用 JDK 11 以上 HttpClient 做服务间调用、压测时发现连接数异常、或者想从其他 HTTP 客户端迁移过来的同学参考。
1. 先破除一个误解:JDK 的 ConnectionPool 不是你想的那种连接池
1.1 它管理的是空闲连接复用,不是并发限流
很多人第一次听说 JDK HttpClient 有 ConnectionPool,会下意识以为它和 Apache HttpClient 的 PoolingHttpClientConnectionManager 差不多:设置一个最大总连接数、一个默认每路由连接数,超了就排队等待。
这个理解是错的。
JDK 内置 HttpClient 的 ConnectionPool,核心职责只有一个:把用完的连接保存下来,给后续请求复用。它不负责控制并发连接总量,也不会因为某个路由的连接数超过阈值就阻塞新请求。连接池里维护的都是“此刻没有请求在跑”的空闲连接,至于活跃的连接——也就是正在发请求、正在读响应的连接——并不在它的管辖范围内。
换句话说,连接池更像是“闲置物品回收站”,而不是“并发流量闸门”。大量并发请求到来时,如果池子里没有可复用的空闲连接,HttpClient 会毫不犹豫地新建连接。至于同时能新建多少条,JDK 本身没有硬性上限,真正限制它的往往是操作系统的文件描述符上限(ulimit -n)、服务端的连接数限制、或者客户端这边的线程数。
这也是我在项目里踩坑的根源:我以为“连接池”会像一个池子一样装满就溢出来,实际上它只负责“回收再用”;并发请求一多,连接数就跟着飞涨,这个行为在 JDK 的设计里是“正常”的。
1.2 和 Apache HttpClient / OkHttp 连接池的差异
拿最常见的两个 HTTP 客户端的连接池配置能力做个对比,就很直观了。
| 配置项 | JDK HttpClient | Apache HttpClient | OkHttp |
|---|---|---|---|
| 设置最大总连接数 | 无公开 API | setMaxTotal | 不提供,但可通过 dispatcher 限制并发请求数 |
| 设置每路由连接数 | 无公开 API | setDefaultMaxPerRoute / setMaxPerRoute | 不提供,靠 ConnectionPool 的 maxIdleConnections 控制空闲连接数 |
| 配置连接空闲超时 | 无直接 API,受服务端 Keep-Alive 头影响 | setValidateAfterInactivity | ConnectionPool 构造参数 |
| 获取连接时是否排队等空闲 | 否,直接新建 | 是,可配置阻塞等待 | 否,超出的请求进入 dispatcher 队列 |
| 连接池监控和状态查询 | 无公开 API | 有 ConnPoolControl 接口 | 无直接监控 API |
从这个表能看出来,JDK HttpClient 的连接池在“可配置性”上几乎是做减法的。它假设连接应该是“用完就尽量留着,不要管那么多”,而不像 Apache HttpClient 那样什么都开放给你调。
顺带说一句,OkHttp 虽然也没有“最大总连接数”这个配置,但它借着 Dispatcher 的 maxRequests / maxRequestsPerHost 从应用层限制了并发请求数,所以同样的并发连接问题在 OkHttp 里可控性更强。JDK HttpClient 在这个维度可以说是“裸奔”的,你连 Dispatcher 这种东西都没有,所以更需要在应用层自己控制。
2. ConnectionPool 内部到底怎么转
2.1 一个路由怎么定义:CacheKey 的构成
既然要讲连接池工作机制,就绕不开“路由”这个概念。JDK 的 ConnectionPool 内部用CacheKey来区分不同的路由。一个 CacheKey 大致由下面这些要素组合而成:
- 目标地址:host + port,或者说是 URI 里的 authority 部分;
- 是否走 TLS(ssl 标志);
- 是否经过代理(Proxy);
- 是否带 Authenticator(身份认证信息)。
两个请求如果这些要素完全一致,就会被认为属于同一个路由,共用同一批空闲连接;只要有一个要素不同,就是完全独立的路由,各管各的连接。
在 ConnectionPool 内部,数据组织是两个 HashMap:plainPool和sslPool。一个存明文 HTTP 连接,一个存 TLS 连接。因为 TLS 连接建立开销大,复用价值更高,JDK 把这两种连接分开管理,避免取连接时还要逐个判断协议类型。
// JDK 内部实现摘要,位于 jdk.internal.net.http.ConnectionPool private final HashMap<CacheKey, LinkedList<HttpConnection>> plainPool; private final HashMap<CacheKey, LinkedList<HttpConnection>> sslPool;每个 CacheKey 对应一个 LinkedList,里面存的是该路由下、当前空闲的 HttpConnection。取出时从链表头部拿,归还时放到链表尾部,整体就是个普通的 LRU 味道的队列,但 JDK 并没有做严格的按访问时间排序,只是“头取尾放”的最简实现。
2.2 取连接、归还连接、丢弃连接
一次标准的 HTTP/1.1 请求,和连接池的交互大致是下面这几步。
取连接阶段:请求发出前,HttpClient 根据目标地址等信息生成 CacheKey,然后去对应的池子(plainPool 或 sslPool)里找有没有空闲连接。找出来之后不能直接用,还要检查这条连接是否仍然可用:Socket 是否还开着、是否已经被对端关闭、有没有超出 keep-alive 的有效期。只要有一项不满足,就丢弃这条连接继续找下一条;池子翻空了都没有可用连接,就新建一条 TCP 连接。
归还连接阶段:响应体读完之后,如果这条连接符合继续复用的条件——没有异常、服务端没有要求关闭、协议还允许 keep-alive——就会尝试归还给 ConnectionPool。归还时 JDK 会做一些检查,其中比较关键的一个是:连接池里的空闲连接总数是否已经达到上限。如果池子已经满了,这条连接不会放进去,而是直接关闭。
丢弃连接阶段:什么情况下连接会被丢弃?最常见的是从池子里取出来的时候发现连接已经死了,或者连接空闲时间超过了 keep-alive 窗口,由清理逻辑处理掉。注意,这个“清理”发生在连接复用的路径上,而不是有一个专门的守护线程时刻盯着所有连接。
这段逻辑也解释了最经典的坑:服务端空闲超时设置为 60 秒,客户端连接池里的连接闲置了 90 秒后再被取出来用,发送请求后才发现服务端早就把这条连接关了。JDK 的取连接逻辑只能确认“Socket 对象还在”,但它没有办法提前感知对端已经悄悄关闭了这条连接。
2.3 空闲连接何时被清理
JDK 的 ConnectionPool 没有像有些客户端那样启动一个专门的后台线程反复扫描所有连接,它的清理触发点是分散在几个路径上的:借出连接时、归还连接时、以及 SelectorManager 线程处理事件时。
每个连接在被归还到池里的时候,JDK 会计算出一个“过期时间”。这个时间主要参考服务端返回的Keep-Alive: timeout=响应头,如果服务端没给,就按 JDK 内部默认策略来计算。过期时间还没到的连接,可以继续待在池子里;一旦到了过期时间,等到下次被扫描到,就会被关闭并从池中移除。
ConnectionPool里维护了一个按过期时间排列的ExpiryList,清理时只要从表头开始处理到期项就行,不需要遍历所有连接。这个设计比较简洁,但副作用是:如果一段时间内没有新请求、也没有归还动作,那些已经过期的空闲连接不会立刻被关闭,而是会再存活一会儿,直到某个操作触发了清理。
这也是为什么大家在线上用ss看连接状态时,经常会看到一些已经空闲很久却没有被关闭的连接。JDK 走得是“懒清理”路线,不像有些客户端会严格按计划任务去销毁过期连接。
3. 最大连接数和每路由连接数:JDK 给你什么、不给什么
3.1 HTTP/1.1 的真实行为:并发请求会拆成多少连接
先明确一个前提:JDK HttpClient 不支持 HTTP/1.1 的 pipelining(管线化),而且 HTTP/1.1 协议本身是一个连接同一时刻只能处理一个请求/响应。
这就带来一个非常关键的结论:在 HTTP/1.1 下,同一路由的并发请求数,基本等于实际建立的连接数下界。如果你一口气发起 100 个并发请求到同一个 host,而连接池里没有 100 条空闲连接可用,HttpClient 就会实时创建新的连接,最终这个路由下的连接数会非常接近 100。
我在之前的网关项目里实测过:HTTP/1.1 场景,50 个并发请求打同一个后端服务,ss -tn看过去就是 50 条 ESTABLISHED 连接,分毫不差。而且因为很多请求响应很快,请求结束后连接回到池子里,后面再有新请求又取出来用,但整体并发连接数始终是由“同时在途的请求数”决定的。
所以你要真想控制 HTTP/1.1 场景下每个路由的连接数,思路必须转换:连接池本身不管这个事,你要控制的是“同一时刻到同一 host 的并发请求数”。
3.2 HTTPS/2 为什么每路由天然只有一个连接
如果服务端支持 HTTP/2,情况就完全不一样了。
HTTP/2 引入了多路复用机制,一条 TCP 连接上可以同时跑很多个 stream,每个 stream 对应一个请求/响应。JDK HttpClient 对同一个路由只会维持一条 HTTP/2 连接,后续的所有并发请求全部在这条连接上以 stream 的形式发送,不需要再新建连接。
这意味着,在 HTTP/2 场景下,“每路由连接数 = 1” 是天然成立的事实,你不需要去配置什么,也不应该试图去改它。
需要提醒的是,JDK HttpClient 默认请求时使用 HTTP/2 优先的版本协商策略。客户端向服务端发起 TLS 握手时,通过 ALPN 扩展协商协议;如果服务端支持 HTTP/2,就直接用 HTTP/2 通信;如果服务端只支持 HTTP/1.1,则自动降级到 HTTP/1.1。这个降级过程非常隐蔽,很多同学以为设置了.version(HttpClient.Version.HTTP_2)就已经在跑 HTTP/2 了,实际上服务端没开启 HTTP/2 时会悄悄退回 HTTP/1.1,然后连接数又开始疯狂上涨。排查的时候一定要先用日志或者抓包工具确认协议版本真的协商成了 h2。
3.3 jdk.httpclient.connectionPoolSize 到底控制什么
JDK 官方虽然没有在 API 层面开放连接池配置,但在 JDK 源码里是存在一个内部系统属性的:jdk.httpclient.connectionPoolSize。
这个属性在ConnectionPool类中被读取,作用是用来限制连接池中保留的空闲连接的总数上限。当归还连接时,如果发现当前空闲连接总数已经超过这个值,就会拒绝把连接放回池子,而是直接关闭。
注意几个关键点:
- 它限制的是“空闲连接数”,不是“并发连接总数”。正在被请求使用的连接不在这个计数范围内。
- 它是 JDK 内部属性,从未出现在官方 API 文档里。不同 JDK 版本的默认值和行为不完全一致,不保证未来版本仍然兼容。
- 它按“整个 HttpClient 实例”维度生效,不是按路由维度。也就是说它没有提供 per-host 或 per-route 的限制能力。
使用方式是在启动 JVM 时加参数:
java -Djdk.httpclient.connectionPoolSize=200 -jar app.jar如果你真的需要减少闲置连接堆积,把它调小是有效的。但如果你想靠它限制“每一路由最多能开多少条连接”,那做不到,别抱期望。
我给一个判断依据:如果你在代码里找不到HttpClient.Builder上的任何连接池参数,就说明 JDK 有意把这件事留给了开发者自己。官方给出的默认 HttpClient 是全局共享的,它更关心请求效率和连接复用,而不是替你做细粒度的资源管控。
4. 真要限制连接数,这几个方案能用
4.1 用 Executor 限制并发,间接控制连接规模
因为 JDK HttpClient 是异步设计,虽然send()方法看起来是同步阻塞的,但它内部的任务调度都发生在你通过executor()传入的线程池里。换句话说,同一时刻真正在发请求的任务数,受制于这个 Executor 的线程数。
基于这个特性,最简单的连接数控制办法是:给 HttpClient 配一个固定大小的线程池。
ExecutorService executor = new ThreadPoolExecutor( 20, 20, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>(1000), new ThreadFactory() { private final AtomicInteger index = new AtomicInteger(1); @Override public Thread newThread(Runnable r) { Thread t = new Thread(r, "http-client-worker-" + index.getAndIncrement()); t.setDaemon(true); return t; } } ); HttpClient client = HttpClient.newBuilder() .executor(executor) .version(HttpClient.Version.HTTP_1_1) .build();当你的业务代码调用client.send()时,请求任务实际上是在这 20 个线程上执行的。即使你从外面往提交队列里塞几千个任务,同一时刻真正在和远端建立连接、发送请求的只有 20 个,所以连接数自然就被压住了。
这个方案的优点是改动小、思路直接,缺点也很明显:它限制的是全局请求并发度,做不到按路由精细控制。如果你的服务同时调用多个后端,A 后端抢占了大部分线程资源,B 后端的请求就得排队等着,某种程度会有“路由间互相影响”的问题。
4.2 基于路由的信号量限流方案
比 Executor 更精细的做法是加点“私货”:按路由维护单独的 Semaphore。想让人数不超过 threshold,就在请求前 acquire,请求完成后 release。
下面是我后来在项目里用的方案,核心就一个类:
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Semaphore; public class RouteConnLimiter { private final ConcurrentHashMap<String, Semaphore> routeLimiters = new ConcurrentHashMap<>(); private final int maxPerRoute; public RouteConnLimiter(int maxPerRoute) { this.maxPerRoute = maxPerRoute; } public <T> T runWithPermit(String route, IOFunction<T> action) throws Exception { Semaphore semaphore = routeLimiters.computeIfAbsent(route, r -> new Semaphore(maxPerRoute)); semaphore.acquire(); try { return action.apply(); } finally { semaphore.release(); } } @FunctionalInterface public interface IOFunction<T> { T apply() throws Exception; } }使用的时候,以“host:port”作为 route:
RouteConnLimiter limiter = new RouteConnLimiter(10); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("http://backend:8080/api/order")) .GET() .build(); String body = limiter.runWithPermit("backend:8080", () -> client.send(request, HttpResponse.BodyHandlers.ofString()).body() );这样就能保证同一时刻只有 10 个请求在访问backend:8080,对应到 HTTP/1.1 场景,连接数也就不会超过 10 条左右。
如果你用的是sendAsync()异步调用,信号量的释放时机要往后挪,放到CompletableFuture的回调里:
Semaphore semaphore = limiter.semaphoreFor("backend:8080"); semaphore.acquire(); CompletableFuture<HttpResponse<String>> future = client.sendAsync(request, HttpResponse.BodyHandlers.ofString()); future.whenComplete((resp, throwable) -> semaphore.release());这个方案比 Executor 精准,能真正做到按路由隔离。不过注意Semaphore.permits默认是非公平模式,高并发下可能出现某些线程长时间等不到许可的情况,必要时可以改用new Semaphore(maxPerRoute, true)开公平模式。
4.3 HTTP/2 + 并发限制:常规场景的组合拳
如果你的服务端是自研的,最好直接用 HTTP/2。原因很简单:HTTP/2 单连接多路复用,从根上消灭了“连接数爆炸”这个问题。
但 HTTP/2 也不是完全没有并发限制需要考虑的对象。一条 HTTP/2 连接上虽然能开很多 stream,但当 stream 数量过多时,会受流量控制窗口和并发流数上限的影响。服务端可以通过 SETTINGS_MAX_CONCURRENT_STREAMS 参数限制客户端在一条连接上同时开的 stream 数。JDK HttpClient 遇到这个限制时,新请求会排队等待。
所以,HTTP/2 场景下连接的瓶颈从“TCP 连接数”转移到了“stream 并发数”,控制并发请求数量依然有意义,只是你不用再关心底层连接数了。
我的建议是:
- 目标服务支持 HTTP/2,优先开启 HTTP/2;
- 服务端不可控、必须走 HTTP/1.1 的,结合信号量按路由限流;
- 多个后端服务混合调用,不要用一个固定线程池控制全局,否则容易出现路由间互相影响。
5. 排查 ConnectionPool 相关问题时我常用的三板斧
5.1 打开 HttpClient 调试日志看真相
JDK HttpClient 内置了一套日志机制,通过系统属性开启。加在哪里都行,JVM 启动参数最方便:
java -Djdk.httpclient.HttpClient.log=all -jar app.jar日志级别可以更细,例如errors,requests,headers,connect是常用组合:
java -Djdk.httpclient.HttpClient.log=errors,requests,headers,connect -jar app.jar打开 connect 日志后,你能看到类似下面的输出(不同 JDK 版本格式略有差异):
[DEBUG] ConnectionPool: proxy=DIRECT ssl=false address=backend/10.0.0.12:8080 cache hit [DEBUG] ConnectionPool: proxy=DIRECT ssl=false address=backend/10.0.0.12:8080 created new connection [DEBUG] Http1Connection: [HttpClient-1] connection opened这里“created new connection”出现的频率,就是你连接池未命中的频率。如果每来一个请求都打一条 created new connection,说明池子里基本没有可复用的空闲连接,要么是请求并发太高,要么是连接空闲时间太短导致频繁过期。
日志里还可以看到 HTTP/2 的协商结果:如果协商失败自动降级到 HTTP/1.1,日志里会有相关提示。定位“我以为在跑 HTTP/2 其实在跑 HTTP/1.1”的问题时,这一步最直接。
5.2 用 ss 命令确认真实连接数
日志能告诉你连接池的逻辑状态,但要确认真实的 TCP 连接数量,还是得靠系统工具。
统计到某个目标端口的连接数:
ss -tn state established '( dport = :8080 )' | wc -l统计某个 Java 进程建立的所有连接,并按状态归类:
ss -tanp | grep java | awk '{print $1}' | sort | uniq -c注意看 ESTABLISHED 和 TIME_WAIT 两类状态。ESTABLISHED 数量高说明当前有大量连接正活着;TIME_WAIT 数量高说明连接被频繁建立和关闭,通常对应连接池级别没做好复用,或者是服务端主动关闭了大量连接。
有一个典型场景能通过这组命令一眼看出问题:压测结束后,ESTABLISHED 掉下来了但 TIME_WAIT 有几千条,大概率是请求都走完了,连接没有被客户端留下,或者服务端返回了Connection: close。到这一步,再配合 HttpClient 日志去确认是哪一端在决定关闭连接,基本就能定位。
5.3 三个高频坑
踩过几轮之后,我把和 JDK HttpClient 连接池相关的常见问题总结成三个高频坑,供大家避雷。
坑一:每个请求都 new 一个 HttpClient
这是最伤的一个。JDK HttpClient 本身是线程安全的设计,官方建议全局复用同一个实例。如果你在每次请求前都HttpClient.newBuilder().build(),等于每来一次请求就创建一个全新的连接池、一套全新的 SelectorManager 线程。连接池无法跨 HttpClient 复用,连接数暴涨,线程数也一起暴涨。
正确做法是:一个服务里,针对一组相同配置,维护一个单例 HttpClient。最省事的是在 Spring 场景下用@Bean单例,或者其他容器环境里用静态字段持有。
坑二:长连接被服务端断开后,客户端仍然从池里取出来用
表现是:服务跑一段时间后,突然冒出一批请求报java.io.IOException: HTTP/1.1 header parser received no bytes,而且往往集中在空闲了一段时间后的第一批请求上。
原因是:连接池里的连接存得太久,服务端早就把它关了,但客户端不知道,仍然从池里取出来发请求。JDK 在部分场景下会对幂等请求自动重试一次,但如果请求已经在发送过程中才发现的连接失效,抛错就非常干脆。
针对这个坑,我验证过的有效办法是:
- 服务端和客户端约定合理的 keep-alive 时间,客户端的空闲时间尽量短于服务端;
- 在业务层对幂等请求做一次重试包装,捕获到这种 IOException 后重试;
- 如果你使用的 HttpClient 是全局单例,而且对连接复用要求高,考虑在低峰期主动对目标服务发一个探测请求,把可能过期的连接换掉。
坑三:把jdk.httpclient.connectionPoolSize当并发控制来配置
前面说过,这个内部属性只管空闲连接总数,不限制活跃连接数。如果你希望“超过这个数就阻塞新请求”,它会让你失望。我也是在压测环境里调了半天这个参数,连接数该涨还是涨,最后读源码才发现理解错了方向。
这类内部属性,看一眼、知道能影响什么就够了,不要把它当作一个可靠的配置面来依赖。跨 JDK 小版本升级后行为可能就变了。
结合这几个手段,我后来再遇到连接数异常的情况,排查链路基本固定了:先看协议版本是不是 HTTP/2,再看 HttpClient 是不是单例,然后看日志里 created new connection 的频率,最后用 ss 确认真实连接数。哪一步发现问题,就在哪一步针对性解决。
JDK HttpClient 的 ConnectionPool 并不复杂,它就是个很纯粹的空闲连接复用器,远没有 Apache HttpClient 那样的管理颗粒度。搞懂它的边界之后,你就会明白:在 JDK 的体系里,限制连接数是应用层自己的职责,而不是靠调一个隐藏参数就能糊弄过去的。