承接的问题
构造场景:libraryd-conc 的插件化借阅规则每接一个新租户就热部署一次,部署脚本会 new URLClassLoader(...) 加载租户自己的规则类,再交给一个按租户新建的线程池执行。上线三个月,租户从 20 个涨到 200 个,进程的线程数从 40 涨到 400 多,Metaspace 从 60 MB 涨到 900 MB,Full GC 越来越频繁但死活回收不掉那些 URLClassLoader。重启能撑一周,一周后同样的曲线再来一遍。
这条故障链上有三个独立的问题,经常被混成一句「内存泄漏」:
- 线程池没有
shutdown(),worker 线程永远不退出; ThreadLocal在池线程上留下的值没有remove(),大对象一直挂在ThreadLocalMap里;- 线程的
contextClassLoader指向了插件加载器,线程活着,加载器就活着,它加载的类元数据也就活着。
前两个是「线程活得太久」,第三个是「线程顺手把一整个类加载器也留住了」。这一节把它们分开复现、分开检测、分开修。
三种「不会退出的线程」
ThreadPoolExecutor 的 worker 线程什么时候退出?看 JDK 25 src.zip 里 getTask() 的核心几行:
int wc = workerCountOf(c);
// Are workers subject to culling?
boolean timed = allowCoreThreadTimeOut || wc > corePoolSize;
...
Runnable r = timed ?
workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
workQueue.take();
allowCoreThreadTimeOut 默认是 false,所以只要 wc <= corePoolSize,timed 就是 false,worker 走的是 workQueue.take()——在空队列上无限期阻塞。只有调用 shutdown() / shutdownNow() 把状态改成 SHUTDOWN / STOP,take() 才会被唤醒并返回 null,worker 才会退出。池对象被 GC 不代表线程会退出:worker 线程自己持有 ThreadPoolExecutor 的引用,线程又是 GC root,所以「丢引用」这个动作对 worker 完全无效。
再加上 Executors.defaultThreadFactory() 里的 t.setDaemon(false),这些线程默认是非守护线程,JVM 必须等它们结束才能退出。于是「池没关」的外部表现往往是:main 已经跑完,进程还在。
| 形态 | 触发方式 | 表现 | 检测信号 |
|---|---|---|---|
| 池未关闭 | new ThreadPoolExecutor 后从不 shutdown | 线程数随池数量线性增长 | 线程名前缀计数 |
| 池被重建 | 每次请求 / 每次部署 new 一个池 | 线程数单调增长、jstack 里大量空闲 worker | 线程总数 vs 业务并发 |
| 核心线程常驻 | corePoolSize 固定且不超时 | 低峰期线程数不下降 | allowCoreThreadTimeOut=false |
ThreadLocal 滞留 | 池线程 set 了大对象,没 remove | 堆老年代阶梯上升 | jmap -histo:live 看 byte[] |
复现:100 个池留下 200 个 worker
下面的程序建 100 个 2 线程的池,每个池提交一个空任务让它把核心线程拉起来,然后不调用 shutdown。再用一个单线程池演示 ThreadLocal 滞留 16 MiB 数组,最后把所有池关掉看线程数是否回落。
import java.lang.management.ManagementFactory;
import java.lang.management.ThreadMXBean;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
/** 复现:池不 shutdown 的线程泄漏,以及 ThreadLocal 在池线程上滞留大对象。 */
public final class ThreadLeak {
static final ThreadMXBean MX = ManagementFactory.getThreadMXBean();
static final ThreadLocal<byte[]> CACHE = new ThreadLocal<>();
public static void main(String[] args) throws Exception {
Runtime rt = Runtime.getRuntime();
System.out.println("baseline live=" + MX.getThreadCount()
+ " heapUsed=" + usedMb(rt) + "MB");
List<ThreadPoolExecutor> leaked = new ArrayList<>();
for (int i = 0; i < 100; i++) {
int id = i;
ThreadPoolExecutor pool = new ThreadPoolExecutor(
2, 2, 0, TimeUnit.SECONDS, new LinkedBlockingQueue<>(),
r -> new Thread(r, "leak-pool-" + id));
pool.prestartAllCoreThreads();
pool.execute(() -> { });
leaked.add(pool); // 只保留引用,不 shutdown
}
ThreadPoolExecutor one = new ThreadPoolExecutor(
1, 1, 0, TimeUnit.SECONDS, new LinkedBlockingQueue<>(),
r -> new Thread(r, "tl-worker"));
one.submit(() -> CACHE.set(new byte[16 * 1024 * 1024])).get();
gc();
System.out.println("after ThreadLocal.set(16MB) on pooled thread: heapUsed="
+ usedMb(rt) + "MB");
one.submit(CACHE::remove).get();
gc();
System.out.println("after ThreadLocal.remove() on same thread: heapUsed="
+ usedMb(rt) + "MB");
one.shutdown();
gc();
System.out.println("after 100 pools without shutdown: live=" + MX.getThreadCount()
+ " leak-pool-*=" + count("leak-pool-") + " heapUsed=" + usedMb(rt) + "MB");
for (ThreadPoolExecutor pool : leaked) {
pool.shutdownNow();
}
Thread.sleep(800);
gc();
System.out.println("after shutdownNow: live=" + MX.getThreadCount()
+ " leak-pool-*=" + count("leak-pool-") + " heapUsed=" + usedMb(rt) + "MB");
}
static void gc() throws InterruptedException {
System.gc();
Thread.sleep(300);
System.gc();
Thread.sleep(200);
}
static long count(String prefix) {
return Thread.getAllStackTraces().keySet().stream()
.filter(t -> t.getName().startsWith(prefix))
.count();
}
static long usedMb(Runtime rt) {
return (rt.totalMemory() - rt.freeMemory()) >> 20;
}
}
本机实测(-Xmx256m,Apple M1 Pro、Temurin 25.0.5+8、单次运行、随调度波动):
baseline live=6 heapUsed=2MB
after ThreadLocal.set(16MB) on pooled thread: heapUsed=18MB
after ThreadLocal.remove() on same thread: heapUsed=1MB
after 100 pools without shutdown: live=206 leak-pool-*=200 heapUsed=1MB
after shutdownNow: live=7 leak-pool-*=0 heapUsed=1MB
从外部看是同一件事:
jcmd <pid> Thread.print | grep -c 'leak-pool-'
# 200
jcmd <pid> Thread.print | grep '"leak-pool-' | head -3
"leak-pool-0" #25 [27139] prio=5 os_prio=31 cpu=0.44ms elapsed=4.38s tid=0x000000072adb1800 nid=27139 waiting on condition [0x0000000171daa000]
"leak-pool-0" #26 [43267] prio=5 os_prio=31 cpu=0.17ms elapsed=4.38s tid=0x000000072adb2000 nid=43267 waiting on condition [0x0000000171fb6000]
"leak-pool-1" #27 [42755] prio=5 os_prio=31 cpu=0.07ms elapsed=4.38s tid=0x000000072adb2800 nid=42755 waiting on condition [0x00000001721c2000]
三条 leak-pool-* 全部停在 waiting on condition,cpu 接近 0——它们没有在干活,只是在 take() 上睡着,但这不影响它们占着线程栈、线程对象和 TLS 区域。shutdownNow() 之后 leak-pool-* 归零,live 从 206 回到 7(比基线的 6 多 1,是 JVM 自己的辅助线程,不是池线程)。
ThreadLocal 那两行更直观:池线程 set 了 16 MiB 之后堆从 2 MB 涨到 18 MB,同一线程 remove() 之后回落到 1 MB。ThreadLocalMap 的 key 是弱引用的 ThreadLocal 对象,value 是强引用;只要池线程活着、ThreadLocal 是静态的,那条 entry 就永远在。这也是为什么「池线程 + ThreadLocal」必须成对出现 set/remove,而且 remove 要放在 finally 里。
TCCL:为什么一个线程能钉住一整个类加载器
Thread 有一个 contextClassLoader 字段,JDK 25 的 Thread.java 第 2136–2138 行:
public void setContextClassLoader(ClassLoader cl) {
contextClassLoader = cl;
}
它没有 volatile,没有弱引用,就是一个普通字段。线程是 GC root,所以线程活着,这个字段指向的 ClassLoader 就活着;ClassLoader 活着,它加载过的每一个 Class、每一个类的静态字段、每一条常量池引用都活着。在 JDK 25 里,类卸载的前提是「它的定义加载器可以被回收」,加载器被钉住,类元数据就一直在 Metaspace 里。
TCCL 本来是为了解决「父加载器看不到子加载器里的实现」:JDK 的 SPI(JDBC、JNDI、XML 解析)在父加载器里跑,但要加载子加载器里的实现类,于是约定「用当前线程的 contextClassLoader 去找」。框架(Web 容器、插件容器、脚本引擎)广泛使用这个约定:拿到一个插件加载器,setContextClassLoader(pluginLoader),执行插件代码,然后忘了恢复。线程来自容器级线程池,一辈子不退出,于是每一次「部署」都留下一个加载器。
一个线程能钉住一个加载器,听起来不多。但热部署场景里,每次部署都是「新建加载器 + 复用或新建线程池」,200 次部署就是 200 个加载器;每个加载器在真实应用里可能加载几千个类,Metaspace 的增长就是几千倍。
复现:200 次热部署留下 200 个加载器
实验用一个独立的插件目录,里面编译了一个 plugin.LeakyPlugin。每次「部署」新建一个 URLClassLoader、加载插件、把它交给一个 1 线程的池执行;任务里把该加载器设为当前线程的 TCCL。restore 模式在任务结束时把 TCCL 恢复成应用加载器,作为对照组。
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Set;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
/** 复现:池线程的 contextClassLoader 持有插件加载器,热部署后旧加载器无法回收。 */
public final class TcclLeak {
static final int DEPLOYS = 200;
static final List<ThreadPoolExecutor> POOLS = new ArrayList<>();
public static void main(String[] args) throws Exception {
Path pluginsDir = Path.of(args[0]);
boolean restore = args.length > 1 && args[1].equals("restore");
URL url = pluginsDir.toUri().toURL();
ClassLoader app = TcclLeak.class.getClassLoader();
for (int i = 0; i < DEPLOYS; i++) {
URLClassLoader pluginLoader = new URLClassLoader(new URL[]{url}, app);
Class<?> pluginClass = Class.forName("plugin.LeakyPlugin", true, pluginLoader);
Object plugin = pluginClass.getDeclaredConstructor().newInstance();
int id = i;
ThreadPoolExecutor pool = new ThreadPoolExecutor(
1, 1, 0, TimeUnit.SECONDS, new LinkedBlockingQueue<>(),
r -> new Thread(r, "deploy-" + id));
pool.execute(() -> {
Thread thread = Thread.currentThread();
thread.setContextClassLoader(pluginLoader); // 框架常见做法:挂上插件 TCCL
try {
pluginClass.getMethod("run").invoke(plugin);
} catch (ReflectiveOperationException e) {
throw new IllegalStateException(e);
}
if (restore) {
thread.setContextClassLoader(app); // 对照组:用完恢复
}
});
POOLS.add(pool);
}
Thread.sleep(800);
System.out.println("mode=" + (restore ? "restore" : "leak")
+ " deploys=" + DEPLOYS + " deployThreads=" + countThreads()
+ " distinctTCCL=" + distinctTccl());
if (args.length > 2 && args[2].equals("pause")) {
Thread.sleep(25_000); // 留给 jcmd 采样
}
for (ThreadPoolExecutor pool : POOLS) {
pool.shutdownNow();
}
}
static long countThreads() {
return Thread.getAllStackTraces().keySet().stream()
.filter(t -> t.getName().startsWith("deploy-"))
.count();
}
static int distinctTccl() {
Set<ClassLoader> held = Collections.newSetFromMap(new IdentityHashMap<>());
for (Thread t : Thread.getAllStackTraces().keySet()) {
if (t.getName().startsWith("deploy-") && t.getContextClassLoader() != null) {
held.add(t.getContextClassLoader());
}
}
return held.size();
}
}
两次运行的对照(-Xmx256m,插件目录 /tmp/conc10/plugins):
mode=leak deploys=200 deployThreads=200 distinctTCCL=200
mode=restore deploys=200 deployThreads=200 distinctTCCL=1
两种模式下 deployThreads 都是 200——线程泄漏是一样的;差别只在 distinctTCCL:leak 模式有 200 个不同的加载器被线程持有,restore 模式只剩应用加载器一个。在 leak 模式运行到 pause 时对进程执行 jcmd <pid> GC.class_histogram(会先做一次 Full GC),原文节选:
19: 200 19200 java.net.URLClassLoader (java.base@25.0.5)
182: 1 104 jdk.internal.loader.ClassLoaders$AppClassLoader (java.base@25.0.5)
183: 1 104 jdk.internal.loader.ClassLoaders$BootClassLoader (java.base@25.0.5)
184: 1 104 jdk.internal.loader.ClassLoaders$PlatformClassLoader (java.base@25.0.5)
Full GC 之后仍然有整整 200 个 URLClassLoader 存活,不是垃圾没来得及收,而是有 GC root 指着它们。换 jmap -histo:live 得到同一行:
19: 200 19200 java.net.URLClassLoader (java.base@25.0.5)
restore 模式下同样的命令,grep URLClassLoader 一行都没有。对照组的意义在这里:线程数没变、任务数没变、类加载次数没变,唯一变的是「用完有没有把 TCCL 还回去」,加载器就从 200 个变成 0 个。直方图里看不到 plugin.LeakyPlugin 的实例,因为任务结束后插件对象已经不可达;被钉住的是加载器本身以及它加载的类元数据。
检测手段
| 想看什么 | 命令 | 判据 |
|---|---|---|
| 线程总数 | jcmd <pid> Thread.print | grep -c '^"' | 随时间单调增长,且不随业务并发回落 |
| 池线程归属 | jcmd <pid> Thread.print | grep '"前缀-' | 线程名前缀数量 ≈ 池数量 × 核心线程数 |
| 加载器数量 | jcmd <pid> GC.class_histogram | grep URLClassLoader | 随部署次数线性增长,Full GC 后不降 |
| 另一入口 | jmap -histo:live <pid> | grep URLClassLoader | 与上面一致 |
| 引用链 | jcmd <pid> GC.heap_dump /tmp/x.hprof | 用 MAT 看 Thread.contextClassLoader |
两点经验:
GC.class_histogram默认会先做 Full GC,所以它列出来的是存活对象;如果数量在两次采样之间只涨不跌,基本可以排除「垃圾没回收」这种解释。- 直方图只能看到「有多少个加载器」,看不到「谁持有它」。要确认是 TCCL 而不是别的静态字段,最直接的办法是看 dominator tree 里
java.lang.Thread的 retained heap,或者用 MAT 的 Path to GC Roots 排除弱引用。本机没有安装 MAT,本节没有做引用链分析;distinctTCCL的前后对照已经足以定位,但那是在代码里埋的观测点,线上未必有。
heap dump 的用法与 10.1 相同:-XX:+HeapDumpOnOutOfMemoryError 或 jcmd <pid> GC.heap_dump,dump 完立刻评估磁盘占用并清理。加载器泄漏通常不会立刻 OOM,而是先把 Metaspace 撑爆(java.lang.OutOfMemoryError: Metaspace),这时候 -XX:MaxMetaspaceSize 会决定你是「早死早发现」还是「慢慢烂掉」。
在 libraryd-conc 里落地
修复分两层:线程工厂负责「新线程的 TCCL 是干净的」,作用域负责「临时切换一定还回去」,生命周期负责「池一定会关」。
package com.plume.conc.ops;
import java.util.concurrent.Callable;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.atomic.AtomicInteger;
/** 线程工厂 + TCCL 作用域:worker 的 TCCL 固定,临时切换必须恢复。 */
public final class TcclSafeThreadFactory implements ThreadFactory {
private final String prefix;
private final ClassLoader tccl;
private final boolean daemon;
private final AtomicInteger seq = new AtomicInteger();
public TcclSafeThreadFactory(String prefix, ClassLoader tccl, boolean daemon) {
this.prefix = prefix;
this.tccl = tccl;
this.daemon = daemon;
}
@Override
public Thread newThread(Runnable task) {
Thread thread = new Thread(task, prefix + "-" + seq.incrementAndGet());
thread.setDaemon(daemon);
thread.setContextClassLoader(tccl); // 不让 worker 继承到别人的加载器
return thread;
}
/** 在指定 TCCL 下执行,finally 里一定恢复原值。 */
public static <T> T callWith(ClassLoader loader, Callable<T> task) throws Exception {
Thread thread = Thread.currentThread();
ClassLoader previous = thread.getContextClassLoader();
thread.setContextClassLoader(loader);
try {
return task.call();
} finally {
thread.setContextClassLoader(previous);
}
}
}
四条落地规则:
- 线程池必须有明确的生命周期。 谁创建谁关闭,
close()里先shutdown()、再awaitTermination、超时才shutdownNow()。应用关闭钩子、容器的ServletContextListener都是这个位置——不依赖框架的话,就自己写一个AutoCloseable的包装。 - 核心线程要么允许超时,要么接受它常驻。
allowCoreThreadTimeOut(true)适合流量有昼夜差的入口池;不适合「必须随时待命」的低延迟池。选了常驻,就要在容量规划里把线程栈算进去(-Xss× 线程数)。 - TCCL 的切换必须成对。 用
try/finally,不要靠「记得改回来」。上面的callWith是最小实现;如果同一线程里要嵌套切换,它天然支持(每次保存 previous)。 ThreadLocal一律remove()。 尤其是缓存大对象、SimpleDateFormat、MDC 这类。线程池复用线程,不 remove 就等于把上一次请求的状态留给下一次请求——这既是内存泄漏,也是数据串号(6.3 讲过 MDC 的同类问题)。
常见错误与边界
| 错误 | 后果 | 正确做法 |
|---|---|---|
池用完不 shutdown | worker 永远 take() 阻塞,线程数只增不减 | 明确生命周期,close() 兜底 |
用 finalize / 弱引用「等它自己退」 | worker 持有池引用,永远不会被回收 | 主动 shutdown |
ThreadLocal 只 set 不 remove | 大对象挂在池线程上,老年代阶梯上涨 | finally { tl.remove(); } |
| 热部署后不恢复 TCCL | 每次部署泄漏一个加载器 + 全部类元数据 | try/finally 恢复,或固定线程 TCCL |
用 jstack 只数线程不认名字 | 分不清业务线程和泄漏线程 | 线程工厂统一命名 |
看到 URLClassLoader 就重启 | 一周后复发 | 先确认 TCCL / 静态字段的持有链 |
什么时候可以不管 TCCL:线程池的生命周期和类加载器的生命周期完全一致(比如「一个租户一个进程」),线程随加载器一起销毁,泄漏窗口为零。什么时候必须管:任何「加载器比线程短命」的组合——热部署、插件、脚本引擎、测试容器,都属于这一类。
小结
三种泄漏要分开看。线程泄漏的机制在 getTask() 里:allowCoreThreadTimeOut=false 时核心线程走 workQueue.take(),不 shutdown 就永远阻塞;本机 100 个未关闭的池留下 200 个 leak-pool-* 线程,jcmd Thread.print | grep -c 数出来正好 200,shutdownNow() 之后归零。
ThreadLocal 是「线程活得太久」的内存版本:池线程 set 16 MiB 后堆从 2 MB 涨到 18 MB,同一线程 remove() 后回到 1 MB。
TCCL 是「线程顺手扣住加载器」:Thread.contextClassLoader 是普通字段、线程是 GC root,所以 200 次热部署在 Full GC 之后仍有 200 个 URLClassLoader 存活(jcmd GC.class_histogram 与 jmap -histo:live 输出一致);同样 200 个线程,只要每次用完把 TCCL 恢复,加载器一个都不剩。线程数相同、加载次数相同,唯一变量是「有没有还回去」——这就是定位这类的关键对照。
阅读导航:上一节 10.2 拒绝策略引发的雪崩 | 下一节 11.1 数据库行锁与 Java 锁的叠加
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。