线上稳定性告警里,线程数飙升是非常典型的资源泄漏信号。它不像K8S OOM那样直接让服务挂掉,但会慢慢蚕食系统资源,最终引发卡顿、最后也会OOM导致进程崩溃。本文复盘一次典型的第三方客户端线程泄漏故障,完整还原从告警触发、线程dump分析、代码链路追踪到根因定位的全过程。
运维监控系统触发线程数阈值告警:某业务服务线程数超过2000告警阈值,实际峰值达到3498,远超正常服务百级线程的水位。
线程数随运行时间持续增长、流量平稳但只增不减,基本可以判定是线程泄漏——代码中不断创建新线程,但没有正常销毁,越积越多。
排查线程问题的标准动作,是使用 JDK 自带的 jstack 命令,导出当前进程的全部线程栈信息。
命令格式:
jstack 123其中 123 为目标 Java 进程的 PID(进程号),执行后会输出该进程内所有线程的名称、状态、调用栈等完整信息,输出到文件后即可做统计分析。

图简述:jstack 输出的原始线程栈片段,可见大量编号递增的同名线程,均处于 TIMED_WAITING 状态,调用栈统一指向空闲连接监控类。
拿到 dump 文件后,我们对线程名称、状态做批量统计,很快发现了异常特征:
Thread-65、Thread-66、Thread-67……);TIMED_WAITING(计时等待)状态,不消耗CPU但常驻内存;com.qcloud.cos.http.IdleConnectionMonitorThread。到这里,我们拿到了第一个关键线索:泄漏的线程,全部来自腾讯云COS对象存储SDK的空闲连接监控线程。
知道了线程类名,接下来就是在代码库中定位这个类,搞清楚三个问题:这个线程是谁创建的?什么时候启动?谁来负责关闭?
在项目依赖源码中全局搜索 IdleConnectionMonitorThread,很快定位到它属于 DefaultCosHttpClient 类,是HTTP连接池的配套组件。
查看该类的成员定义:
public class DefaultCosHttpClient implements CosHttpClient {
private PoolingHttpClientConnectionManager connectionManager;
private IdleConnectionMonitorThread idleConnectionMonitor;
// ...
}可以看到,每个 DefaultCosHttpClient 实例,都会持有一个独立的 IdleConnectionMonitorThread 监控线程。也就是说:有多少个HTTP客户端实例,就会有多少个监控线程。
继续查看构造方法和 shutdown 方法:
new 出监控线程并启动,线程内部是一个 while(!shutdown) 的死循环,定期清理连接池中的空闲连接;shutdown 标记置为 true,终止循环,让监控线程正常退出。结论很明确:这个线程默认随客户端启动,必须主动调用 shutdown 才会停止,不会自行销毁。
既然监控线程依附于HTTP客户端,那HTTP客户端又是谁创建的?继续向上追调用链:
DefaultCosHttpClient 是底层HTTP实现,它由 COSClient 构造方法内部创建;new COSClient(credentials, clientConfig) 来实例化客户端;整条调用链路彻底清晰:
业务方法调用
↓
new COSClient(...)
↓
new DefaultCosHttpClient(...)
↓
启动 IdleConnectionMonitorThread 监控线程调用链梳理清楚后,最后一步就是去业务代码里验证:COSClient 是怎么使用的?有没有调用 shutdown?
查看对应的业务实现类 TencentDocServiceImpl,结果一目了然:
new 一个全新的 COSClient 实例;至此根因完全闭环:
业务代码每次调用对象存储接口,都会创建新的客户端实例;客户端底层附带的空闲连接监控线程是死循环常驻线程,必须调用 shutdown 才会终止。由于业务代码用完即抛、从不关闭,导致监控线程只增不减,最终引发线程数泄漏,峰值超过3400。
空闲连接监控线程采用的是典型的守护线程循环设计:
while (!shutdown) {
// 定期清理空闲连接
wait(cleanInterval);
closeIdleConnections();
}shutdown 标记不置为 true,线程就会一直处于循环等待状态,永远不会自行销毁;TIMED_WAITING 状态不会消耗大量 CPU,但会持续占用内存、线程句柄和进程线程数配额;最直接的修复方式:每次使用完客户端后,主动调用 shutdown() 方法,释放连接池资源、停止监控线程。
COSClient cosClient = null;
try {
cosClient = new COSClient(credentials, clientConfig);
// 业务上传/下载逻辑
cosClient.putObject(...);
} finally {
if (cosClient != null) {
cosClient.shutdown();
}
}注意:关闭逻辑必须放在
finally块中,确保异常场景下也能正常释放,避免异常路径发生泄漏。
频繁创建销毁客户端本身也有不小的性能开销,生产环境更合理的做法是单例复用客户端实例。
这种方案既彻底解决了线程泄漏问题,也提升了运行性能,是生产环境的标准最佳实践。
这类问题具备极强的普遍性,几乎所有带连接池的第三方客户端都有同样的陷阱:HTTP客户端、Redis客户端、MQ客户端、对象存储客户端……无一例外。
遇到线程数持续上涨的问题,按以下步骤快速定位:
这次3400+线程数的故障,本质是对第三方SDK的生命周期认知不足导致的资源泄漏。客户端类看似只是一个工具,底层却附带了连接池、监控线程等一系列资源,创建容易销毁难,忽略关闭就会造成线程泄漏。
稳定性治理很多时候就是在抠这些细节:大的容错、限流框架搭好了,真正线上出问题的往往是某个不起眼的客户端实例,默默积累资源,直到触发告警。养成“创建即考虑销毁”的编码习惯,把客户端复用和资源关闭纳入开发规范,才能从根源上避免这类问题。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。