如果你在日志里看到过 java.lang.OutOfMemoryError: GC overhead limit exceeded,大概率它出现之前的几分钟,还躺着一长串 background concurrent copying gc freed 7101KB allocspace bytes ... 这样的记录。很多人会下意识先怀疑是不是自己新加的缓存、列表太大,但顺着引用链查下去,最后经常会落到一个特别基础、又特别容易被忽略的机制上:ThreadLocalMap 里的弱引用被 GC 回收之后,线程到底还攥着多少东西不放手。这篇文章我就想从头到尾把这件事拆开——弱引用在 ThreadLocal 内部是什么角色,键被回收之后 map 里留下了什么,内存为什么还是会涨,以及线上应该怎么观察和规避。
1. 从一行 GC 日志,聊到 ThreadLocalMap 的典型故障现场
先说那行日志。background concurrent copying gc 这名字在不同运行时里可能略有差异,我拿到的线上现场是 Android ART 的日志,关键字是 freed 7101KB、16% free、1496KB 这类。意思很直白:这次 GC 确实回收掉了一些内存,但回收完堆剩余空间仍然很少,整体堆已经明显不够用了。如果这样的日志每隔一会儿就冒一条,说明存活对象数量很大、分配压力始终降不下来。
而 GC overhead limit exceeded 是 HotSpot 里的一个保护机制:JVM 花在 GC 上的时间比例超过阈值,但回收到的堆比例不到 2% 的时候,它就会抛出 OOM 而不是继续空转。这个异常不是说你申请了一个超大的数组,而是说你的堆里塞满了没法回收的存活对象,GC 就像在已经快满的房间里倒腾行李,每次只能腾出一小块地方,马上又被分配动作填满。
ThreadLocalMap 为什么会搅进来?因为它有一个很迷惑人的设定:key 是弱引用。很多人一听“弱引用”,就觉得 GC 来了它会被自动清理,内存不会泄漏。结果线上 OOM 一出现,最先被排除的就是 ThreadLocal。而实际上,弱引用回收的只是 ThreadLocal 对象本身,key 指向的 value 仍然被 ThreadLocalMap 的 Entry 强引用着。这个不对称,才是 ThreadLocal 内存问题的根源。
拆一下日志里的关键字段:
freed 7101KB:本次 GC 回收掉的字节数。这个数字看起来不小,但要看相对值。16% free:回收之后堆剩余百分比,几乎贴着上限。1496KB:剩余可分配堆的大小。allocspace bytes:分配区的持续增长。
这种组合说明应用处于“能回收,但回收速度赶不上分配速度”的状态。遇到这种情况,我的第一反应不是调大堆,而是先找谁在持有大量不该长期存活的对象。ThreadLocalMap 一旦参与进来,表现出的就是这种典型的慢性恶化曲线。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. ThreadLocalMap 的存储设计:Entry 的弱引用到底弱在哪
2.1 一个 Entry 同时包着两种引用
每个线程内部都有一个 ThreadLocal.ThreadLocalMap 实例,挂在 Thread.threadLocals 字段上。ThreadLocal 的 set / get,本质就是在当前线程的这个 map 里写入和读取。这个 map 内部就是一个 Entry[] table,每个 Entry 继承自 WeakReference<ThreadLocal<?>>:
java复制public class ThreadLocal<T> {
static class ThreadLocalMap {
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
private Entry[] table;
private int size;
private int threshold;
}
}
注意这两行代码完全不同的性质:
super(k):把 ThreadLocal 对象交给WeakReference持有。也就是说,Entry 对 key 的引用是弱引用。value = v:value 字段是普通强引用。
引用链是这样的:
code复制线程对象 → ThreadLocalMap → Entry[] → Entry → value(强引用)
↓
WeakReference → ThreadLocal 对象(弱引用)
2.2 为什么用弱引用:索引语义与生命周期分离
ThreadLocal 本身更像一个“索引”。业务代码真正想存的是 value,而 ThreadLocal 对象只是用来定位 table 下标的钥匙。如果 Entry 用强引用持有 ThreadLocal 对象,线程就会牢牢拽住它,即使业务代码栈帧已经结束、外部已经完全不再使用这个 ThreadLocal,对象也无法被 GC。那么 ThreadLocal 对象本身就会大面积泄漏。
改成弱引用之后,ThreadLocal 对象一旦没有任何外部强引用,GC 就可以把它回收掉。这是 JDK 在设计上的一个取舍:索引对象不该被线程长周期持有,所以用弱引用让索引本身能退出;但 value 是业务数据,生命周期没法由 ThreadLocal 自动判断,必须由程序员明确管理。
这里有一个反直觉的细节:弱引用不等于“自动清理 value”。GC 把 ThreadLocal 对象回收之后,对应的 Entry 并不会立刻从 table 里消失,只是 entry.get() 返回 null。value 字段还指向那个大对象,而 ThreadLocalMap 还存活在线程上。只要线程不结束、不触发清理,这个 value 就一直是可达的。
2.3 ThreadLocal 的哈希布局,决定了清理必须连片进行
ThreadLocal 实例被创建时会分配一个 threadLocalHashCode,ThreadLocalMap 通过这个值计算槽位下标:hash & (len - 1)。一旦发生冲突,就向后线性探测,直到找到空槽。这种开放地址法的实现方式,意味着不能想删哪个槽就删哪个槽——如果只清掉一个冲突链上的中间节点,后面的 Entry 可能就找不到了。这也是后续清理算法必须向后扫描、做 rehash 的根本原因。
3. 弱引用被回收之后:Entry 变脏,但清理不会自动完成
3.1 回收瞬间的引用状态
当 GC 判定某个 ThreadLocal 对象只被弱引用可达时,会把它放进 ReferenceQueue(如果创建时传了 queue),并把 WeakReference 内部的 referent 字段置为 null。这时,ThreadLocalMap 里的 Entry 处于一个很尴尬的中间状态:
entry.get() == nullentry.value != null
我把这种 Entry 叫“脏槽”。GC 只负责回收 key,它不会也不知道这个 key 对应的 value 是否还有业务意义。想要 value 也被回收,只能靠 ThreadLocalMap 自己的清理路径。
3.2 expungeStaleEntry:被访问到才会执行的正向清理
ThreadLocalMap 核心清理方法是 expungeStaleEntry(staleSlot),它的行为可以简化成下面这段逻辑:
java复制private int expungeStaleEntry(int staleSlot) {
Entry[] tab = table;
int len = tab.length;
// 清空当前过期槽位
tab[staleSlot].value = null;
tab[staleSlot] = null;
size--;
// 从下一个槽位开始,向后扫描到第一个空槽
Entry e;
int i;
for (i = nextIndex(staleSlot, len);
(e = tab[i]) != null;
i = nextIndex(i, len)) {
ThreadLocal<?> k = e.get();
if (k == null) {
e.value = null;
tab[i] = null;
size--;
} else {
// 因为前面删掉了一个槽,当前 Entry 如果不在其“理想位置”,要重新插槽
int h = k.threadLocalHashCode & (len - 1);
if (h != i) {
tab[i] = null;
while (tab[h] != null) {
h = nextIndex(h, len);
}
tab[h] = e;
}
}
}
return i;
}
这个方法做了三件事:清空当前脏槽;继续向后扫,把沿途遇见的其他脏槽也一起清掉;对仍然存活但位置不对的 Entry 做 rehash,保证哈希链完整。这也是为什么 ThreadLocal 内存泄漏不是“立刻导致 GC 失效”,而是“长期无人访问就永远不清”。
但最关键的一点是:expungeStaleEntry 属于惰性清理,它只在某个线程的 get、set、remove 操作恰好经过这个槽位附近时才可能被调用。如果一个脏槽对应的 ThreadLocal 以后再也没有被访问到,它就永远躺在线程对象的 threadLocals 里。
3.3 插入与替换路径上的启发式清理
ThreadLocal.set 的逻辑里,如果发现槽位 key 相同就直接替换 value;如果槽位 key 为 null,说明这是一个脏槽,会调用 replaceStaleEntry 把新值放进去,同时向后清理附近其他脏槽;如果当前 key 还没出现,就继续向后找空位插入。
插入完成之后,set 还会调用 cleanSomeSlots(i, sz)。这个“some”很诚实,它只做启发式扫描:默认扫描 log2(table.length) 个槽位,碰到脏槽就调用 expungeStaleEntry。JDK 之所以不做全量清理,是因为全量扫描在大 table 上的代价太大,而 ThreadLocal 的访问本身要求低延迟,所以选择在每次操作时顺便清理一小片。
get 路径也一样:如果通过哈希计算第一个槽位没命中,进入 getEntryAfterMiss,其中会调用 expungeStaleEntry 清理碰到的脏槽。因此结论可以总结成一句话:只要业务还在继续 set/get 同一个 ThreadLocal,清理机会就存在;一旦业务不再访问,清理就不会发生。
3.4 remove() 是最值得养成的习惯
很多人习惯用 tl.set(null) 来“释放”,这并不彻底。set(null) 只是把 value 字段换成 null,Entry 对象还留在 table 里,ThreadLocal 键如果被强引用,这个槽位还会占住索引。真正彻底的做法是调用 remove():
java复制ThreadLocal<String> tl = new ThreadLocal<>();
try {
tl.set(userContext);
doWork();
} finally {
tl.remove();
}
remove() 会定位到 key 对应的 Entry,然后调用 expungeStaleEntry 把 Entry 从 table 中移除,同时清理连带脏槽。这是程序员唯一能主动、精确触发清理的手段,也是线程池场景下最应该写进业务代码的操作。
4. 从清理不及时到 GC overhead limit exceeded:泄漏的演进链路
4.1 线程池为什么是 ThreadLocal 泄漏的放大器
ThreadLocal 在单线程内自娱自乐时,问题不大:栈帧结束,ThreadLocal 变量失引用,GC 回收 key,线程本身也可能很快结束,threadLocals 随之灰飞烟灭。一旦进入线程池,情况就完全不同了。线程池里的线程是长生命周期对象,会一直挂在 JVM 里,threadLocals 字段跟着线程存活。你在任务里 set 进去的 value,只要不 remove,就会在线程内长期保留。
假设一个线程池固定 200 个线程,业务在每次请求时把 10KB 的上下文对象塞进 ThreadLocal,请求结束只清空局部变量、不 remove。只要每个线程里积累一个脏 entry,就有 200 * 10KB = 2MB 的存活对象。如果上下文换成 50MB 的缓存对象或者一批历史请求对象,200 个线程就是 10GB,直接能把典型服务端堆压爆。这些对象从 GC 根上完全可达:线程 → threadLocals → entry.value,GC 一点办法都没有。
4.2 GC overhead limit exceeded 的触发条件
HotSpot 默认开启了 -XX:+UseGCOverheadLimit 保护。当连续多次 GC 都处于“回收量低于 2%,但 GC 时间超过 98%”的状态,就会抛出 java.lang.OutOfMemoryError: GC overhead limit exceeded。这个机制的本质是:与其让 JVM 把所有时间花在无效 GC 上,不如直接终止,让你有时间去 dump 堆、查根因。
很多团队遇到这个异常,第一反应是加 -Xmx。这个动作往往只是把崩溃时间点往后推——如果根因是 ThreadLocal 长期持有对象,堆越大,泄漏的绝对值就越大,GC 压力不减反增。调堆可以暂时止血,但不能替代排查。
4.3 回头看那行日志:7MB 回收量救不了堆
回到日志里的 freed 7101KB 和 16% free。一次回收掉 7MB,听起来不少,但如果堆的剩余空间一直徘徊在低位,这 7MB 只是杯水车薪。GC 反复运行,每次回收的都是那些本来就可以回收的临时对象,而真正占着坑的 ThreadLocal value 因为脏槽没被清理,始终保留在堆里。
这时候堆的曲线会呈现一个典型特征:年轻代频繁 GC,老年代持续增长不下降。GC 日志里表现为 GC 频率越来越高,单次回收量越来越小,最后兜不住,触发 overhead limit。
一条典型的泄漏链路复盘:
- 请求 A 到达线程池中的线程 T,代码把用户上下文 set 进 ThreadLocal。
- ThreadLocal 变量是方法栈里的局部变量,调用结束后栈弹出,外部不再持有该 ThreadLocal 对象。
- GC 回收 ThreadLocal 对象,ThreadLocalMap 里 key 变 null,value 却仍然被 Entry 强引用。
- 线程 T 继续跑下一个请求,但不会再访问旧 ThreadLocal 的槽位,脏 entry 迟迟得不到清理。
- 请求越多,脏 entry 越多,堆里全是被线程强引用的过期 value。
- GC 频繁运行、回收不干净,最终触发 GC overhead limit exceeded。
4.4 单线程 set 后长期不访问也要小心
还有一个容易忽视的场景:单线程、非线程池,但 ThreadLocal 是静态字段,且 set 了一次大对象后再也不管。静态字段本身就是强引用,ThreadLocal 对象永远可达,弱引用永远不会变成 null,value 也就永远可达。这种情况下,连脏 entry 都算不上,是干净的长期占用。遇到这种代码,除了 remove 没别的解法。
5. 动手实验:弱引用回收后,ThreadLocalMap 里到底留下什么
5.1 一个最小复现 Demo
理论说再多,不如动手跑一次。下面的代码模拟了“ThreadLocal 置空后触发 GC,再反射查看当前线程的 ThreadLocalMap 内部状态”:
java复制public class ThreadLocalGcDemo {
static ThreadLocal<byte[]> tl = new ThreadLocal<>();
public static void main(String[] args) throws Exception {
// 1. 塞入一个 64MB 的“大对象”
tl.set(new byte[64 * 1024 * 1024]);
// 2. 把 ThreadLocal 引用置空,模拟外部不再持有
tl = null;
// 3. 触发一次 GC
System.gc();
Thread.sleep(1000);
// 4. 反射查看当前线程 ThreadLocalMap 的表
printThreadLocalMap();
}
static void printThreadLocalMap() throws Exception {
Thread t = Thread.currentThread();
java.lang.reflect.Field threadLocalsField =
Thread.class.getDeclaredField("threadLocals");
threadLocalsField.setAccessible(true);
Object map = threadLocalsField.get(t);
java.lang.reflect.Field tableField =
map.getClass().getDeclaredField("table");
tableField.setAccessible(true);
Object[] table = (Object[]) tableField.get(map);
for (Object e : table) {
if (e == null) continue;
java.lang.reflect.Field referentField =
java.lang.reflect.Reference.class.getDeclaredField("referent");
referentField.setAccessible(true);
java.lang.reflect.Field valueField =
e.getClass().getDeclaredField("value");
valueField.setAccessible(true);
System.out.println("entry.key = " + referentField.get(e));
byte[] value = (byte[]) valueField.get(e);
System.out.println("entry.value length = "
+ (value == null ? 0 : value.length));
}
}
}
5.2 实验结果:key 变 null,value 仍在
运行这段代码,输出会类似:
code复制entry.key = null
entry.value length = 67108864
这就是整个 ThreadLocal 内存问题最核心的一个画面:GC 确实回收了 ThreadLocal 对象,key 位置变成了 null,但 64MB 的 value 还老老实实挂在 Entry 上,Entry 还挂在线程的 threadLocals 里。
这里必须说明,System.gc() 在多数 JVM 上会触发 Full GC,WeakReference 会在这个阶段被处理;但生产环境不要依赖它,只适合本地实验。最终验证应以 GC 日志或堆转储为准。
5.3 用一个 set 触发清理,验证 expungeStaleEntry
接下来做一个对照实验:在上面代码的 tl = null 之后,再创建一个新的 ThreadLocal 并执行一次 set,让 ThreadLocalMap 的插入路径恰好扫过脏槽,触发 expungeStaleEntry。如果是同一个哈希槽位附近,你会看到 value 被清空,Entry 被删除。这段逻辑可以这样扩展:
java复制ThreadLocal<String> other = new ThreadLocal<>();
other.set("hello");
set 过程中一旦发现 entry.get() == null 的槽位,会调用 replaceStaleEntry 或 expungeStaleEntry,把旧 value 释放。但注意,如果新 ThreadLocal 的哈希下标离脏槽很远,线性探测扫不到,清理就不会发生。这就是为什么我强调“靠近才会清”,而不是“一定会清”。
这个实验给我们的落地启示是:弱引用回收 key 是真实且及时的,但 value 的回收是临时且机会性的,完全取决于后续是否有访问或写入动作。
6. 生产环境里三个高频 ThreadLocal 误用模式及修正
6.1 在线程池里存放重量级的“上下文”
最常见的误用是把请求上下文、用户会话、大 JSON 对象直接塞进 ThreadLocal,认为请求结束就万事大吉。线程池里线程复用,Entry 留在 map 中,下次请求如果初始化时只执行 set,不执行 remove,旧 value 被替换还好;一旦某个分支忘了 set,旧 value 就会在下一次请求被意外读到,既埋逻辑雷,又埋内存雷。
修正方式很简单:任务执行做一次 set,最后无论成功失败都 remove。不要依赖“下一次 set 会覆盖”。
6.2 set 之后用 set(null) 代替 remove
前面提过,set(null) 只是把 value 置空,Entry 仍然存在。如果 ThreadLocal 是静态字段,Entry 就一直留在 table 里;线程频繁执行任务时,这个空 Entry 虽然不占大对象内存,但会占数组位置,堆积多了同样引发扩容和哈希链变长,间接影响性能。
正确的做法是 remove(),它会把 Entry 从 table 里摘掉,并顺手清理附近脏槽。
6.3 静态 ThreadLocal + 类加载器耦合
静态字段的 ThreadLocal 本身是强引用,key 永远不会变成 null。如果 value 里间接持有了某个 ClassLoader,或者某个 ClassLoader 加载的类实例,等于把整个类加载器、类元数据、单例对象全部拽在线程上。这在应用热部署、动态加载插件的场景下尤其致命,常见的表现是 Metaspace 疯涨、频繁 Full GC、类加载器无法回收。
这类问题的难点在于 ThreadLocal 的 key 是静态字段,反射看 ThreadLocalMap 时 entry.key 不是 null,所以 MAT 里你看到的是一大堆“存活但无意义”的 Entry。定位方式不是找 null key,而是分析 Entry.value 的引用链,看它是否连到了 ClassLoader。
6.4 防御性写法:try-finally 与工具封装
最不容易漏的写法,是把 set 和 remove 放进同一个 try-finally 结构:
java复制private static final ThreadLocal<RequestContext> CTX = new ThreadLocal<>();
void handleRequest(Request request) {
CTX.set(new RequestContext(request));
try {
process();
} finally {
CTX.remove();
}
}
如果一个项目里到处都要用 ThreadLocal,我倾向于做一个封装,把 set/remove 的生命周期管理收拢到一个方法里,减少业务开发漏写的概率。比如实现一个 ThreadLocalScope,用 AutoCloseable 配合 try-with-resources:
java复制public class ThreadLocalScope<T> implements AutoCloseable {
private final ThreadLocal<T> threadLocal;
public ThreadLocalScope(ThreadLocal<T> threadLocal, T value) {
this.threadLocal = threadLocal;
threadLocal.set(value);
}
@Override
public void close() {
threadLocal.remove();
}
}
调用方:
java复制try (ThreadLocalScope<RequestContext> ignored =
new ThreadLocalScope<>(CTX, new RequestContext(request))) {
process();
}
这样即使中间抛异常,close 也会执行 remove,把 Entry 彻底清掉。
7. 再遇到 background concurrent copying gc 时,我的排查清单
7.1 先抓堆,再翻代码
我的习惯是遇到 GC 压力异常时,不先对着代码猜。ThreadLocal 泄漏在外观上太像普通大对象泄漏了,直接翻 set/remove 调用点容易漏。先抓一份堆转储:
bash复制jmap -dump:live,format=b,file=heap.hprof <pid>
Android 场景无法直接使用 jmap,可以在应用里调用 Debug.dumpHprofData(path),或者结合 Android Studio 的 Memory Profiler 导出。然后放到 MAT 里看。
7.2 用 MAT 看 Thread 对象下的 ThreadLocalMap
打开 MAT 后,我一般手动做这几步:
- 在 Histogram 里搜索
ThreadLocal$ThreadLocalMap$Entry,看实例总数。 - 深入每个 Entry,看
value字段指向什么类型,是不是同一类业务对象。 - 再看
referent字段,如果发现大量 entry.get() == null 但 value != null,说明脏 entry 已经堆积。 - 顺着引用链回到线程对象,确认这些 Entry 究竟是哪些线程持有,线程池线程数是否和 Entry 数量级对得上。
如果 value 的类型非常集中,比如都是 RequestContext、UserSession、byte[] 等,同时持有它们的线程都是线程池里的长期线程,根因基本锁定。
7.3 快速止血和修根因
止血阶段,可以先把线程池大小降低,减少并发线程数量;同时在线程池任务执行链路上加一个
