"用完 ThreadLocal 要调 remove(),不然会内存泄漏"是句常见提醒,但很少有人真的量过泄漏多少。真实模拟一个线程池里的工作线程,反射进它的内部状态,把这句提醒变成一个具体数字。
上一篇:ReentrantLock:公平锁和非公平锁,真实差了 58 倍耗时
"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(); // 模拟线程池待命,不退出 });
线程停在待命状态后,反射进它的 ThreadLocalMap,统计有多少条目"key 没了、value 还在"。
table 长度: 512 key 已被 GC(弱引用清空)但 value 仍然真实占着内存的条目数: 238 这些残留条目真实占用的字节数: 23800000 (约 23242KB) 正常存活的条目数: 0500 次操作里有 238 次真实留下了残留条目,合计真实吃掉约 23MB 内存,没有任何一个对象能通过正常的 GC 路径回收它们——只要这条线程不退出,这些内存就一直占着
换成每次都调用 tl.remove(),同样的循环、同样反射检查:
table 长度: 16 key 已被 GC(弱引用清空)但 value 仍然真实占着内存的条目数: 0 这些残留条目真实占用的字节数: 0 (约 0KB) 正常存活的条目数: 0table 长度稳定在初始大小 16,没有一条残留——remove() 会同时清掉 key 和 value,不给弱引用回收留"半吊子"状态
每一行都是真实运行这段代码得到的结果。