本文目录导读:

- 案例一:
ThreadLocal未清理导致线程池中的线程“僵尸化” - 案例二:
new Thread()未受控创建 - 案例三:线程池的
corePoolSize设置过大且使用SynchronousQueue无界 - 案例四:使用
CompletableFuture+ForkJoinPool不当 - 通用排查工具与步骤
- Java线程泄漏的本质
Java线程泄漏是生产环境中一种棘手的内存问题,因为线程本身占用大量JVM堆外内存(线程栈),一旦泄漏,最终会导致 OutOfMemoryError: unable to create new native thread。
下面通过几个典型的实战案例,帮你认识线程泄漏的成因、排查方法及修复方案。
ThreadLocal 未清理导致线程池中的线程“僵尸化”
问题描述
某系统使用固定线程池(Executors.newFixedThreadPool(100))处理业务,运行几天后频繁出现以下错误:
java.lang.OutOfMemoryError: unable to create new native thread
根因分析
代码中使用 ThreadLocal 存储用户上下文信息(如用户ID、角色),但在线程池中的线程复用场景下,任务执行完毕后没有调用 remove() 清理。
- 线程池中的线程是长期存在的,不会被回收。
ThreadLocal中的 value 会被线程持续持有,形成 线程 → ThreadLocalMap → value 的强引用链。- 尽管堆内存(Heap)看起来还在增长,但真正的致命点是:每次新的任务都可能往
ThreadLocal里塞入新的对象,导致该线程持有的对象越来越多。
演进后果
JVM的堆内存耗尽,尝试创建新的线程时,操作系统无法分配原生线程栈,抛出 unable to create new native thread。
修复方案
// 错误示例:只设置不清理
try {
UserContext.set(user);
doTask();
} finally {
// 修复:必须清理,防止线程复用后数据残留
UserContext.remove();
}
new Thread() 未受控创建
问题描述
某订单推送系统,每来一条订单就 new Thread(() -> pushOrder(order)).start(),高并发下系统崩溃。
根因分析
- 每个
new Thread()默认分配 1MB 左右的线程栈空间(JVM参数-Xss决定)。 - 如果推送服务响应慢(比如下游 HTTP 超时),线程阻塞在 I/O 上无法释放。
- 短时间内大量线程堆积,占用大量原生内存,最终触发
OutOfMemoryError: unable to create new native thread。
排查方法
用以下命令查看线程数:
jstack <pid> | grep "java.lang.Thread.State" | awk '{print $2}' | sort | uniq -c | sort -rn
如果看到大量 RUNNABLE 或 WAITING 状态的推送线程,且线程ID在持续增长,基本可以确认。
修复方案
使用有界线程池 + 拒绝策略,
ThreadPoolExecutor pool = new ThreadPoolExecutor(
10, 20, 60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(500),
new ThreadPoolExecutor.CallerRunsPolicy()
);
pool.execute(() -> pushOrder(order));
线程池的 corePoolSize 设置过大且使用 SynchronousQueue 无界
问题描述
某系统配置如下:
new ThreadPoolExecutor(
0, Integer.MAX_VALUE, // 核心线程0,最大线程无界
60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>() // 不缓存任务,直接创建线程
);
根因分析
SynchronousQueue不会缓存任务,每一个任务到达时,若没有空闲线程,就会新建线程。- 当任务提交速率大于任务执行速率时,线程数无限增长。
- 最终线程数达到数千或数万,每个线程占用约 1MB 栈空间,内存耗尽。
修复方案
核心思想:有界队列 + 固定上限的线程数
new ThreadPoolExecutor(
8, 32, // 核心线程8,最大32
60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(1000) // 有界队列
);
使用 CompletableFuture + ForkJoinPool 不当
问题描述
某数据分析系统大量使用:
CompletableFuture.runAsync(() -> process(data));
默认使用 ForkJoinPool.commonPool(),其线程数由 Runtime.getRuntime().availableProcessors() - 1 决定。
根因分析
process(data)是阻塞操作(如数据库查询),大量阻塞任务会让 commonPool 的线程全部阻塞。- 后续任务不断提交,commonPool 会自动从
0扩展到最大32767线程(理论值),同样造成线程爆炸。 ForkJoinPool默认不会拒绝任务,会无限扩张。
修复方案
指定专用线程池,不要复用 commonPool:
ExecutorService customPool = new ThreadPoolExecutor(
16, 16,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<>(10000),
new NamedThreadFactory("analysis-")
);
CompletableFuture.runAsync(() -> process(data), customPool);
通用排查工具与步骤
确认是否线程泄漏
# 查看进程的线程数(多次执行看是否持续增长) watch -n 1 "ls /proc/<pid>/task | wc -l"
抓取线程快照
jstack -l <pid> > thread_dump_1.txt sleep 5 jstack -l <pid> > thread_dump_2.txt
对比两份dump,重点观察:
- 线程名是否一致(比如有没有线程名重复?)
- 是否有大量相同状态(如
WAITINGon monitor)的线程 - 是否有线程只增不减
分析堆和原生内存
# 查看堆内存使用 jmap -heap <pid> # 查看线程栈占用的内存(Linux) cat /proc/<pid>/status | grep Threads
Java线程泄漏的本质
| 场景 | 本质原因 | 修复思路 |
|---|---|---|
| ThreadLocal 未清理 | 线程复用导致对象积累 | 在 finally 中调用 remove() |
| new Thread() 无限创建 | 没有线程复用机制 | 使用固定线程池 |
| 线程池最大线程数无界 | 任务队列饱和导致线程扩张 | 设置最大线程数 + 有界队列 |
| ForkJoinPool.commonPool 被阻塞 | 默认线程池被滥用 | 使用业务隔离的专用线程池 |
核心修复黄金法则:
- 尽量用线程池,不要裸奔
new Thread()。 - 线程池必须配置有界队列和拒绝对策。
ThreadLocal执行完必须remove()。- 监控线程数和核心线程活跃数,设置告警。