如果你在日志里看到过 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 在设计上的一个取舍:索引
