凌晨一点,监控告警把我和值班同事同时震了起来:Java服务日志里刷出了一片刺眼的报错——java.net.SocketException: No buffer space available (maximum connections reached?): connect。当时我的第一反应是内存不足,毕竟报错里带着 buffer space 字样,结果内存查了一圈完全正常,CPU 也不高,但服务就是大面积超时。折腾了两个多小时,最后才发现:根子根本不在 JVM 里,而是这台 Windows 机器上的 TCP 动态端口被榨干了。
这个坑我踩过,身边不少同行也踩过,相关搜索热度一直不低。但很多帖子只给一句“执行 netsh 扩大动态端口范围”,不讲为什么,读完能抄但不懂原理,换台机器换套环境又不会排查了。这篇文章把我完整的排查链路、背后的 TCP 工作原理、系统参数调整和应用层改造方案全部写清楚,给所有跑在 Windows 上的 Java 应用做个参考。
1. 从一条日志到服务雪崩:报错的真实场景与表象
1.1 日志现场的典型面貌
当时服务日志里出现的完整异常长这样:
java.net.SocketException: No buffer space available (maximum connections reached?): connect at java.base/java.net.Socket.connect0(Native Method) at java.base/java.net.Socket.connect(Socket.java:634) at java.base/sun.nio.ch.NioSocketImpl.connect(NioSocketImpl.java:139) ...注意报错信息后半段自带的括号提示:(maximum connections reached?),这是 Windows 底层在告诉你“连接数可能到顶了”。调用栈通常指向Socket.connect或SocketChannel.connect,也就是代码里主动发起的出站 TCP 连接操作。也就是说,这个错不是接收连接时爆的,而是“主动连别人”时爆的。
1.2 最容易踩坑的三类场景
我复盘了自己遇到的情况以及帮别人排查过的案例,基本逃不出下面这几类:
- Windows 服务器上跑 Java 服务,对外高频发起 HTTP/RPC 调用。这是最常见的一种。业务逻辑里每来一个请求,就要向下游服务发一次请求,而下游服务又没开连接池,导致每次请求都新建连接。
- 定时任务批量并发拉数据。比如每天凌晨整点跑批,几十个线程同时向外连数据库、连第三方接口,瞬间把连接数拉高。
- 压测环境。用 JMeter 或自研脚本压测时,QPS 一上来,Java 应用作为调用方疯狂建连接,几分钟之内端口池就空了。热搜里也有
jmeter报错org.apache.http.conn.httphostconnectexception这类关键词,说明压测阶段遇到连接问题非常普遍。
我自己的案例是定时任务触发的:每天凌晨有几十个批任务同时启动,每个任务都新建 HTTP 连接去拉数据。峰值时每秒新建连接接近 150 个,Windows 默认只给 16384 个动态端口,坚持不到两分钟就开始大面积抛错。
1.3 很容易被误判的“假线索”
遇到这个报错,最坑的是它自带误导属性。我第一反应是查内存,相信很多人也一样,结果 JVM 堆没问题,物理内存也没问题,直接白查了半小时。
这里先把容易出现误判的几个方向列出来,方便你对照排除:
- 以为是 JVM 堆内存或物理内存不足:报错里“buffer space”这个词实在太像内存告警了。实际上如果内存不足,通常会先有
OutOfMemoryError,而不是SocketException。 - 以为是防火墙或网络策略拦截:防火墙拦截连接时一般是
Connection refused或Connection timed out,不会出现 “No buffer space available”。 - 以为只是代码里“忘记关闭连接”导致的句柄泄漏:连接泄漏确实会让句柄数上升,但就算每条连接都正确关闭,只要建立频率够高,TIME_WAIT 依然会堆积,照样报这个错。
真正应该做的是:把目光从 JVM 转移到操作系统网络协议栈。下面我逐步拆解这个错误的本质。
2. WSAENOBUFS 的真面目:No buffer space available 到底缺的是什么
2.1 Windows 错误码 10055 的官方含义
在 Windows 平台上,SocketException背后映射的是 Winsock 错误码WSAENOBUFS(10055)。微软官方文档描述是:由于系统缺少足够的缓冲区空间,或者因为队列已满,无法对套接字执行某项操作。
关键点在于,这里说的“缓冲区”并不是我们脑子里的那根内存条,而是 TCP/IP 协议栈为每个套接字分配的传输缓冲区,以及发起出站连接时需要的临时端口资源。你主动connect一个远端地址时,内核要做两件事:第一,从动态端口范围里挑一个空闲的临时端口;第二,为这个套接字分配发送/接收缓冲区。这两件事任何一个做不成,都报 buffer space 相关错误。
在实际高并发场景下,缓冲区内存一般够用,真正被抢光的是临时端口池,也就是俗称的动态端口。
2.2 临时端口的一生:从被选中到 TIME_WAIT 释放
每个 TCP 连接由四元组唯一标识:本地 IP + 本地端口 + 远端 IP + 远端端口。对外发起连接时,本地端口不是你指定的,而是内核从动态端口范围里自动分配的。连接关闭之后,这个端口不会立刻回到可用池,而是进入TIME_WAIT状态,等待 2MSL(Maximum Segment Lifetime,报文最大存活时间)之后才能复用。Windows 上默认 2MSL 是 120 秒,也就是有一个TcpTimedWaitDelay注册表参数在控制。
打个比方:这就像餐厅的叫号小票。客人拿号入座,结账离开后,这个号不能马上发给下一个人,得等服务员确认上一桌的账单、餐具、遗留物品都处理干净了,号码才敢再发出去。TCP 也是这个逻辑——旧连接的迟到报文可能还在网络中游荡,如果立刻把端口复用给新连接,新连接就可能收到旧报文,导致数据错乱。
所以TIME_WAIT是 TCP 可靠性的基石,不能取缔。能做的只有两条路:缩短等待时间(TcpTimedWaitDelay调小),或者扩大可用端口池(动态端口范围调大)。
2.3 “maximum connections reached?” 的含义
报错信息里的(maximum connections reached?)是 Windows 附加的一句提示性文案。它其实非常直白:你发起的连接数已经触碰到了系统层面的上限。这个上限的物理承载者,就是动态端口池的剩余数量。
同一个端口理论上可以同时被多个连接使用吗?可以,只要四元组里的远端 IP 或远端端口不同就行。但在“一个 Java 服务向同一个下游服务发起大量并发连接”的典型场景里,远端 IP 和端口往往是固定的,能变化就只剩下本地端口,所以本地端口池就成了硬约束。一旦池子见底,新的connect请求就无法分配端口,异常随之而来。
2.4 什么时候要怀疑“另有原因”
动态端口耗尽是最常见的原因,但不能看到报错就无脑下结论。我给一个区分原则:如果netstat里 TIME_WAIT 连接数常年徘徊在万台级别,且动态端口范围只剩 20% 以内,那几乎可以确定是端口耗尽;如果连接数不高、端口余量充足,却依然报No buffer space available,这时候才需要去考虑非分页缓冲池(Nonpaged Pool)内存耗尽、安全软件驱动(LSP/WFP 驱动)占用资源等冷门问题。
3. 现场取证:三步定位动态端口耗尽的完整排查链路
被这个报错折腾过的人都知道,直接改注册表、放大端口范围是能“解决”,但不看清楚每个环节就动手,容易误判,也可能改完还是复发。我习惯按下面这套链路走一遍,从现象到根因全程取证,基本十分钟内心里有数。
3.1 第一步:确认机器系统与目标进程
先确认这套 Java 服务跑在什么系统上。No buffer space available这种措辞在 Windows 上出现频率最高;Linux 下一般是Cannot assign requested address,后面我会专门对照。确认命令:
tasklist /fi "imagename eq java.exe"找到对应 PID,一会儿netstat的结果要对着 PID 看,判断哪些连接是当前 Java 进程产生的。
3.2 第二步:用 netstat 统计连接状态分布
在管理员 CMD 或 PowerShell 里执行:
netstat -ano | findstr TIME_WAIT | find /c "TIME_WAIT"这个命令会直接数出当前机器的 TIME_WAIT 连接总数。如果数量已经过万,大概率中招了。更精细一点,把结果导出到文件,再区分是哪个 PID 的:
netstat -ano | findstr TIME_WAIT > timewait.txt findstr "<你的JavaPID>" timewait.txt | find /c "TIME_WAIT"还可以顺手看看ESTABLISHED(活跃连接)、CLOSE_WAIT(等待关闭)的数量:
netstat -ano | findstr ESTABLISHED | find /c "ESTABLISHED" netstat -ano | findstr CLOSE_WAIT | find /c "CLOSE_WAIT"这三个状态数字的意义完全不同:
| 连接状态 | 说明 | 排查含义 |
|---|---|---|
| TIME_WAIT 多 | 连接已关闭,端口在等待复用 | 建连频率太高,默认等待 120 秒导致堆积 |
| ESTABLISHED 多 | 当前活跃的连接很多 | 可能并发量真的很大,也可能连接没释放 |
| CLOSE_WAIT 多 | 连接已被对端关闭,本地程序没有调用 close | 几乎可以断定代码里有连接泄漏 |
我当时统计出来的结果是 TIME_WAIT 一万三千多,ESTABLISHED 两千多,CLOSE_WAIT 也有好几百。CLOSE_WAIT 的那几百个说明代码里还有没被关闭的连接,但即便忽略这些,光靠 TIME_WAIT 也已经把端口池快占满了。
3.3 第三步:查看动态端口范围
接下来看系统到底给了多少个可用端口:
netsh int ipv4 show dynamicport tcp netsh int ipv4 show dynamicport udp输出类似:
协议 tcp 动态端口范围 --------------------------------- 起始端口 : 49152 端口数量 : 16384这就是 Windows 出厂默认配置:动态端口从 49152 到 65535,总共 16384 个。也就是说,这台机器最多只能同时维持一万六千多个处于“占用”状态的出站连接(包括 TIME_WAIT、ESTABLISHED 等等)。
3.4 第四步:计算余量,判断是否踩线
用第一步统计出来的“占用端口总数”除以 16384,就是端口池使用率。比如 TIME_WAIT 一万三加上 ESTABLISHED 两千多,加起来已经一万五千多,使用率超过 90%。这个数字意味着:只要再来一波瞬时并发,端口池直接清空,新连接全部报错。
到这里,根因基本锁定了:动态端口池枯竭,端口消耗速度远大于释放速度。40 分钟级别的时间窗口内如果还没有恢复,再用资源监视器看网络活动,但一般走到这一步,该查的已经查清了。
4. 默认 16384 个端口为什么不够用:算一笔 TIME_WAIT 的账
4.1 端口占用的稳态公式
要理解为什么“看起来很多”的 16384 个端口这么容易爆,得先算一笔账。TIME_WAIT 的稳态数量约等于:
每秒新建连接数 × TIME_WAIT 等待时间(默认 120 秒)
举个直观的例子:
| 每秒新建连接数 | TIME_WAIT 等待 120 秒 | 是否逼近 16384 上限 |
|---|---|---|
| 50 | 6,000 | 安全,但已不宽裕 |
| 100 | 12,000 | 危险,余量不到 30% |
| 150 | 18,000 | 爆了 |
| 200 | 24,000 | 彻底雪崩 |
每秒只建 100 个连接,对一台业务机器来说高吗?不算高。很多 Java 服务在高峰期只要被三个上游同时调用,每个上游每秒三四十个请求,一下子就到这个量级了。
4.2 为什么“正确关闭连接”也救不了
这是我踩过最深的坑:代码里每条连接都正确 close 了,但服务还是报错。后来才反应过来,正确关闭只是让端口进入了 TIME_WAIT 而不是 CLOSE_WAIT,但只要连接是“短连接 + 高频创建”,TIME_WAIT 的堆积速度完全取决于“新建速率”,而不是“有没有关闭”。
正确关闭连接只解决了一半问题:把端口从“泄漏”变成了“正常等待复用”。可 Windows 默认要等 120 秒,每秒建 150 个连接,等 120 秒算下来就会积压 18000 个 TIME_WAIT,而端口池只有 16384,必然爆。
另一个放大因素是异常分支不关闭连接。比如代码里connect成功了,但在读写阶段抛出异常,没有走 finally 块,这条连接就挂在 CLOSE_WAIT 上没人管。CLOSE_WAIT 是连接被对端关闭、本地程序没关的状态,它会一直占着端口,比 TIME_WAIT 更可怕,因为 TIME_WAIT 至少 120 秒后会释放,CLOSE_WAIT 可能挂到天荒地老。
4.3 为什么 Windows 默认只给了 16384 个端口
这是历史设计留下的坑。Windows 动态端口的默认起始值是 49152,这原本是为了避开低于 49152 的“已注册端口段”,避免与系统服务、传统应用冲突。这个设计在二十年多前是够用的,当时一个应用服务器同时维持几百个连接就算重负载了。
今天完全不一样了:微服务架构下每个应用都是“连接大户”,一个 Java 服务可能同时要调用十几个下游,每个下游又要维持几十上百个连接,再加上短连接反复横跳,16384 个端口很快就不够看。我不是说微软设计错了,而是在现在的部署密度下,默认参数确实需要按实际负载重新调。
4.4 缩短等待时间的数学收益
如果把默认的 120 秒缩短到 30 秒,结果会立刻不同:每秒建 150 个连接,稳态 TIME_WAIT 变成约 4500 个;每秒建 300 个连接,也才 9000 个。端口池立刻回到安全水位。这也是为什么“扩大端口范围 + 缩短 TIME_WAIT”能成为标配组合拳的原因,两者不是在抢同一个空间,而是分别从“池子大小”和“占用时长”两头解决问题。
5. 两条腿走路:系统参数调整与 Java 应用层改造的配合方案
系统参数调整是止血,应用代码改造是根治。只调参数不改代码,端口池变大只是把爆炸时间往后推;只改代码不调参数,又可能在某些流量突刺时被打穿。正确的姿势是同时做。
5.1 系统侧:扩大动态端口范围
改动前先把当前配置备份下来,方便回滚:
netsh int ipv4 show dynamicport tcp netsh int ipv4 show dynamicport udp然后用管理员权限修改:
netsh int ipv4 set dynamicport tcp start=1025 num=60000 netsh int ipv4 set dynamicport udp start=1025 num=60000改完再查一次确认生效:
netsh int ipv4 show dynamicport tcp会看到起始端口 1025,端口数量 60000,也就是说动态端口池从原来的 16384 直接扩到 60000。为什么从 1025 开始?因为 1024 以下的端口大多被系统服务保留,从 1025 起跳能避开常规冲突,同时也更贴近 Linux 上ip_local_port_range的常用设置。
注意几点:
- 已经建立的连接不受影响,新发起的连接会从新范围里分配。有的机器立即生效,有的需要等一会或重启,按低峰变更窗口来操作。
- 不要把范围扩得太满。有人喜欢直接
start=1 num=65535,其实没必要,1024 以下的保留段一旦占用容易和其他服务打架;留一点余量给系统,运维更省心。 - 改完动态端口范围后,可以用
netstat -ano观察新连接是否落到了新范围(比如本地端口是否出现了 1025 以上的)。
5.2 系统侧:缩短 TIME_WAIT 等待时间
第二个参数是TcpTimedWaitDelay,注册表位置:
HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters在该路径下新建或修改 DWORD 值:
TcpTimedWaitDelay:设为30(十进制,单位秒)MaxUserPort:设为65534(十进制)
微软官方文档对TcpTimedWaitDelay的建议一般是最低 30 秒,我生产环境里也按 30~60 秒来设置,实测稳定,没有因为缩短等待时间出现过数据延迟报文串连的问题。改完注册表一般需要重启机器,或者重启 TCP/IP 协议栈(不建议直接重启协议栈,会断掉本机所有网络连接),稳妥做法是安排在重启窗口执行。
这里补充一个容易混淆的点:MaxUserPort是早期 Windows TCP/IP 参数,netsh动态端口范围是更上层的配置入口。在较新的 Windows 版本上,实际限制以 netsh 动态端口范围为准,两者取更严格的那个生效。所以单独调MaxUserPort而不管 netsh,可能没有效果;正确做法是先扩 netsh 动态端口范围,注册表里的等待时间调节作为辅助。网上很多老教程只提注册表,移植到新系统上容易踩坑,这点要留意。
5.3 应用侧:用连接池替代“每次新建”
不管系统参数怎么调,应用层最该做的还是降低建连频率。能复用就不要新建,这是对端口池最根本的减负。
我以一个 Java 服务最常使用的 Apache HttpClient 5 为例,连接池的最小配置长这样:
PoolingHttpClientConnectionManager connectionManager = PoolingHttpClientConnectionManagerBuilder.create() .setMaxConnTotal(200) // 整个连接池最大连接数 .setMaxConnPerRoute(100) // 到同一个目标主机的最大连接数 .build(); CloseableHttpClient httpClient = HttpClients.custom() .setConnectionManager(connectionManager) .evictExpiredConnections() // 剔除过期连接 .evictIdleConnections(60, TimeUnit.SECONDS) // 60秒空闲连接回收 .build();如果你用的是 SpringRestTemplate,默认的SimpleClientHttpRequestFactory是每次请求都新建连接,必须替换掉:
@Bean public RestTemplate restTemplate() { CloseableHttpClient client = HttpClients.custom() .setConnectionManager(connectionManager) .build(); ClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory(client); return new RestTemplate(factory); }如果你用的是OkHttp,它内置连接池,核心是配置maxIdleConnections和keepAliveDuration,同样能做到连接复用。总之原则就一条:让连接活着重复用,而不是用完立刻拆掉。
5.4 应用侧:资源释放和超时必须规范
连接池解决的是“高频新建”问题,但代码里如果仍然有裸Socket或裸连接,释放逻辑必须规范。最简单可靠的是 try-with-resources:
try (Socket socket = new Socket()) { socket.connect(new InetSocketAddress(host, port), 3000); // 连接超时3秒 // 业务处理... } catch (IOException e) { // 异常处理 }如果项目里确实有手动管理连接的老代码,请确保关闭动作放在 finally 里,无论正常流程还是异常流程都要执行到:
Socket socket = null; try { socket = new Socket(); socket.connect(...); // ... } finally { if (socket != null) { try { socket.close(); } catch (IOException ignored) {} } }还要给所有连接设置超时。connectTimeout如果不设,连接请求可能长期卡在SYN_SENT,端口一直被占着;readTimeout不设,对端不回应时会一直占着连接。这两个超时不仅关系到用户体验,也是端口池能否及时回收的关键。
5.5 两种手段的优先级安排
处理时机不同,侧重点也不同:
| 当前状态 | 优先动作 |
|---|---|
| 正在大面积报错,服务受影响 | 先扩大动态端口范围(netsh),必要时同时调低 TcpTimedWaitDelay 到 30 秒,快速止血 |
| 报错但服务还能扛 | 系统参数调整 + 应用层连接池改造一起做,缺一不可 |
| 新项目上线前 | 直接在代码里设计好连接池,系统参数按高并发模型预留,不要等上线后补救 |
我实际遇到的情况是“正在大面积报错”,当时先用 netsh 把端口池扩到 60000,再顺手把TcpTimedWaitDelay调到 30 秒,业务在几分钟内恢复。但后续仍然花了半天时间把所有调用点的连接池补齐,把裸连接改成 try-with-resources,才算真正把这个“已解决”钉死。
6. 别把 Windows 问题带到 Linux:跨平台部署的对照排查
如果你的服务会部署到 Linux 服务器,这个问题的表现形式不同,但底层逻辑完全一致。对照着看,以后切平台不会一头雾水。
6.1 Linux 上的对应报错
Linux 下动态端口池耗尽时,Java 日志里通常不是No buffer space available,而是:
java.net.BindException: Cannot assign requested address (Bind failed)有人看到BindException以为代码里绑定端口出了问题,其实在客户端主动出站连接的场景下,这个错误往往就是本地临时端口分配失败。同一个病,Windows 和 Linux 换了件马甲而已。
6.2 Linux 的排查与调整
先看当前端口范围:
cat /proc/sys/net/ipv4/ip_local_port_range默认通常是32768 60999,也就是只有约 28232 个端口。统计 TIME_WAIT:
netstat -ant | grep TIME_WAIT | wc -l调整参数(临时生效):
sysctl -w net.ipv4.ip_local_port_range="1024 65535" sysctl -w net.ipv4.tcp_fin_timeout=30 sysctl -w net.ipv4.tcp_tw_reuse=1要永久生效,写入/etc/sysctl.conf后执行sysctl -p。
这里必须强调两个容易踩坑的点。第一,tcp_tw_reuse只对主动发起连接的一方有效,也就是说 Java 服务作为客户端时才有意义;架设在服务器侧的监听连接,开启tcp_tw_reuse可能会引入数据串扰,但一般不会作为服务器调优手段。第二,tcp_tw_recycle在新内核已经移除,老内核开启它会对 NAT 环境下的连接造成严重干扰,不要碰这个参数。
6.3 容器化环境还多一层限制
在 Docker/K8s 环境里排查时,要先确认你看到的端口范围是宿主机命名空间的还是容器命名空间的。桥接网络模式下,容器出网流量做 NAT 时,宿主机的动态端口同样会被消耗。也就是说,即使容器里的 Java 应用已经把连接池做得很好了,宿主机端口池如果被大量并发短连接打爆,一样会报类似错误。这种场景下的排查要从容器和宿主机两边看,别只盯着 Java 进程本身。
7. 从“已解决”到“不再犯”:监控与日常体检
说实话,这个报错“解决一次”不难,难的是“不再犯”。服务一扩容、调用链一调整、流量一增长,老问题可能换个形式再回来。我现在会在收尾阶段把监控和日常巡检也一起做掉,这里分享几个自己长期用的手段。
7.1 给端口余量做一个简易看板
Windows 上可以写个简单的 PowerShell 采样脚本,定时把端口余量打到监控系统:
$total = (netsh int ipv4 show dynamicport tcp | Select-String "端口数量").ToString().Split(":")[1].Trim() $timewait = (netstat -ano | Select-String "TIME_WAIT").Count $established = (netstat -ano | Select-String "ESTABLISHED").Count $used = [int]$timewait + [int]$established $left = [int]$total - $used Write-Output "$(Get-Date) TIME_WAIT=$timewait ESTABLISHED=$established 剩余端口估算=$left"用计划任务每 1~5 分钟跑一次都行。告警阈值我一般设置成:剩余端口低于总端口 20% 且持续 5 分钟就发告警。这个指标比 CPU、内存更能提前反映“连接风暴”。
7.2 应用层连接池的可观测性
系统层面看端口,应用层还要看连接池。连接池有没有耗尽、活跃连接数有没有异常上涨、等待获取连接的线程数是多少,这些都要能实时看到。Apache HttpClient 的PoolStats、OkHttp 的连接池指标、数据库连接池的 active/idle 状态,都可以接入日志或指标系统。重点关注“每秒新建连接数”,这是端口池告警的前置指标,看到它蹭蹭上涨就该查代码了。
7.3 上线前的连接压测
新服务上线,或者老服务要扩容,建议先做一轮“连接压力验证”。用 JMeter 模拟调用方视角,对目标 Java 服务发请求,中间件看板里同时观察目标服务的出站连接数。如果在压测过程中 TIME_WAIT 曲线一路上扬、端口余量跌破 20%,那说明代码里的连接复用还有问题,或者连接池配小了,在正式流量进来之前就得修掉。这个环节便宜又有效,比上线后半夜被叫醒强太多。
7.4 最容易复发的三个原因
见过不少团队调完参数后隔几个月又踩同一个坑,根据我的观察,复发原因基本就这三种:
- 新增调用链没走连接池:业务代码加了个新的下游调用,开发图省事直接 new 连接,老连接池形同虚设。
- 扩容后连接数成倍放大:应用从 2 台扩到 10 台,每台都在建连接,系统参数没跟着同步评估。
- 机器镜像或重建后参数丢失:有些云主机镜像会重置 netsh 动态端口配置,机器重新拉起后默认值又回来了,需要把参数固化到初始化脚本或运维平台里。
这三点都不难防,难的是把它写进流程里。我的做法是把“动态端口范围配置”加进服务器初始化脚本,把“连接池是否启用”加进代码评审清单,让成本和门槛都变得极低。
这次把No buffer space available完整解决之后,我把“先查动态端口、再看连接池配置”固化成了 Java 网络报错的默认排查顺序。说句大实话,这个错十次里有九次不是内存问题,而是连接洪峰挤爆了临时端口;系统参数调一调很快,但真要稳,还是得让代码里的连接学会复用。希望这套从原理到实操的完整链条,能帮你少掉几根头发。