《Java 并发编程·性能诊断与事故》10.3 线程泄漏与上下文类加载器

线程池不 shutdown、ThreadLocal 滞留大对象、线程上下文类加载器钉住插件加载器,是三种不同的泄漏。本节用 100 个未关闭的池复现 200 个常驻 worker,用 200 次 URLClassLoader 热部署复现 200 个无法回收的加载器,并用 jcmd GC.class_histogram 与 jmap -histo:live 做前后对照。

承接的问题

构造场景:libraryd-conc 的插件化借阅规则每接一个新租户就热部署一次,部署脚本会 new URLClassLoader(...) 加载租户自己的规则类,再交给一个按租户新建的线程池执行。上线三个月,租户从 20 个涨到 200 个,进程的线程数从 40 涨到 400 多,Metaspace 从 60 MB 涨到 900 MB,Full GC 越来越频繁但死活回收不掉那些 URLClassLoader。重启能撑一周,一周后同样的曲线再来一遍。

这条故障链上有三个独立的问题,经常被混成一句「内存泄漏」:

  1. 线程池没有 shutdown(),worker 线程永远不退出;
  2. ThreadLocal 在池线程上留下的值没有 remove(),大对象一直挂在 ThreadLocalMap 里;
  3. 线程的 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);
        }
    }
}

四条落地规则:

  1. 线程池必须有明确的生命周期。 谁创建谁关闭,close() 里先 shutdown()、再 awaitTermination、超时才 shutdownNow()。应用关闭钩子、容器的 ServletContextListener 都是这个位置——不依赖框架的话,就自己写一个 AutoCloseable 的包装。
  2. 核心线程要么允许超时,要么接受它常驻。 allowCoreThreadTimeOut(true) 适合流量有昼夜差的入口池;不适合「必须随时待命」的低延迟池。选了常驻,就要在容量规划里把线程栈算进去(-Xss × 线程数)。
  3. TCCL 的切换必须成对。 用 try/finally,不要靠「记得改回来」。上面的 callWith 是最小实现;如果同一线程里要嵌套切换,它天然支持(每次保存 previous)。
  4. ThreadLocal 一律 remove()。 尤其是缓存大对象、SimpleDateFormat、MDC 这类。线程池复用线程,不 remove 就等于把上一次请求的状态留给下一次请求——这既是内存泄漏,也是数据串号(6.3 讲过 MDC 的同类问题)。

常见错误与边界

错误后果正确做法
池用完不 shutdownworker 永远 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 锁的叠加

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收