文件描述符上涨不是 GC 问题,是 Netty 连接没关干净
上个月帮一个做即时通信的项目排查内存问题。监控显示服务启动后内存一直缓慢上涨,起初以为是堆内存泄漏,用 JProfiler 抓了 Heap Dump 对比,对象分布看起来很正常——没有明显的大对象滞留,HashMap 和 List 的容量也和预期一致。
但进程 RSS 确实在涨,而且 CPU 占用率也慢慢上去了。把 lsof -p <PID> 跑了一下,才发现文件描述符数量从几百飙到了八万以上,而且大部分是 socket 类型的 FD。
这时候才意识到,问题不在堆内存,而在网络连接。
为什么 FD 会和连接挂钩

Linux 系统里,文件描述符不只是用来打开文件的。套接字、管道、事件fd、定时器fd,甚至 Netty 用的 epoll,全部占用 FD 配额。一个进程的 FD 上限通常是 1024,生产环境一般会调到 65535 甚至更高,但这个上限是真实存在的。
Netty 服务端每接受一个新连接,就会分配一个 NioSocketChannel,底层对应一个 Java 的 SocketChannel,再往下是一个 epoll fd。这个 fd 的生命周期应该由 Netty 管理,连接关闭时自动释放。但如果某个环节没有正确触发关闭,fd 就会一直占着不走。
更隐蔽的是,Netty 的连接关闭涉及多个层次:TCP 层面的 close、Netty 层面的 channelInactive、业务层面的资源清理。任何一个环节出问题,都可能导致 fd 泄漏。
泄漏通常发生在哪里
最常见的场景是 handler 链里没有正确释放资源。比如自定义了一个 ChannelInboundHandlerAdapter,在 channelRead 里处理消息时,如果因为异常提前 return,而后续需要关闭的 channel 没有被关闭,或者分配的 DirectBuffer 没有释放,fd 就漏了。
public class MessageHandler extends ChannelInboundHandlerAdapter {
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
try {
// 处理消息
process(msg);
} catch (Exception e) {
// 只记了日志,忘了关 channel
log.error("处理失败", e);
}
}
}
这个例子看着眼熟,但在实际项目里,问题往往比这复杂得多。有时异常被上游捕获后吞掉,channel 的状态变得不明确;有时是心跳检测机制误判,把活跃连接当成不活跃关闭了,但实际上连接还在用。
另一个高发区是自定义的 ChannelPool 或连接复用逻辑。有些项目为了控制连接数,自己维护了一个连接池,复用的是 Bootstrap 或者 NioSocketChannel。如果池化逻辑在归还连接时没有调用 close() 而是直接放回队列,下次取出来使用时,底层的 fd 其实是旧的、可能已经处于半关闭状态的。
// 错误的连接池实现
public class BadChannelPool {
private final Queue<Channel> freeChannels = new ArrayBlockingQueue<>(100);
public Channel acquire() {
Channel ch = freeChannels.poll();
if (ch != null && ch.isActive()) {
return ch; // fd 可能已经接近超时
}
return createNew();
}
public void release(Channel ch) {
freeChannels.offer(ch); // 没有 reset 状态,fd 悬空
}
}
还有一种容易被忽略的情况:自定义的 EventLoop 或者 BossGroup/WorkerGroup 没有被正确 shutdown。Netty 的服务端启动时,会创建两组线程池,Boss 负责接受连接,Worker 负责处理 IO。如果应用关闭时只调用了 bossGroup.shutdownGracefully() 而忘了 workerGroup,或者反过来,已接受的连接就会卡在 Worker 的队列里,fd 不会被释放。
怎么定位到底哪里漏了
光看 lsof 只能知道 FD 多了,不知道是谁开的。真正的定位需要结合多个工具。
第一步,确认泄漏的模式。是稳定上涨还是阶梯式上涨?稳定上涨通常是持续接受新连接但没关闭;阶梯式上涨则可能是批量建立连接后一次性没释放,比如某个定时任务每次触发都会创建一批连接但忘记清理。
第二步,抓取当前的 FD 列表,结合 Netty 的 Channel 对象进行关联。Netty 的 Channel 有一个 id() 方法,返回的是 ChannelId,底层对应一个随机的十六进制字符串。可以通过 JMX 或者 Actuator 暴露出当前活跃的 Channel 列表,看看数量和 FD 数量是否匹配。
如果项目用了 Micrometer 或者 Spring Boot Actuator,可以直接看 /actuator/metrics/jvm.threads 和自定义的 Netty Channel 指标。没有的话,可以写一个简单的 ChannelActivityListener,在 channelActive 和 channelInactive 里计数,和生产环境的 FD 数量做对比。
public class ChannelCountListener extends ChannelDuplexHandler {
private final AtomicLong activeCount = new AtomicLong();
@Override
public void channelActive(ChannelHandlerContext ctx) {
activeCount.incrementAndGet();
log.info("活跃连接: {}", activeCount.get());
}
@Override
public void channelInactive(ChannelHandlerContext ctx) {
activeCount.decrementAndGet();
log.info("活跃连接: {}", activeCount.get());
}
public long getActiveCount() {
return activeCount.get();
}
}
把这个 listener 加到 pipeline 的最前面,定期打印 activeCount。如果这个数字持续增长,而 FD 数量也在增长,就能确认是连接没关干净。
第三步,找到具体是哪个 handler 或者哪段代码导致的问题。这时候需要借助 netstat 或者 ss 命令,看 socket 的状态。CLOSE_WAIT 状态过多通常意味着本地应用没有主动关闭连接,对端发了 FIN 但本地没有回 ACK;TIME_WAIT 过多则是正常现象,说明连接已经关闭但内核还在等待超时;如果是 ESTABLISHED 持续增长,那就是连接一直没断开。
# 查看各状态的连接数
ss -tan | awk 'NR>1 {print $1}' | sort | uniq -c | sort -rn
# 查看特定进程的 socket
lsof -p <PID> -i
如果 CLOSE_WAIT 很多,需要排查是不是对端(客户端)关闭了连接,而服务端的 handler 没有调用 ctx.close() 或者 channel.close()。这种情况常见于客户端异常断开,而服务端还在等数据。
几个容易踩的坑
第一个坑是误以为 channel.close() 就够了。 Netty 的 close() 是异步的,调用之后要等 closeFuture().sync() 才能真正释放资源。如果在 close() 之后立刻返回,后续的代码可能还在用这个 channel,而底层的 fd 已经处于关闭流程中,造成状态混乱。
第二个坑是 ChannelFutureListener 里忘记处理异常。 很多代码在关闭 channel 时会加一个 listener:
channel.close().addListener(future -> {
if (!future.isSuccess()) {
log.error("关闭失败");
}
});
看起来没问题,但如果 log.error 本身抛了异常,或者 listener 的执行上下文有问题,可能导致整个 close 流程卡住。更稳妥的做法是把 listener 的逻辑尽量简化,异常处理放在外层。
第三个坑是 Bootstrap 的重复使用。 有些项目喜欢把 Bootstrap 或者 ServerBootstrap 当成单例复用,每次接受连接都调 bind()。实际上 bind() 只是绑定端口,不会创建新的 channel;但如果绑定成功后没有正确 unbind(),再绑定时可能会复用旧的 channel,导致 fd 累积。正确的做法是在应用启动时 bind 一次,关闭时 unbind 并 shutdown group。
第四个坑是自定义 IdleStateHandler 的参数配置不当。 IdleStateHandler 会在读空闲、写空闲或读写空闲时触发 userEventTriggered。如果配置的空闲时间太短,会把还在正常通信的连接误判为空闲并关闭,导致连接反复建立,fd 数量震荡;如果配置太长,又起不到检测泄漏的作用。一般建议根据业务的最小心跳间隔来设置,留有一定的余量。
预防比排查更重要
文件描述符泄漏最麻烦的地方在于,它不会立刻让你服务挂掉,但会慢慢蚕食系统的资源。等到 lsof 显示八万多个 fd 时,往往已经积累了好几天甚至几周。
所以比较好的做法是在服务启动时就设定一个基准值,记录当前的 FD 数量和活跃连接数,然后每隔一段时间采样对比。如果发现 FD 数量持续增长而活跃连接数没有相应增长,就说明有泄漏。这个检查可以放在一个定时任务里,也可以接入 Prometheus 之类的监控系统,设置告警阈值。
另外,代码审查时要把 channelInactive 和 channelUnregistered 这两个生命周期方法当成重点。凡是涉及网络连接关闭的逻辑,都应该有对应的资源释放。如果看到某个 handler 重写了 channelRead 但没有处理异常情况下的 channel 关闭,就要警惕了。
还有一个实用的习惯:在测试环境故意制造连接异常断开的场景,比如用 kill -9 杀掉客户端进程,或者用 tcpkill 切断连接,然后观察服务端的 FD 变化。如果 FD 没有回收,说明关闭逻辑有问题。这种压力测试成本低,但能暴露很多平时注意不到的问题。
Netty 本身的设计是健壮的,绝大多数情况下只要按照标准模式使用,不会出问题。但如果要在复杂场景下做连接管理、自定义 handler 链、或者复用连接池,就需要格外注意资源的生命周期。文件描述符的持续增长就是一个明确的信号,告诉你有什么东西没有关干净。抓住这个信号,顺着渠道往下查,通常能找到根因。

浙公网安备 33010602011771号