ThreadLocal 原理与内存泄漏实战:从弱引用到 TTL 框架
适合用过 ThreadLocal、被内存泄漏警告吓到过但仍不清楚根因的开发者。不适合还不理解 Java 引用的四种类型的读者。ThreadLocal 的 key 是弱引用所以内存泄漏不会发生——这是我半年前跟一个同事说的话。后来线上真的出了 ThreadLocal 相关的 OOM 问题排查下来发现弱引用确实解决了一部分问题但远远没解决全部。我花了两个晚上把ThreadLocal和ThreadLocalMap的源码重新读了一遍结合那个 OOM 事故整理出这篇文章。ThreadLocal 根本不是线程本地存储先纠正一个普遍错误的理解。很多人以为 ThreadLocal 创建了一个属于线程的变量副本——实际上ThreadLocal 只是一个索引。真正的数据存在 Thread 对象自己的字段里// java/lang/Thread.java — JDK 8 public class Thread implements Runnable { // ... // 每个线程有自己的 ThreadLocalMap // 这个 map 的 key 是 ThreadLocal 对象value 是你存的数据 ThreadLocal.ThreadLocalMap threadLocals null; // 继承上下文用的 InheritableThreadLocal ThreadLocal.ThreadLocalMap inheritableThreadLocals null; // ... }// java/lang/ThreadLocal.java public class ThreadLocalT { public void set(T value) { Thread t Thread.currentThread(); // 拿到当前线程的 Map把 this 当 keyvalue 当 value 放进去 ThreadLocalMap map getMap(t); if (map ! null) map.set(this, value); else createMap(t, value); } public T get() { Thread t Thread.currentThread(); ThreadLocalMap map getMap(t); if (map ! null) { ThreadLocalMap.Entry e map.getEntry(this); if (e ! null) { SuppressWarnings(unchecked) T result (T)e.value; return result; } } return setInitialValue(); } // 从 Thread 中获取 ThreadLocalMap ThreadLocalMap getMap(Thread t) { return t.threadLocals; // 就是 Thread 本身的字段 } }结构关系Thread 1 ──→ threadLocals (ThreadLocalMap) ├── Entry(keyThreadLocalA, valuectx-1) ├── Entry(keyThreadLocalB, valuetx-1) └── Entry(keyThreadLocalC, valuereq-1) Thread 2 ──→ threadLocals (ThreadLocalMap) ├── Entry(keyThreadLocalA, valuectx-2) ├── Entry(keyThreadLocalB, valuetx-2) └── Entry(keyThreadLocalC, valuereq-2)每个 Thread 拥有自己的 ThreadLocalMapEntry 的 key 是 ThreadLocal 对象的弱引用。同一个 ThreadLocal 对象在不同线程中读到的 value 不同。ThreadLocalMap 的 Entry弱引用的设计意图// java/lang/ThreadLocal.java // ThreadLocalMap 的内部 Entry static class ThreadLocalMap { static class Entry extends WeakReferenceThreadLocal? { // value 是强引用——这就是内存泄漏的来源 Object value; Entry(ThreadLocal? k, Object v) { super(k); // key 是弱引用 value v; // value 是强引用 } } // 默认容量 16负载因子 2/3 private static final int INITIAL_CAPACITY 16; private Entry[] table; private int size 0; private int threshold; // 默认为 len * 2/3 }为什么 key 设计为弱引用假如 key 是强引用ThreadLocal对象不再被业务代码引用时由于 ThreadLocalMap 中还有 Entry 强引用着它——ThreadLocal 无法被回收Entry 也无法被回收。强引用假设 main → ThreadLocalRef → [ThreadLocal 对象] ← Entry.key强引用 ↑ ThreadLocalMap 如果 ThreadLocalRef nullThreadLocal 对象仍被 Entry.key 持有 → 无法回收 弱引用实际 main → ThreadLocalRef → [ThreadLocal 对象] ← Entry.key弱引用 ↑ ThreadLocalMap 如果 ThreadLocalRef nullGC 时 ThreadLocal 对象被回收 Entry.key 变为 null所以弱引用的设计意图是业务代码不再使用 ThreadLocal 对象时GC 能够回收它对应的 Entry 变为key null的脏条目。内存泄漏的真正来源弱引用只解决了 ThreadLocal 对象自身的泄漏问题但没有解决 value 的泄漏问题。线程存活 → ThreadLocalMap 存活 → Entry 存活key 可能为 null但 value 不为 null当一个 Entry 的 key 被 GC 回收后key null这个 Entry 里的value 仍然是强引用指向业务对象。如果线程长期存活比如 Tomcat 的线程池这些 value 永远不会被回收。这就是 ThreadLocal 内存泄漏的完整链条1. ThreadLocal 不再使用 → GC 回收 ThreadLocal 对象 2. Entry.key 变为 null 3. Entry.value 还是强引用 → 业务对象无法被 GC 回收 4. 线程存活线程池复用→ ThreadLocalMap 不释放 → value 泄漏我在线上遇到的情况去年有个服务跑了三周后监控报警堆内存使用率持续走高。dump 下来分析发现大量的ThreadLocalMap$Entry引用了业务RequestContext对象——一个 Filter 里用了 ThreadLocal 保存当前请求信息但请求结束后没有 remove。Tomcat 的线程池有 200 个线程每个线程持有一个 RequestContext —— 这个对象内部又引用了用户信息、权限列表、请求参数——算下来每个线程泄漏了几百 KB200 个线程就是几十 MB。而且随着请求量增加RequestContext 内部数据越来越大。如何正确使用 ThreadLocal最佳实践就三个字用完删。方案 Atry-finallyprivate static final ThreadLocalRequestContext contextHolder new ThreadLocal(); public void handleRequest(Request request) { try { contextHolder.set(new RequestContext(request)); // ... 业务逻辑 } finally { contextHolder.remove(); // 关键 } }方案 BFilter/Interceptor// Spring MVC 的 Interceptor public class ContextInterceptor implements HandlerInterceptor { private static final ThreadLocalRequestContext CTX new ThreadLocal(); Override public boolean preHandle(HttpServletRequest request, ...) { CTX.set(new RequestContext(request)); return true; } Override public void afterCompletion(HttpServletRequest request, ...) { CTX.remove(); // 在所有 View 渲染完之后清理 } }InheritableThreadLocal线程间传参// java/lang/InheritableThreadLocal.java public class InheritableThreadLocalT extends ThreadLocalT { protected T childValue(T parentValue) { return parentValue; // 默认直接复制 } // 在 Thread.init() 中被调用 // 子线程创建时复制父线程的 inheritableThreadLocals }子线程创建时如果父线程有 InheritableThreadLocal 的值会通过childValue()复制过去。这用在主线程把 Trace ID 传给子线程的场景。但有个问题线程池中的线程不会重新初始化——所以第一次复制的值会在线程池中一直传下去。这就引出了 TransmittableThreadLocal。TransmittableThreadLocalTTL阿里开源的 TTL 解决了线程池场景下 ThreadLocal 值传递的问题。// com.alibaba.ttl.TransmittableThreadLocal.java // ——TTL 的核心逻辑 public class TransmittableThreadLocalT extends InheritableThreadLocalT { // 在执行 Runnable 之前捕获上下文 Override public final T get() { // 从当前线程获取 T value super.get(); if (value ! null) addThisToHolder(); // 记录当前 TTL 实例 return value; } // TtlRunnable 在 run() 之前捕获所有 TTL 值 // 在目标线程中重新 set() }// 使用方式 TransmittableThreadLocalString traceId new TransmittableThreadLocal(); traceId.set(req-123); // 提交到线程池 executor.submit(TtlRunnable.get(() - { // 这里能拿到 traceId req-123 System.out.println(traceId.get()); }));我项目中用 TTL 做链路追踪的 Trace ID 传递效果不错。但要注意 TTL 也有自己的泄漏风险——如果 TTL 的值的生命周期比任务长也会有泄漏。ThreadLocalMap 的哈希冲突处理ThreadLocalMap 不用链表它用**线性探测Linear Probing**解决哈希冲突// java/lang/ThreadLocal.java — ThreadLocalMap.set() private void set(ThreadLocal? key, Object value) { Entry[] tab table; int len tab.length; int i key.threadLocalHashCode (len - 1); // 线性探测从哈希位置开始逐个往后找空位或相同的 key for (Entry e tab[i]; e ! null; e tab[i nextIndex(i, len)]) { ThreadLocal? k e.get(); if (k key) { // 找到相同 key → 替换 value e.value value; return; } if (k null) { // 遇到过期 Entrykey 被 GC 回收→ 替换它 replaceStaleEntry(key, value, i); return; } } tab[i] new Entry(key, value); int sz size; if (!cleanSomeSlots(i, sz) sz threshold) rehash(); // 扩容 }ThreadLocal.threadLocalHashCode是怎么保证均匀分布的private final int threadLocalHashCode nextHashCode(); private static AtomicInteger nextHashCode new AtomicInteger(); private static final int HASH_INCREMENT 0x61c88647; // 斐波那契数乘子 private static int nextHashCode() { return nextHashCode.getAndAdd(HASH_INCREMENT); }0x61c88647这个值选得很讲究——它是斐波那契散列的乘子能保证在 2 的幂次长度下键均匀分布减少线性探测的冲突链长度。实际性能对比维度ThreadLocalInheritableThreadLocalTTL作用范围当前线程当前线程 子线程创建时线程池传递线程池支持❌❌仅首次传递✅性能开销get/set ≈ 10ns同上包装 Runnable 后 ≈ 50ns内存泄漏风险高需要手动 remove高同上中如果正确包装的话JDK 版本1.21.2第三方阿里总结回到开头那句话——ThreadLocal 的 key 是弱引用所以不会内存泄漏——这确实是错的。弱引用只保证 ThreadLocal 对象本身能被回收但Entry.value 是强引用只要线程还活着value 就不会被回收。正确的做法在 finally 块中调remove()或者用 try-with-resource 模式封装线程池场景下考虑 TTL定期检查堆中 ThreadLocalMap$Entry 的数量文中引用的 JDK 源码路径java/lang/Thread.java — threadLocals 和 inheritableThreadLocals 字段java/lang/ThreadLocal.java — set/get/remove 和 ThreadLocalMap 实现java/lang/InheritableThreadLocal.java — 子线程继承完整源码github.com/openjdk/jdkTTL 源码github.com/alibaba/transmittable-thread-local

相关新闻