用 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. 自测
- 弱引用与软引用的回收时机差别在哪一句上?
WeakHashMap的 Entry 里,key 和 value 分别是什么引用强度?这导致了什么泄漏窗口?- 为什么
"cfg"这样的字符串 key 放进WeakHashMap永远不会被清? size()为什么不能当作缓存条目数使用?ThreadLocal的 key 已经是弱引用,为什么线程池场景下仍会泄漏?- 用
SoftReference做缓存,为什么会出现"命中率忽高忽低"的抖动?