有一个问题ThreadLocal:是线程的"私人储物柜"还是"垃圾填埋场"?哈哈 这是一个好问题吧,下面欢迎大家来到内存管理的敏感地带;
在 Spring 事务管理、用户上下文(UserContext)、数据库连接池等场景中,ThreadLocal 是我们的得力助手:它让每个线程拥有了自己的"私人储物柜",互不干扰!但是,如果咱只记得 set() 却忘记了 remove(),那么这个"私人储物柜"就会变成永久性的垃圾填埋场 。在线程池复用的场景下,这直接导致经典的OOM (Out Of Memory),让你的服务器在深夜崩溃、深夜里买醉~深夜里默默哭泣、自己扛
第一章:核心概念
ThreadLocal 提供了线程局部的变量副本。每个访问该变量的线程都有自己独立的初始化副本
想象一家大型健身房(JVM 进程):
- 线程 (Thread) = 健身会员。
- ThreadLocal = 储物柜管理员。
- ThreadLocalMap = 每个会员腰上挂着 的**私人腰包,**装个水什么的。
- Key = 储物柜的钥匙(
ThreadLocal实例本身)弱引用。 - Value = 你存在柜子里的贵重物品(比如
UserContext对象)。
第二章:底层源码
存储结构:Thread -> ThreadLocalMap -> Entry
每个线程都持有一个 ThreadLocalMap,这个 Map 的 Key 是 ThreadLocal 对象,Value 是你存的数据;
public class Thread {
// ... 其他字段
/* ThreadLocal values pertaining to this thread. This map is maintained
* by the ThreadLocal class. */
//每个会员(线程)都有一个专属腰包 threadLocals
ThreadLocal.ThreadLocalMap threadLocals = null;
// ...
}
Entry 的秘密:弱引用 vs 强引用
ThreadLocalMap 内部的静态内部类:entry ------安静的美男子
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k); // 重点!Key 是弱引用 (WeakReference)
value = v; // 致命!Value 是强引用
}
}
再上一个形象的图:很形象、你细品
[ Thread 对象 ] (强引用)
|
| (持有)
v
[ ThreadLocalMap ]
|
| (数组 table)
v
[ Entry 对象 ] (强引用)
/ \
/ \
(WeakRef) (Strong Ref)
| |
v v
[ ThreadLocal Key ] [ Value 对象 (如 UserContext) ]
(弱引用) (强引用)
泄漏剧本:当 Key 消失,Value 却还在
你自定义了一个 ThreadLocal<User>,用完之后,你把外部对 ThreadLocal 实例的引用置为 null(或者它是一个局部变量,方法执行完就没了)
1、Key 的结局 :因为 Entry 继承自 WeakReference,如果外部没有其他地方引用这个 ThreadLocal 对象,GC (垃圾回收器) 在下一次扫描时,会回收掉 Key。
- 此时,
Entry里的key变成了null - 如果 Key 是强引用,那么只要
Thread活着,ThreadLocal对象就永远无法被回收(即使外部已经不用它了)。这会导致ThreadLocal实例本身的内存泄漏。 - 设计成弱引用,至少保证了当外部不再引用
ThreadLocal时,Key 能被回收,从而有机会(在下次 set/get 时)触发 Value 的清理。虽然 Value 仍可能泄漏,但这比 Key 和 Value 都泄漏要好一点。
2、Value 的悲剧:
Thread对象还活着(特别是在线程池中,线程是复用的,长期存活)。Thread强引用着ThreadLocalMap。ThreadLocalMap强引用着Entry数组。Entry数组强引用着Entry对象。Entry对象强引用 着Value!- 结论 :即使 Key 死了(变 null 了),Value 依然有一条完整的强引用链连接到根节点 (GC Roots)。
- Value 是强引用。只要 Thread 活着,Entry -> Value 的链就断不了。只有 Key 变 null 后,配合后续的
set/get操作才能清理 Value。如果线程一直复用且不再操作该 TL,Value 就死锁在内存里!
灾难现场(线程池场景)
- 假如线程池有 100 个线程,长期运行。
- 每个请求都用
ThreadLocal存了一个 1MB 的用户对象。 - 请求结束,业务代码没调
remove()。 - 线程归还给线程池,线程没死,只是去睡大觉了。
- 那个 1MB 的对象依然死死地抱在线程的腰包里。
- 再来 10000 个请求...一直没有清理+线程复用
- 结局 :100 个线程 * 10000 次复用 * 1MB = 1GB 内存泄漏 !直到
java.lang.OutOfMemoryError: Java heap space。
补充:引用类型
1、强引用 (Strong Reference) ------ "铁链锁魂"
只要你用 = 赋值了一个对象,它就是强引用
Object obj = new Object(); // 这就是强引用
List<String> list = new ArrayList<>();
- GC 行为 :只要强引用存在,GC 绝对不会回收该对象,哪怕内存已经溢出(OOM)。
- 后果 :如果内存不足,JVM 宁愿抛出
OutOfMemoryError让程序崩溃,也不会断开强引用来回收内存 - 你把大象(对象)用粗铁链拴在柱子(变量)上。结局:只要铁链不断(变量不置为 null 或超出作用域),大象就永远走不了。哪怕动物园(堆内存)着火了,消防员(GC)也不敢砍断铁链,只能看着动物园烧毁(OOM)
2. 软引用 (Soft Reference) ------ "弹性绳索"
-
描述一些还有用但并非必须的对象,只有在内存不足时,GC 才会回收它们
-
需要使用
java.lang.ref.SoftReference类// 创建一个软引用
Object strongObj = new Object();
SoftReference<Object> softRef = new SoftReference<>(strongObj);// 获取对象
Object obj = softRef.get(); -
内存充足时:不回收,像强引用一样活着。
-
内存不足时 (即将 OOM 前):GC 会把这些对象回收掉,释放内存,避免程序崩溃;所以第二次调用
get()可能返回null。 -
你把大象用一根有弹性的绳子拴着,平时大象好好的。但如果动物园快挤爆了(内存不足),管理员(GC)会剪断橡皮筋,把大象放走(回收),给新动物腾地方。
使用场景:比想象中要多
- 缓存系统 :比如图片缓存、网页缓存。
- 内存够用时,缓存留着,下次访问快。
- 内存不够时,自动清空缓存,保命要紧。
- 实现方式:很多第三方缓存库(如早期的 EhCache)利用软引用实现自动淘汰。
3. 弱引用 (Weak Reference) ------ "蛛丝一吹即断"
-
描述非必需的对象,强度比软引用更弱。无论内存是否充足 ,只要发生 GC,扫描到弱引用对象,立刻回收。
-
需要使用
java.lang.ref.WeakReference类// 创建一个弱引用
Object strongObj = new Object();
WeakReference<Object> weakRef = new WeakReference<>(strongObj);//熟悉吧// 手动断开强引用
strongObj = null;// 触发 GC (System.gc() 只是建议,但在演示中通常有效)
System.gc();// 获取对象 -> 大概率是 null
Object obj = weakRef.get(); // 返回 null -
GC 行为 :无视内存状况。一旦 GC 启动,发现只有弱引用连着对象,马上回收。
-
后果:对象的生命周期非常短,随时可能消失。
-
你用一根脆弱的蜘蛛网拴着大象,😂只要清洁工(GC)拿着扫帚过来扫一扫(不管动物园挤不挤),蜘蛛网就断了,大象跑了
场景:
首先就是咱们的threadLocal的key
- ThreadLocal 的 Key :
- 最经典用法
ThreadLocalMap的Entry继承自WeakReference<ThreadLocal> - 目的 :防止
ThreadLocal实例本身泄漏,当外部代码不再使用某个ThreadLocal对象时(强引用消失),GC 能立刻回收 Key,将其变为null。 - 局限 :虽然 Key 没了,但 Value 还是强引用,所以还需要配合
remove()才能彻底清理 Value。
- 最经典用法
- 监听器/回调注册:防止因忘记移除监听器导致的内存泄漏。
- 规范映射 (Canonicalizing Mappings) :如
WeakHashMap。
4. 虚引用 (Phantom Reference) ------ "幽灵通知"
-
最弱的引用。无法通过虚引用获取对象实例 。它的唯一作用是跟踪对象被垃圾回收的状态。
-
必须和
ReferenceQueue联合使用。ReferenceQueue<Object> queue = new ReferenceQueue<>();
PhantomReference<Object> phantomRef = new PhantomReference<>(new Object(), queue);// phantomRef.get() 永远返回 null!
-
GC 行为:对象被回收时,JVM 会将这个虚引用放入关联的队列中。
-
用途 :收到通知,知道"某对象刚才死了",以便执行一些堆外内存清理等收尾工作。
-
你并没有拴住大象,你只是在大象脖子上挂了一个报警器,大象死了(被回收了 阿门),报警器响了(引用入队),告诉你"大象已死,快去处理后事(清理堆外资源)"
场景:
直接内存(堆外内存)管理 :如 java.nio.DirectByteBuffer。
- Java 堆内的对象被回收了,但堆外分配的内存(操作系统内存)不会自动释放。
- 通过虚引用监控堆内对象死亡,一旦收到通知,立即去释放对应的堆外内存,防止堆外 OOM
第三章:JDK 的"补救措施"与局限性
在 ThreadLocalMap 的 set(), get(), remove() 方法中,加入了一些启发式清理 (Heuristic Cleaning) 逻辑
1、探测式清理 (Expunge Stale Entries)
当调用 set() 或 get() 时,JDK 会遍历 table 数组,检查是否有 key == null 的 Entry
-
如果发现
key == null:- 将该 Entry 的
value置为null(帮助 GC 回收 Value)。 - 将该 Entry 本身置为
null(槽位释放)。 - 重新哈希 (Rehash) 后面的元素,解决冲突。
// 伪代码逻辑示意
private int expungeStaleEntry(int staleSlot) {
Entry[] tab = table;
int len = tab.length;// 先清理当前槽位 tab[staleSlot].value = null; tab[staleSlot] = null; // 向后探测,清理所有 key 为 null 的 Entry for (int i = nextIndex(staleSlot, len); ; i = nextIndex(i, len)) { Entry e = tab[i]; if (e != null) { if (e.get() == null) { // Key 是弱引用,get() 返回 null 说明被 GC 了 e.value = null; tab[i] = null; } } else { break; } } // ...}
- 将该 Entry 的
2. 为什么这还不够?
- 被动触发 :清理只在
set/get时发生。如果线程存入数据后,再也没有调用过该ThreadLocal的get/set,而是直接结束了业务逻辑,清理永远不会发生。 - 哈希冲突:如果哈希冲突严重,清理效率会下降。
- 线程池复用 :在线程池中,线程长期存活。如果业务代码不规范,依赖 JDK 的被动清理是非常危险的。必须主动清理
如何监控 ThreadLocal 泄漏
- 代码审查(Code Review):检查所有
set是否有对应的remove。 - 压测 + Heap Dump:在高并发压测后,dump 内存,分析
Thread对象的threadLocals字段,看是否有大量堆积的Entry或大对象。 - 使用 Arthas 等工具监控线上内存
第四章:最佳实践
错误示范(内存泄漏元凶)
咱们整一个一般权限业务常用的一种方式:
// 典型的 Spring Interceptor 或 Filter 错误写法
public class UserInterceptor implements HandlerInterceptor {
private static final ThreadLocal<User> userContext = new ThreadLocal<>();
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
User user = parseUser(request);
userContext.set(user); // 存进去
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
// 忘记 remove()!
// 线程归还给线程池,user 对象依然挂在线程上,泄漏!
}
}
正确示范
原则 :谁 set,谁 remove。通常在 finally 块中清理
public class SafeUserInterceptor implements HandlerInterceptor {
private static final ThreadLocal<User> userContext = new ThreadLocal<>();
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
User user = parseUser(request);
userContext.set(user);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
try {
// 业务逻辑处理...
} finally {
// 必须移除!无论是否发生异常
userContext.remove();
// 这一步做了三件事:
// 1. 获取当前 Entry
// 2. 将 value 置为 null
// 3. 将 entry 置为 null (如果 key 也为 null)
// 彻底切断强引用链,允许 GC 回收 Value
}
}
}
进阶技巧:InitialValues 与 自动清理
如果你使用 withInitial 或者重写 initialValue,也要小心。最好的方式依然是显式的 try-finally;对于某些框架(如 Spring),它们已经在 TransactionSynchronizationManager 或 RequestAttributes 中帮你做了 remove,但如果是自定义的 ThreadLocal,一定一定要自己动手!
总结:ThreadLocal 的双刃剑
- 本质 :
ThreadLocal是线程级别的全局变量 ,数据存在线程对象 的ThreadLocalMap中。 - 泄漏根源 :
Thread(强) ->Map(强) ->Entry(强) ->Value(强)。Key是弱引用,挂了;但Value因为强引用链,死不了。- 线程池让线程长期存活,加剧了问题。
- 铁律 :
try { ... } finally { threadLocal.remove(); }。- 这行代码是防止 OOM 的护身符。
- 用完储物柜,不仅要拿走东西,还要**把柜子清空并归还钥匙;**否则,下一个用这个储物柜的人(复用的线程)会发现里面塞满了上一任留下的垃圾,最终仓库爆炸。
之前同事问了我一个问题:方法A和方法B 都用了同一个线程池。方法A调用方法B 会出现什么问题