bigywater.tech
Java 内部原理 · 08

ThreadLocal 内存泄漏:线程池里不调用 remove(),真实测出 23MB 残留

"用完 ThreadLocal 要调 remove(),不然会内存泄漏"是句常见提醒,但很少有人真的量过泄漏多少。真实模拟一个线程池里的工作线程,反射进它的内部状态,把这句提醒变成一个具体数字。

238 / 500
不调用 remove(),真实反射统计出的残留条目数(key 已被回收、value 还占着内存)
23MB → 0
这些残留条目真实占用的内存,调用 remove() 之后同样的操作真实归零

上一篇:ReentrantLock:公平锁和非公平锁,真实差了 58 倍耗时

思路:弱引用的是 key,不是整个 Entry

"ThreadLocal 用了弱引用所以不会泄漏"是最容易被记错的一句话。

每个线程内部维护一个 ThreadLocalMap,存的不是普通的 (key, value) 键值对,而是一种特殊的 Entry——它继承自 WeakReference<ThreadLocal<?>>,构造时把 ThreadLocal 实例本身作为弱引用包起来当 key,但 value 字段是一个普通的强引用字段:

java.lang.ThreadLocal.ThreadLocalMap.Entry(简化) static class Entry extends WeakReference<ThreadLocal<?>> { Object value; // 注意:这个字段是强引用,不是弱引用 Entry(ThreadLocal<?> k, Object v) { super(k); value = v; } }

这个设计只解决了一半问题:如果外部不再持有 ThreadLocal 实例的任何强引用,GC 确实能把这个弱引用的 key 回收掉——但 Entry 对象本身、以及它强引用着的 value,只要这条线程还活着、ThreadLocalMap 还在,就不会被回收。key 没了,value 却还占着内存,这才是"内存泄漏"真正发生的地方。

真实踩坑:第一次实验测不到泄漏,因为线程退出会自动清空状态

线程跑完 join() 直接结束,反射进去发现 threadLocals 字段是 null——这不是实验失败,是线程退出时 JVM 自己清理过了。

第一版实验很直接:起一个线程,循环创建 ThreadLocal 并 set 值,不调用 remove(),join() 等线程跑完,再反射读 Thread.threadLocals 字段。结果两组(调 remove 和不调)都读到同一个结果:

真实实测(第一版,有问题)
ThreadLocalMap 为 null,什么都没留下

查了一下才想起来:Thread 类在真正退出时(exit() 方法里)会主动把自己的 threadLocals 字段置空——线程都要消失了,它自己的 ThreadLocalMap 当然也跟着没了,这本身不是泄漏,只是这次实验的场景不对:真实会出问题的场景是线程池里的核心线程,它们跑完一个任务不会退出,是继续"待命"等下一个任务,ThreadLocalMap 会一直跟着这个线程活下去。改成让线程跑完循环后卡在 CountDownLatch.await() 上模拟"待命"状态,不退出,再去反射检查:

模拟线程池工作线程 Thread worker = new Thread(() -> { for (int i = 0; i < 500; i++) { ThreadLocal<byte[]> tl = new ThreadLocal<>(); tl.set(new byte[100_000]); // 每次 100KB // 不调用 remove() } parked.await(); // 模拟线程池待命,不退出 });

真实验证:500 次不 remove(),真实残留了 23MB

线程停在待命状态后,反射进它的 ThreadLocalMap,统计有多少条目"key 没了、value 还在"。

真实实测 · 不调用 remove()
table 长度: 512
key 已被 GC(弱引用清空)但 value 仍然真实占着内存的条目数: 238
这些残留条目真实占用的字节数: 23800000 (约 23242KB)
正常存活的条目数: 0
500 次操作里有 238 次真实留下了残留条目,合计真实吃掉约 23MB 内存,没有任何一个对象能通过正常的 GC 路径回收它们——只要这条线程不退出,这些内存就一直占着

换成每次都调用 tl.remove(),同样的循环、同样反射检查:

真实实测 · 每次调用 remove()
table 长度: 16
key 已被 GC(弱引用清空)但 value 仍然真实占着内存的条目数: 0
这些残留条目真实占用的字节数: 0 (约 0KB)
正常存活的条目数: 0
table 长度稳定在初始大小 16,没有一条残留——remove() 会同时清掉 key 和 value,不给弱引用回收留"半吊子"状态
为什么残留的是 238 条,不是全部 500 条?ThreadLocalMap 内部并不是完全放任泄漏——每次 set()/get() 沿哈希槽探测时,顺手发现探测路径上有 key 已经是 null 的过期条目,会顺带清理掉。这是一种"顺手做一点、不保证做干净"的机会性清理,不是专门的回收机制,能减少一部分残留,但没法完全替代显式调用 remove()——真实数据里还是有接近一半的条目躲过了这种顺手清理,真实留了下来。

分步演示:每个线程的 ThreadLocal 副本真实存在哪里

每一行都是真实运行这段代码得到的结果。

ThreadLocal 真实把数据存在每个 Thread 对象自己的字段里,而且真实是惰性分配的,没用过就不占用这份内存
这一步新验证的线程

    参考与说明

    • 本文所有条目数量、残留字节数均在本机真实运行 Java 17(反射需要 --add-opens java.base/java.lang=ALL-UNNAMED)得到,包括第一版"测不到泄漏"的真实报错过程。
    • 同样是"设计上留了个缺口,得靠使用者主动清理"的例子,参见ReentrantLock 公平/非公平锁那篇——只是这篇的代价是内存,那篇的代价是延迟。
    • 没有涉及:InheritableThreadLocal 的父子线程传递机制、ThreadLocalMap 扩容和探测清理的具体算法细节——这些留给系列后面按需再写。
    京ICP备2026067502号-1京公网安备11010502063487号