jstack 是排查线程数上涨的首选工具,重点检查三类线索:未关闭的线程池、手动创建未管理的线程、重复堆栈无 shutdown 的泄漏;需确保线程池单例复用、合理配置 corePoolSize 与 allowCoreThreadTimeOut,并警惕 ThreadLocal 导致的内存假性泄漏。

用 jstack 看线程名和状态分布
线程数持续上涨,第一反应不是改代码,而是先看“现在到底有多少线程、长什么样”。jstack 是最直接的入口。执行 jstack ${pid} > thread_dump.log 后,重点扫三类线索:
- 大量形如
pool-12345-thread-1的线程,且状态是WAITING (parking)或TIMED_WAITING—— 这大概率是线程池没 shutdown,核心线程空闲但不销毁; - 线程名含
DestroyJavaVM之外的自定义名(比如data-processor),且堆栈停在Object.wait()或LockSupport.park()—— 可能是手动new Thread()后没控制生命周期; - 重复出现的相同堆栈,尤其是出现在
submit()、execute()调用点附近,而后续没有shutdown()或awaitTermination()—— 这是线程池泄漏的铁证。
查线程池是否被反复创建
很多问题不是线程池配置错,而是“每次调用都 new 一个”。典型错误模式是把 ExecutorService 声明在方法内,而不是作为成员变量复用:
public void handleRequest() {
// ❌ 错误:每次请求都新建线程池
ExecutorService executor = Executors.newFixedThreadPool(5);
executor.submit(() -> doWork());
// 忘了 shutdown → 线程池永生,线程越积越多
}
正确做法是:
- 线程池必须是单例或静态成员,初始化一次,长期持有;
- 如果业务确实需要“按需创建”,必须配对调用
shutdown()+awaitTermination(),且要处理中断; - 避免用
Executors.newCachedThreadPool(),它默认无界,maximumPoolSize是Integer.MAX_VALUE,突发流量下线程数会爆炸式增长。
确认 corePoolSize 和 allowCoreThreadTimeOut 设置
即使线程池是复用的,也可能因参数导致线程“只增不减”。关键看两个点:
-
corePoolSize设为 10,但业务低峰期实际只用 2 个线程 —— 默认情况下这 10 个核心线程不会自动销毁; - 若希望核心线程也能超时回收,必须显式调用
allowCoreThreadTimeOut(true),否则keepAliveTime对核心线程无效; - 注意:Tomcat 内置线程池(
maxThreads)和应用自建线程池是两套体系,别混淆;前者管 HTTP 请求分发,后者管业务异步逻辑,两者线程数都会计入 JVM 总线程数。
警惕 ThreadLocal + 线程复用导致的假性泄漏
线程数没涨,但内存占用持续上升?可能是 ThreadLocal 没清理,导致线程复用时不断累积对象。更隐蔽的是:线程池里的线程长期存活,ThreadLocal 的 value 一直强引用着业务对象,GC 不掉。虽然这不直接增加线程数,但会让线程“变重”,间接加剧资源紧张。
排查方式:
- 用
jmap -histo ${pid} | grep YourClass看特定类实例数是否异常增长; - 用 MAT 分析 heap dump,过滤
ThreadLocalMap$Entry,检查value是否持有大量不应存在的对象; - 所有
ThreadLocal.set()后,务必在 finally 块中remove(),尤其在线程池场景下。
线程数上涨本身往往只是表象,背后可能是线程池生命周期管理缺失、参数与业务节奏错配,或是资源未释放引发的连锁反应。最危险的不是“线程多”,而是“线程多且你不知道它们在等什么”。











