ThreadLocal弱引用设计:从源码到内存泄漏实战解析
1. 从一次内存排查说起先讲一个我实际遇到的场景。线上有个服务运行一段时间后老年代内存持续上涨GC 日志显示 Full GC 频率越来越高但堆内存里大部分对象都不是业务对象而是 ThreadLocalMap 里的 Entry。当时排查了很久最后发现问题出在一个很隐蔽的地方某个工具类里用了 ThreadLocal 存储用户上下文但没有在请求结束后调用 remove()。为什么这会导致内存泄漏核心就是今天要聊的话题——为什么 ThreadLocal 对 key 的引用是弱引用。如果你准备过 Java 面试大概率会被问到“ThreadLocal 的 key 为什么是弱引用”。网上答案很多但大多是背结论因为弱引用可以防止内存泄漏。可你要是往下追问“为什么弱引用能防止内存泄漏”“那为什么还会有人遇到 ThreadLocal 内存泄漏”很多人就说不清了。这篇文章我从源码、JVM 引用机制、实际案例三个维度拆开讲保证你看完能理解透彻并且能回答面试官的连环追问。适合的人群正在学习 Java 并发编程的开发者、准备大厂面试的同学以及想搞明白线上内存问题的运维或开发。我会尽量用大白话和代码示例把原理讲清楚。2. ThreadLocal 到底解决了什么问题2.1 线程隔离的朴素愿望ThreadLocal 的核心作用是线程隔离。多线程环境下如果多个线程同时访问同一个变量需要加锁或者用并发容器。但有时候我们希望每个线程都有自己的专属副本互不干扰典型场景有Spring 框架的 RequestContextHolder、事务管理器中的 Connection 持有、日志追踪 ID 的传递。一个简单用法private static final ThreadLocalSimpleDateFormat DATE_FORMAT ThreadLocal.withInitial(() - new SimpleDateFormat(yyyy-MM-dd HH:mm:ss)); public String format(Date date) { return DATE_FORMAT.get().format(date); }SimpleDateFormat 不是线程安全的如果每个线程都用自己的实例就避免了加锁的开销。2.2 每个线程内部都有一个“专属储物柜”ThreadLocal 的底层实现是每个 Thread 对象内部持有一个 ThreadLocalMap 字段这个 Map 的 key 是 ThreadLocal 实例本身value 是你存储的数据。用个生活化类比ThreadLocal 就像房间里的储物柜每个线程进入房间时都有自己的柜子柜子里放什么只有自己知道。你调用 threadLocal.set(value) 时本质是往当前线程的 ThreadLocalMap 里放了一个键值对键就是当前 threadLocal 对象。关键点来了这个 ThreadLocalMap 是定义在 Thread 类里的生命周期跟线程一样长。如果是线程池中的线程线程存活时间很长那么这个 Map 也会一直存在。这时候如果 entry 不能被正确清理就可能造成内存泄漏。3. 弱引用在这里扮演什么角色3.1 引用强度的基本概念要理解为什么用弱引用先得知道 Java 的引用强度。从强到弱分别是强引用、软引用、弱引用、虚引用。平时写的Object obj new Object()就是强引用只要强引用还在GC 永远不会回收该对象。弱引用就不一样WeakReference 指向的对象只要被 GC 扫描到无论内存是否充足都会被回收。Object obj new Object(); WeakReferenceObject weak new WeakReference(obj); // 将 obj 置为 null不保留强引用 obj null; // 在下一次 GC 后weak.get() 会返回 null3.2 ThreadLocalMap 的 Entry 设计看一下源码ThreadLocalMap 里有一个 Entry 类static class Entry extends WeakReferenceThreadLocal? { Object value; Entry(ThreadLocal? k, Object v) { super(k); value v; } } } 这里的关键就是 extends WeakReferenceThreadLocal?。也就是说ThreadLocalMap 里的 key 是一个弱引用value 是强引用。为什么 key 要设计成弱引用直接原因是为了“干净利落地断开 key 的强引用链”。 ### 3.3 如果是强引用会发生什么 我们反向推理。假如 Entry 里的 key 是强引用那么只要 ThreadLocalMap 存在Entry 就强引用着 ThreadLocal 对象。而 ThreadLocalMap 存在于 Thread 对象中只要线程不销毁ThreadLocalMap 不销毁那么 ThreadLocal 对象永远不会被回收。 注意业务代码里ThreadLocal 通常被声明为静态变量或者在某个方法内创建。如果你在方法内 new 了一个 ThreadLocal使用完以后方法退出线程中 ThreadLocalMap 的 key 仍然强引用着这个 ThreadLocal 对象这个 ThreadLocal 对象就回收不掉了value 更不用提。这就是内存泄漏的核心机制。 如果 key 是弱引用情况就不一样当外部不再持有 ThreadLocal 的强引用时GC 回收时发现这个 ThreadLocal 对象只有弱引用指向它就会回收它。ThreadLocal 对象被回收后ThreadLocalMap 里对应的 entry 的 key 就变成 null通过 get() 方法就能发现 key 为 null 的 entry然后清理掉 value防止 value 泄漏。 所以弱引用解决的是“key 的强引用链过长”的问题让 ThreadLocal 对象可以被及时回收。 ### 3.4 一个关键误解弱引用不直接清理 value 有些文章会说“弱引用所以不会内存泄漏”这个说法其实不完整。弱引用只保证 ThreadLocal 对象本身能回收但 Entry 中的 value 是强引用如果 value 没有及时被清理依然会滞留在 ThreadLocalMap 里。所以 ThreadLocal 源码里做了两步处理 - 在 get() 和 set() 时会调用 expungeStaleEntry() 清理 key 为 null 的 entry。 - 在当前线程退出或线程池中的线程执行完任务后如果没有调用 remove()value 就只能等这些 key 为 null 的 entry 被清理。 在实际开发中最稳妥的方式还是用完就调用 remove()。 ## 4. 源码层面看 get/set/remove 的清理机制 ### 4.1 set 方法中的探查与替换 ThreadLocalMap 使用线性探测法解决哈希冲突而不是 HashMap 的链地址法。为什么因为 Entry 的 key 是弱引用用开放地址法更容易在扫描过程中清理失效 entry。 set 方法的简化逻辑 java private void set(ThreadLocal? key, Object value) { Entry[] tab table; int len tab.length; int i key.threadLocalHashCode (len-1); for (Entry e tab[i]; e ! null; e tab[i nextIndex(i, len)]) { ThreadLocal? k e.get(); if (k key) { e.value value; return; } if (k null) { // 用新 entry 替换过期 entry replaceStaleEntry(key, value, i); return; } } tab[i] new Entry(key, value); int sz size; if (!cleanSomeSlots(i, sz) sz threshold) rehash(); }注意这里k null的情况说明 key 已经被 GC 回收这个 entry 已经“过期”。set 时会抓住这个槽位替换掉旧 entry把 value 也一并清理。这算是被动清理触发时机取决于后续操作。4.2 get 方法的透传与清理get 方法流程更长会在探测过程中调用 expungeStaleEntry 来删除连续的 stale entryprivate Entry getEntry(ThreadLocal? key) { int i key.threadLocalHashCode (table.length - 1); Entry e table[i]; if (e ! null e.get() key) return e; else return getEntryAfterMiss(key, i, e); }getEntryAfterMiss 里如果发现 entry 的 key 为 null就清理。但要注意这种清理只在哈希冲突时发生如果 entry 就放在原始槽位get 直接命中就没有机会主动清理。这也是为什么不能依赖 get/set 来清理过期 entry。4.3 remove 方法为什么会多扫一段被很多人忽略的是ThreadLocalMap 的 remove 方法也有一段额外的探测清理private void remove(ThreadLocal? key) { Entry[] tab table; int len tab.length; int i key.threadLocalHashCode (len-1); for (Entry e tab[i]; e ! null; e tab[i nextIndex(i, len)]) { if (e.get() key) { e.clear(); expungeStaleEntry(i); return; } } }没找到 key 也会正常结束不会死循环。它做了一个 clear()将弱引用的 referent 置空然后清理该位置的 stale entry。所以调 remove() 是清理 ThreadLocal 数据最彻底的方式比 set(null) 还靠谱因为 set(null) 只是把 value 置空entry 还在key 也还在。4.4 如果 key 变成 nullvalue 怎么被清理这是理解弱引用的关键一步。当 ThreadLocal 对象被回收后Entry 中 key 引用为 null但这个 Entry 对象本身还有强引用指向 value。此时如果 ThreadLocalMap 一直存在value 就一直被强引用。清理的方式有两种被动清理后续对 ThreadLocalMap 执行 set/get/remove 时探测过程中遇到 key 为 null 的 entry 会调用 expungeStaleEntry 清理。主动清理调用 ThreadLocal.remove() 或 remove 方法内部直接清理。但还有一种情况如果线程结束了Thread 对象被回收ThreadLocalMap 随之被回收那么 value 也会被回收。问题是线程池中的线程不会结束所以这一步保护不了线程池场景。5. 强引用 vs 弱引用的内存泄漏对比5.1 强引用设计下的泄漏链条我们模拟一下强引用设计的场景方便直观感受public class StrongRefLeak { static class LargeObject { private byte[] data new byte[1024 * 1024]; } public static void main(String[] args) throws InterruptedException { while (true) { ThreadLocalLargeObject tl new ThreadLocal(); tl.set(new LargeObject()); // tl 局部变量方法结束tl 应该不再被引用 // 但线程的 ThreadLocalMap 强引用着 tl导致 tl 和 LargeObject 都无法回收 Thread.sleep(1); } } }当tl这个局部变量出了方法作用域后栈上的引用消失但由于 ThreadLocalMap 强引用 tltl 无法回收。tl 本身只是一个小对象但它作为 key 存在 Map 里而 value 是 1MB 的大对象大对象也泄漏了。如果循环中不断创建 ThreadLocal 和 LargeObject内存很快就被打满。5.2 弱引用设计下的回收效果弱引用设计下由于 ThreadLocalMap 只持有 tl 的弱引用当局部变量 tl 被栈销毁后tl 对象没有任何强引用GC 时会被回收。tl 被回收后Entry 的 key 变为 null虽然 value 还强引用着但后续 ThreadLocalMap 的任何访问都可能触发清理。但这不等于 value 就一定能被清理。如果线程池线程一直不进行新的 set/get且不调用 remove这些 key 为 null 的 entry 会一直躺在 ThreadLocalMap 里。所以弱引用只是把泄漏点从“key 和 value 都泄漏”降级为“value 可能在无操作时滞留”。本质上是给了你清理的机会但没给你必然清理的保证。5.3 为什么不用软引用有人问用软引用也行吧内存不足时才回收。但 ThreadLocal 的设计目标不是等内存不够而是要让 ThreadLocal 对象生命周期跟随外部引用尽量减少无谓持有。弱引用在 GC 时就会回收比软引用更及时而且 ThreadLocal 实例本身很轻量回收成本低。软引用通常适合实现缓存而不是这种线程局部变量的生命周期管理。5.4 为什么不直接让 value 变为弱引用如果 value 用弱引用业务代码里 set 进去的对象很容易被 GC 回收用户还没取到值就没了这显然不现实。value 是业务数据必须强引用。所以设计上只能牺牲 value 的清理时机让 key 用弱引用然后在访问时清理 value。6. 实际开发中的内存泄漏场景与排查实录6.1 线程池场景的泄漏最常见的泄漏场景是使用线程池跑任务任务内部创建 ThreadLocal 并 set 了值但没 remove。线程池的线程复用Thread 对象不销毁ThreadLocalMap 就一直存在。如果 value 是较大的对象或持有外部引用且任务频繁执行就会导致老年代持续增长。我遇到的一个案例是这样的一个定时任务线程池每次执行时生成一个 UUID 放到 ThreadLocal用于日志链路追踪。代码里只有 set没有 remove。因为线程池固定 10 个线程所以最多泄漏 10 个 value看起来不多。但 value 里除了 UUID 还放了一个自定义用户对象里面引用了一个全局缓存对象导致缓存对象无法被回收最终老年代被撑爆。这个案例告诉我们即使线程数很少value 的引用链也可能很复杂。6.2 全流程排查思路定位 ThreadLocal 泄漏通常这么做先看 GC 日志确认老年代增长趋势。用 jmap 或 MAT 分析堆 dump搜索 ThreadLocalMap 相关的实例。观察 ThreadLocalMap 中 Entry 的 key 是否为 nullvalue 的类型是什么。结合业务代码搜索所有 ThreadLocal.set 或 withInitial 的地方检查有没有 remove。如果是 Spring 里的 RequestContextHolder、TransmittableThreadLocal 等封装类需要看它的清理机制。有一个比较隐蔽的问题ThreadLocal 的 set 方法在cleanSomeSlots里会随机清理部分过期 entry但不会遍历整个表。所以即使你触发了 set也不能保证清理干净。只有调用 remove 能保证当前 key 对应的数据被清掉。6.3 避免泄漏的规范写法基本规范是永远在 finally 块里调用 remove。例如ThreadLocalContext CONTEXT new ThreadLocal(); public void handle(Request request) { try { CONTEXT.set(new Context(request)); doSomething(); } finally { CONTEXT.remove(); } }即使 doSomething 抛出异常remove 也会执行。这是唯一能保证不泄漏的写法。如果你的线程是线程池的尤其要小心。Tomcat 的工作线程也是线程池所以如果在处理 HTTP 请求时使用 ThreadLocal最好在拦截器的 afterCompletion 中清理。6.4 弱引用与线程池配合时的一个陷阱弱引用的回收时机是不确定的GC 不一定立刻回收所以你不能依赖 key 变为 null 来“暗示”业务数据可以被清除。比如你在 ThreadLocal 里放了数据库连接然后忘记 remove即使 ThreadLocal 对象被 GC 回收了连接对象依然被 Entry.value 强引用连接池会被占满。准确的说泄漏的不是 ThreadLocal 本身而是 value 持有的资源。所以面试官问你“ThreadLocal 弱引用为什么能防内存泄漏”你要回答两层保证 ThreadLocal 对象本身能被回收外部强引用消失后不会有残留。值为 null 的 entry 有机会在下次操作时被清理避免 value 永久滞留。面试官如果继续问“那为什么还会泄漏”你就回答因为 value 是强引用且清理依赖后续操作或 remove 调用线程池场景下如果一直不触发就会泄漏。7. 几个高频面试问题的深度回答7.1 ThreadLocal 的 key 是弱引用value 是强引用为什么这么设计为了达到一种平衡。key 是 ThreadLocal 实例通常很小生命周期应该跟随外部引用。value 是业务数据不能无故消失。弱引用 key 让 ThreadLocal 对象能够被回收value 则通过显式 remove 或惰性清理来管理。核心思想是不强持有 ThreadLocal减少引用链长度同时保留清理 value 的机会。7.2 既然 value 是强引用那 set 后不 remove 一定泄漏吗不一定取决于线程是否存活。如果线程是普通线程执行完 run 方法后线程销毁Thread 对象和 ThreadLocalMap 及所有 entry 都会被 GC 回收不泄漏。但如果线程是线程池复用的线程存活时间长不 remove 就可能泄漏。所以不是必漏但风险很高。7.3 继承关系下子线程能拿到主线程的 ThreadLocal 吗默认不行InheritableThreadLocal 可以。它重写了 childValue 方法在创建子线程时把父线程的值拷贝一份。但线程池场景下每次创建子线程会拷贝一次复用线程时不会自动更新。所以要传递上下文到线程池通常用 Ali 的 TransmittableThreadLocal。这里强调一下InheritableThreadLocal 的实现仍然是基于弱引用 key 的 ThreadLocalMap只是扩展了创建子线程时的拷贝逻辑。所以弱引用的设计原则没有变化。7.4 ThreadLocal 的 hash 冲突处理为什么用线性探测ThreadLocalMap 的并发程度很低因为每个线程只有一个单线程访问不需要复杂哈希结构。线性探测实现简单而且便于在探测路径上清理 stale entry。如果像 HashMap 一样用链表或红黑树清理过期 entry 的成本会更高。这是从设计和性能两个角度考虑的。7.5 使用了线程池如何正确清理 ThreadLocal除了 remove还有几个方案用装饰器模式包装任务执行前后清理 ThreadLocal。使用 TransmittableThreadLocal它内置了任务包装类在 run() 前后自动复原和清理。在任务基类的 finally 块中统一清理。我在项目里比较喜欢用 TransmittableThreadLocal因为它除了清理还能解决异步线程间的上下文传递问题一套方案解决两个痛点。8. 经验总结与补充技巧8.1 如何避免写出 ThreadLocal 泄漏的代码从实战角度看,有几个原则ThreadLocal 变量尽量声明为 private static final这样生命周期明确不会在方法里频繁创建实例。值对象不要太大如果需要存储大批量数据考虑用引用代替副本。无论什么场景set 之后必须 remove二者成对出现。如果需要在线程间传递值用 TransmittableThreadLocal 而不是自己造轮子。8.2 我常用的一个调试技巧有时候不方便搞堆转储可以用 jmap -histo 看到 ThreadLocalMap$Entry 的数量变化jmap -histo:live pid | grep ThreadLocalMap如果 Entry 数量持续增长基本可以断定 ThreadLocal 泄漏。这时候可以去代码里搜索 ThreadLocal检查是否有 remove 操作。另一个技巧是给 ThreadLocal 注册一个虚引用回调当 ThreadLocal 对象被回收时虚引用会进入 ReferenceQueue你可以打印日志确认清理时机。这在分析高并发线程池问题时很有用。8.3 关于弱引用的一个冷知识ThreadLocalMap 里的弱引用是在 Entry 里通过 WeakReference 的构造方法绑定 ThreadLocal 的。当 ThreadLocal 被回收后Entry.get() 返回 null。但 Entry 本身还在它的 value 还被强引用。只有当 ThreadLocalMap 探测到这个 entry 并清理它value 才能被回收。所以你在分析 dump 时看到 ThreadLocalMap$Entry 大量存在不一定代表 ThreadLocal 对象泄漏也可能只是 value 还没被清理。需要进一步看 key 是否为 null。如果是 null说明 key 已经被回收了问题主要在于没有主动清理如果不是 null说明 ThreadLocal 本身还被某个强引用持有你需要检查哪里持有了 ThreadLocal 实例。8.4 在并发框架里弱引用的意义更多是兜底比如 Netty 的 FastThreadLocal 虽然性能更高但它的 ThreadLocalMap 里没有弱引用因为 FastThreadLocal 的索引是自增分配生命周期由开发者管理。如果你忘了 remove一样泄漏。所以弱引用在很大程度上是一个兜底机制不是免死金牌。个人观点判断一个开发者是否真的理解 ThreadLocal不是看他能不能背出“弱引用防泄漏”而是看他能不能说清楚弱引用只是降低 key 的泄漏风险value 的清理仍然依赖主动 remove 和惰性清理机制。明白这一点线上调内存问题心里就有底了。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →