2639 字
约 8 分钟
20
S04-X08 · WeakHashMap 与引用队列:缓存为什么会被悄悄清空

用 WeakHashMap 做本地缓存的代码,通常活不过一次压测:命中率忽高忽低,size() 隔一会儿自己变小,偶尔连刚放进去的配置都读不到。原因不在 API 用错,而在这张表的键是被 GC 托管的——它压根不是缓存,是一个"键跟着业务对象一起死"的附属表。要看懂这个行为,得从四种引用强度和 ReferenceQueue 讲起。


1. 四种引用强度与回收时机

类型 回收时机 get() 是否入队 典型用途
强引用 永不回收(只要可达) 正常 否 普通对象
SoftReference 内存不足时(按 LRU 策略清) 可能返回 null 可选 内存敏感的缓存
WeakReference 下一次 GC 发现仅弱可达时 可能返回 null 可选 附属表、监听器注册表
PhantomReference 任何时候,finalize 之后 恒为 null 必须 回收通知、堆外资源释放
Object strong = new Object();
SoftReference<Object> soft = new SoftReference<>(strong);
WeakReference<Object> weak = new WeakReference<>(strong);
ReferenceQueue<Object> q = new ReferenceQueue<>();
PhantomReference<Object> phantom = new PhantomReference<>(strong, q);

关键区分:软引用是"内存紧张才让",弱引用是"下一轮 GC 就让"。前者适合"能重算的贵对象",后者适合"生命周期本就跟随另一个对象"的场景。


2. WeakHashMap 的结构:key 是 referent,value 是强引用

WeakHashMap 的 Entry 不是普通节点,它继承了 WeakReference:

private static class Entry<K,V> extends WeakReference<Object>
        implements Map.Entry<K,V> {
    V value;          // 强引用
    final int hash;
    Entry<K,V> next;
}

super(key, queue) 把 key 作为 referent 交给引用对象,value 则是一个普通字段。所以引用强度的分布是:

位置 引用强度 后果
table → Entry 强 Entry 本身不会因为 key 消失而消失
Entry → key(referent) 弱 key 无其他强引用时被回收
Entry → value 强 在 Entry 被移除前,value 一直活着

第三行是全部麻烦的来源:key 被回收后,get(key) 已经不可能命中,但 value 仍被 Entry 抓着,直到 expungeStaleEntries 把 Entry 摘掉才释放。这就是为什么必须有 ReferenceQueue——没有它,这张表会变成"key 死了、value 泄漏"的墓地。


3. 为什么 key 必须"没有其他强引用"

WeakHashMap<Object, String> map = new WeakHashMap<>();
Object k1 = new Object();
map.put(k1, "v1");
map.put(new Object(), "v2");      // 匿名 key:put 之后没有任何强引用
System.out.println(map.size());   // 2
System.gc();                      // 建议 GC,典型实现会执行一次 Full GC
System.out.println(map.size());   // 通常是 1:v2 的 key 已被回收
System.out.println(map.get(k1));  // v1 仍在:k1 是强引用,key 不会被回收
key 的形态 会被清空吗 原因
局部变量、字段持有 不会 强引用链仍在
new Object() 一次性传入 会 除弱引用外不可达
字符串字面量 "cfg" 不会 常量池/interning 持有强引用
Integer.valueOf(1)(缓存范围内) 不会 IntegerCache 持有强引用
String.intern() 的结果 不会 同上

用 System.gc() 做演示时有两个前提必须说清:它只是建议,HotSpot 默认会响应,但加了 -XX:+DisableExplicitGC 就完全不响应;而且"下一次 GC 回收弱引用"的时机由收集器决定,size() 之前若没有发生 GC,结果不会变。所以这类演示写"典型输出形态",不要当成确定性行为。

字符串与装箱缓存是最高频的误用:

WeakHashMap<String, Config> cache = new WeakHashMap<>();
cache.put("app", config);   // key 来自常量池,永远不会被回收
// 这张表等价于一个没有容量上限、没有淘汰策略的 HashMap

4. ReferenceQueue:清理是懒的,靠访问触发

WeakHashMap 构造时创建一个 ReferenceQueue,每个 Entry 注册到它上面。key 被回收后,JVM 把对应的 Reference 对象放进队列;WeakHashMap 在每次读写操作时调用 expungeStaleEntries() 把队列里的 Entry 摘掉。

private void expungeStaleEntries() {
    for (Object x; (x = queue.poll()) != null; ) {
        synchronized (queue) {
            Entry<K,V> e = (Entry<K,V>) x;
            int i = indexFor(e.hash, table.length);
            // 从桶链表里摘除 e,并置 e.value = null
        }
    }
}

由此得出三个可观测结论:

行为 真实原因
size() 调用后数字变小 size() 内部先执行 expungeStaleEntries()
长时间不访问,size() 仍偏大 清理是懒的,没人调用方法就不摘 Entry
表里出现 get(key) == null 但 value 仍占内存 key 已回收,Entry 尚未摘除
containsKey 对已死 key 返回 false 命中检查走的是 referent

第 2 条意味着:不能把 WeakHashMap.size() 当作"当前缓存条目数"。它反映的是"上次清理后的残余",统计口径天然不准。要做监控就得自己维护计数器,或者干脆换 Caffeine 的 estimatedSize()。

另外,WeakHashMap 不是线程安全的,并发写会破坏桶链表;必须包一层 Collections.synchronizedMap,而那样又会在每次操作上加全局锁。


5. ThreadLocal 为什么用弱引用 key 却仍会泄漏

ThreadLocalMap 的 Entry 与 WeakHashMap 同构,但泄漏路径更长:

static class Entry extends WeakReference<ThreadLocal<?>> {
    Object value;        // 强引用
    Entry(ThreadLocal<?> k, Object v) { super(k); value = v; }
}
环节 引用 强度
Thread → threadLocals 字段 强
ThreadLocalMap → Entry[] 数组 强
Entry → ThreadLocal(key) referent 弱
Entry → value 字段 强

于是:业务代码里的 static final ThreadLocal 被类加载器持有,key 不会死,value 也不会死——这是"忘记 remove 导致泄漏";反过来,若 ThreadLocal 是局部变量被回收,key 变成 null,但 value 仍被 Entry 强引用,而 Entry 被 Thread 强引用。线程池里的线程活很久,于是这些 null key 的 value 一直堆着。

JVM 的兜底是 set/get/remove 时顺带做 cleanSomeSlots 探测并清理几个槽位,但它是机会性的:如果之后再也不碰这个 ThreadLocalMap,那些槽位就永远留着。所以规范只有一句:

try {
    CONTEXT.set(ctx);
    // 业务
} finally {
    CONTEXT.remove();      // 必须,不是可选
}

remove() 显式删掉当前线程的 Entry,把 key 与 value 的链条一起断开。线程池下漏掉这行,泄漏量约等于"线程数 × 每次任务的对象大小",几天就能撑爆堆。


6. SoftReference 做缓存与 OOM 的关系

软引用被回收的时机不是"OOM 前一瞬间",而是 GC 时按空闲堆比例计算存活期。HotSpot 用 -XX:SoftRefLRUPolicyMSPerMB(默认 1000)控制:一个软引用在最后一次访问后,能存活约"每 MB 空闲堆 1000 毫秒"。堆越空留得越久,堆一紧就成批清掉。

参数 作用
调大 缓存命中率上升,OOM 风险上升
调小 缓存很快被清,接近每次重算

用软引用做缓存的实际问题是不可控:清理是全堆范围的批量行为,可能在一瞬间把热数据全清掉,随后引发"缓存击穿 → 回源 → 再次填满 → 再次被清"的抖动。而且软引用不提供容量上限、TTL、命中率统计——它只是"GC 帮你决定淘汰",把淘汰策略交给了收集器。


7. Guava / Caffeine 为什么不用 WeakHashMap

维度 WeakHashMap Guava Cache Caffeine
淘汰依据 仅 key 可达性 容量 + 权重 + TTL + 弱/软引用(可选) W-TinyLFU + TTL
容量上限 无 maximumSize maximumSize
命中率统计 无 recordStats() recordStats()
线程安全 否 是 是
清理时机 依赖 map 被访问 维护线程 + 访问时 维护线程 + 访问时
键弱引用 强制 可选 weakKeys() 可选 weakKeys()

三点根本差别:其一,WeakHashMap 没有缓存语义——它表达的是"条目跟着 key 的生命周期走",是附属数据结构;其二,它会丢数据,GC 认为 key 不可达就清,不管值刚写入、有多贵,用作配置缓存时表现为"刚加载的配置读不到";其三,它不能限制增长,key 都活着时表就无限长,而这正是缓存最需要的那条约束。

该用 WeakHashMap 的地方是"注册表"类结构:给对象挂附属数据、监听器列表、ClassLoader 级资源映射——条目跟着宿主一起消失才是正确行为。


8. 排错

现象 原因 处理
缓存内容莫名其妙消失 key 无强引用,被 GC 清掉 改强引用缓存(Guava/Caffeine)
put 之后立刻 get 不到 key 是一次性对象,随时可回收 持有 key 的强引用
size() 时大时小 懒清理 + GC 时机 不要用它做监控指标
字符串 key 永不释放 常量池/interning 持有 用 new String(...) 或换 key 类型
ThreadLocal 内存持续增长 线程池复用 + 未 remove() finally 里显式 remove()
NullPointerException 在 get 之后 key 已死,返回 null 同上,改用强引用缓存
并发下桶链表错乱 WeakHashMap 非线程安全 Collections.synchronizedMap 或换库

9. 边界

场景 该用什么 理由
对象附属数据(元数据、监听器) WeakHashMap 生命周期本就跟随宿主
需要容量上限的缓存 Caffeine / Guava Cache 有淘汰策略与统计
内存敏感但可重算 SoftReference 或 Caffeine softValues() 让 GC 参与淘汰
线程上下文传递 ThreadLocal + finally remove() 显式释放,不依赖 GC

10. 自测

  1. 弱引用与软引用的回收时机差别在哪一句上?
  2. WeakHashMap 的 Entry 里,key 和 value 分别是什么引用强度?这导致了什么泄漏窗口?
  3. 为什么 "cfg" 这样的字符串 key 放进 WeakHashMap 永远不会被清?
  4. size() 为什么不能当作缓存条目数使用?
  5. ThreadLocal 的 key 已经是弱引用,为什么线程池场景下仍会泄漏?
  6. 用 SoftReference 做缓存,为什么会出现"命中率忽高忽低"的抖动?
S04-X08 · WeakHashMap 与引用队列:缓存为什么会被悄悄清空
https://www.clxhxhhr.top/posts/5666/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。