概述
WeakHashMap 是 Java 集合框架中一个极具特殊使命的映射实现——它利用弱引用(WeakReference) 让键在不再被外部强引用时能被 GC 自动回收,从而在不干扰垃圾收集器的前提下,构建出内存敏感、可自我清理的缓存结构。本文将从弱引用本质出发,结合 JDK 8 源码深入剖析其 Entry 继承 WeakReference 的设计、引用队列(ReferenceQueue) 与 expungeStaleEntries 清理机制,完整梳理 put/get/remove/resize 等核心操作的实现细节,并通过与 HashMap 及主流缓存框架的对比,揭示它的适用场景、性能特征和常见陷阱。
- 基于弱引用的键管理:Entry 继承
WeakReference<Object>,将键封装为弱引用,当键不再被外部强引用时,下次 GC 后会被自动回收。 - 引用队列与滞后清理:每个 Entry 被 GC 回收后进入 ReferenceQueue,WeakHashMap 在 get/put/size 等操作时通过 expungeStaleEntries 同步清理对应的 Value 节点。
- 与 HashMap 同源的数组+链表结构:底层维护 Entry[] table,通过 key 的 hashCode 定位桶,扩容时容量翻倍,且 table 中每个桶是单链表结构。
- Value 强引用陷阱:若 Value 强引用了 Key(直接或间接),会导致 Key 无法被 GC 回收,弱引用形同虚设。
- 非线程安全与惰性清理:与 HashMap 一样线程不安全;清理过期条目不主动触发,依赖每次操作时的顺便清理,可能导致 Value 短暂驻留。
全文组织架构图
graph TD
subgraph Part1["基础认知篇"]
M1["模块1 定义 特性与适用场景"]
M2["模块2 接口与继承体系"]
end
subgraph Part2["存储与构造篇"]
M3["模块3 存储结构与核心字段"]
M4["模块4 构造方法"]
end
subgraph Part3["核心原理篇"]
M5["模块5 弱引用机制与ReferenceQueue清理"]
M6["模块6 put操作源码剖析"]
M7["模块7 get与remove操作源码剖析"]
M8["模块8 扩容resize"]
end
subgraph Part4["特性与约束篇"]
M9["模块9 null键的处理"]
M10["模块10 Value强引用陷阱与最佳实践"]
end
subgraph Part5["对比与陷阱篇"]
M11["模块11 WeakHashMap vs HashMap"]
M12["模块12 WeakHashMap vs 其他缓存方案"]
M13["模块13 常见陷阱与注意事项"]
end
subgraph Part6["总结与面试篇"]
M14["模块14 性能总结"]
M15["模块15 面试高频专题"]
end
Part1 --> Part2 --> Part3 --> Part4 --> Part5 --> Part6
图表说明:
- 整体结构:本文分为六大篇章,使用
subgraph展示逻辑递进关系:从基础认知开始,理解 WeakHashMap 是什么;再到存储构造,弄清底层数据结构;然后进入核心原理,通过源码深入弱引用与操作流程;接着探讨特性约束和对比陷阱;最后在总结面试篇完成归纳和面试考点命中。 - 第一层:基础认知篇——锚定 WeakHashMap 的定位、特性、适用场景与继承体系,帮助读者建立“它要解决什么问题”的整体框架。
- 第二层:存储与构造篇——聚焦 Entry 继承 WeakReference 的核心设计,以及 table、queue 等字段,为后续原理分析打下数据基础。
- 第三层:核心原理篇——本文的核心引擎,通过 JDK 8 源码拆解弱引用回收与 expungeStaleEntries 清理的闭环,并逐一剖析 put/get/remove/resize 的完整流程。
- 第四层:特性与约束篇——揭示 null 键的特殊处理和 Value 强引用陷阱,并给出可运行的 Demo 与修复方案。
- 第五层:对比与陷阱篇——将 WeakHashMap 与 HashMap、Guava Cache、Caffeine 进行多维度对比,归纳出选型标准和常见误区。
- 第六层:总结与面试篇——性能总结后,集中呈现10 个必考面试题,包含标准回答、追问模拟与加分回答,可作为面试前速查手册。
Part 1:基础认知篇
模块 1:定义、核心特性与适用场景
定义: WeakHashMap 是一种基于弱引用键(weak reference keys) 的哈希映射,它继承自 AbstractMap 并实现 Map 接口。当某个键不再被任何强引用可达时,该键会被 GC 回收,随后 WeakHashMap 内部会自动移除对应的键值对,从而避免内存泄漏。这个特性使它特别适合实现元数据缓存、监听器注册表等内存敏感的结构。
核心特性:
- 键可被 GC 自动回收:Entry 将 key 包装为
WeakReference,当外部没有强引用指向 key 时,下一次 GC 会将其清除。 - 惰性清理过期条目:不主动启动线程扫描,而是在
get/put/size等方法中顺便调用expungeStaleEntries()清理已被回收的 Entry。 - 基于 ReferenceQueue 的追踪:每个 Entry 在构造时将自己注册到一个内部
ReferenceQueue,当弱引用被 GC 清除后,Entry 会被放入该队列,成为可清理的“脏”引用。 - 非线程安全:与 HashMap 一致,多线程并发修改会导致不可预期的行为或
ConcurrentModificationException。 - 允许 null 键和值:null 键会被替换为内部常量
NULL_KEY以兼容弱引用机制。 - 无序:不保证映射顺序,特别是随着键被回收,迭代顺序更加不可预测。
- 初始容量与负载因子:同 HashMap,默认初始容量 16,负载因子 0.75,可配置。
适用场景:
- 内存敏感缓存:如需缓存大量对象的元数据(如类、方法、字段的描述信息),但不想因为缓存而阻碍这些对象被 GC 回收,可以利用 WeakHashMap,当外部不再使用这些对象时,缓存自动消失。
- 弱引用监听器列表:在事件模型中,如果将监听器存储为 WeakHashMap 的键,当监听器对象不再被其他强引用可达时,映射中对应的监听器条目会被自动清理,避免显式注销。
- 临时映射关联:需要在某一生命周期内为对象附加一些额外数据,且这些数据的生命周期应绑定到键对象的生命周期,此时 WeakHashMap 是理想选择。
- 反射缓存:如 JDK 内部
java.beans.Introspector等,使用 WeakHashMap 缓存类的 BeanInfo,当类被卸载时缓存自动清空。
反例场景:
- 需要确定性生命周期:如果必须精确控制何时删除映射,不应使用 WeakHashMap,而应手动移除或使用
LinkedHashMap的 LRU 驱逐。 - 强一致性缓存:WeakHashMap 的清理依赖于 GC 时机,无法保证缓存项在何时失效,可能导致旧值短暂存在。
- 高并发缓存:没有内置同步,无法直接用于并发环境,需配合
Collections.synchronizedMap或改用更成熟的并发缓存。 - 值需要引用键的缓存:如值持有键的强引用,会导致弱引用失效,详见模块 10。
决策树:
flowchart TD
A[是否需要自动回收键的缓存?] -->|否| B[使用 HashMap 或其他 Map]
A -->|是| C{键是否会被外部强引用?}
C -->|键本身将不再被外部强引用| D{值是否强引用了键?}
C -->|键始终被外部强引用| B
D -->|否| E{是否需要线程安全?}
D -->|是| F[需要解除值对键的强引用<br>或改用其他缓存]
E -->|否| G[可以使用 WeakHashMap]
E -->|是| H[用 Collections.synchronizedMap 包装<br>或使用 Guava Cache/Caffeine]
图表说明:
- 第一层:决策起点——是否需要基于键的自动回收?若不需要,普通
HashMap足够。 - 第二层:键的引用状态——即使值存入 WeakHashMap,如果键本身在外部被强引用(如局部变量、成员变量等),该键就不会被 GC 回收,相当于普通 HashMap。只有当键不再被强引用时,WeakHashMap 的弱引用特性才起作用。
- 第三层:值强引用检查——这是最关键的陷阱。如果值对象内部隐式或显式强引用了键(如值持有键的子对象,而子对象又引用了键),那么键依然无法回收,需要设计上解耦或使用弱引用包装。
- 第四层:线程安全需求——WeakHashMap 线程不安全,并发环境需要额外包装或选用
ConcurrentHashMap+WeakReference的替代方案。
模块 2:接口与继承体系
WeakHashMap 与 HashMap 保持相同的继承链,直接继承 AbstractMap 并实现 Map 接口,使其完美融入 Java 集合框架。
classDiagram
class Map {
<<interface>>
+put(K key V value)
+get(Object key)
+remove(Object key)
+size()
+clear()
}
class AbstractMap {
<<abstract>>
#transient Set keySet
#transient Collection values
+size()
+isEmpty()
+containsValue(Object value)
+containsKey(Object key)
+get(Object key)
+put(K key V value)
+remove(Object key)
+clear()
+keySet()
+values()
+entrySet()
}
class WeakHashMap {
-Entry[] table
-int size
-int threshold
-float loadFactor
-ReferenceQueue queue
-int modCount
+WeakHashMap()
+WeakHashMap(int initialCapacity)
+WeakHashMap(int initialCapacity float loadFactor)
+WeakHashMap(Map m)
+put(K key V value)
+get(Object key)
+remove(Object key)
+size()
-expungeStaleEntries()
-resize(int newCapacity)
}
Map <|.. AbstractMap
AbstractMap <|-- WeakHashMap
Map <|.. WeakHashMap
图表说明:
- 第一层:Map 接口——定义映射的基本契约,包含
put、get、remove、size等核心方法。 - 第二层:AbstractMap 抽象类——提供 Map 接口的大部分骨架实现,如
containsKey、containsValue、size、isEmpty等,子类只需实现entrySet()即可获得完整映射功能。WeakHashMap 重写了大部分方法以注入清理逻辑。 - 第三层:WeakHashMap 具体实现——引入
ReferenceQueue、Entry[] table、expungeStaleEntries、resize等私有成员,实现弱引用键的自动回收。它同时实现了Map接口,因此可以无缝替换普通 Map 使用。
Part 2:存储与构造篇
模块 3:存储结构与核心字段(源码剖析)
WeakHashMap 的底层结构与 HashMap 类似,均采用 数组 + 链表 的哈希表实现,但其最关键的差异在于节点 Entry 的设计。
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;
Entry(Object key, V value, ReferenceQueue<Object> queue, int hash, Entry<K,V> next) {
super(key, queue); // 将 key 作为弱引用,并注册到引用队列
this.value = value;
this.hash = hash;
this.next = next;
}
// ... getKey/setValue/equals/hashCode 等
}
Entry 继承 WeakReference<Object>,这意味着它本身就是一个弱引用,引用的对象是 key。在构造时,它会调用 super(key, queue) 将键封装为弱引用并注册到一个 ReferenceQueue 中。当 GC 回收 key 后,这个弱引用(即该 Entry)会被 JVM 自动添加到 queue 中,成为“过期条目”。
核心字段:
public class WeakHashMap<K,V> extends AbstractMap<K,V> implements Map<K,V> {
// 哈希桶数组,存储链表头节点
Entry<K,V>[] table;
// 当前映射中的存活条目数(未被 GC 清除的有效条目)
private int size;
// 扩容阈值 (capacity * loadFactor)
private int threshold;
// 负载因子
private final float loadFactor;
// 引用队列,GC 会将被回收的弱引用(Entry)放入此队列
private final ReferenceQueue<Object> queue = new ReferenceQueue<>();
// 结构化修改次数,用于 fail-fast 迭代器
int modCount;
// ...
}
结构示意图:
classDiagram
class WeakHashMap {
- Entry[] table
- ReferenceQueue queue
- int size
}
class Entry~K,V~ {
+getKey() K
+getValue() V
+V value
+int hash
+Entry next
}
class WeakReference~Object~ {
+get() Object
+clear()
+isEnqueued()
}
class ReferenceQueue {
+poll()
+remove()
}
WeakHashMap "1" *-- "0..*" Entry : table
Entry --|> WeakReference : extends
WeakReference --> ReferenceQueue : registered to
图表说明:
- 第一层:关系总览——WeakHashMap 持有一个
Entry[] table数组,数组的每个位置指向一个Entry链表的头结点;同时持有一个内部ReferenceQueue。 - 第二层:Entry 的多重角色——Entry 既是映射节点(包含 value、hash、next),又是一个弱引用(继承 WeakReference,指向 key)。这种设计使得当 key 对象仅剩这个弱引用时,GC 可以直接回收 key。
- 第三层:引用队列绑定——Entry 构造时将自己注册到
queue,GC 在回收 key 后会把对应的弱引用(Entry)加入queue。WeakHashMap 通过轮询queue即可得知哪些 Entry 已“过期”,从而清理它们并释放 value 和节点本身。
模块 4:构造方法
WeakHashMap 提供了四个构造器,与 HashMap 的构造器签名几乎一致,但内部处理略有差别:所有构造方法都直接分配 table 数组,而不采用延迟分配。
// 默认容量 16,负载因子 0.75
public WeakHashMap() {
this(16, 0.75f);
}
// 指定初始容量,使用默认负载因子
public WeakHashMap(int initialCapacity) {
this(initialCapacity, 0.75f);
}
// 指定初始容量和负载因子
public WeakHashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0) throw new IllegalArgumentException(...);
if (initialCapacity > MAXIMUM_CAPACITY) initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor)) throw new IllegalArgumentException(...);
int capacity = 1;
while (capacity < initialCapacity)
capacity <<= 1; // 调整为 2 的幂
table = newTable(capacity); // 直接分配 table
this.loadFactor = loadFactor;
threshold = (int)(capacity * loadFactor);
}
// 用给定 Map 初始化
public WeakHashMap(Map<? extends K, ? extends V> m) {
this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1, DEFAULT_CAPACITY), DEFAULT_LOAD_FACTOR);
putAll(m); // 内部会调用 put,触发清理
}
关键点: 构造方法中 newTable(capacity) 会直接创建数组,这与 JDK 8 中 HashMap 的延迟分配(首次 put 时才分配)不同。此外,queue 作为 final 字段在对象创建时即初始化,保证了每个 WeakHashMap 实例都有独立的引用队列来追踪过期条目。
Part 3:核心原理篇
模块 5:弱引用机制与 ReferenceQueue 清理(源码剖析)
WeakHashMap 的自动清理能力源于 弱引用 + 引用队列 的配合。
弱引用回收与入队过程:
- 创建 Entry:
put时,Entry构造器执行super(key, queue),将key包装为一个弱引用,同时指定回调队列queue。 - 外部强引用消失:当程序中所有指向该
key的强引用(方法局部变量、成员字段等)不可达后,只剩Entry内部的弱引用。 - GC 触发:JVM 在下次 GC 时检测到该对象只有弱引用可达,于是回收
key对象,并将对应的WeakReference(即该Entry)加入queue。 - 惰性清理:WeakHashMap 并不主动监控
queue,而是在get/put/size等操作入口处调用expungeStaleEntries(),从queue中批量移除所有已入队的过期条目。
交互时序图:
sequenceDiagram
participant AppThread as 应用线程
participant WeakHashMap as WeakHashMap
participant Entry as Entry (WeakReference)
participant JVM as JVM/GC
participant Queue as ReferenceQueue
participant Key as Key 对象
AppThread->>WeakHashMap: put(key, val)
WeakHashMap->>Entry: new Entry(key, val, queue)
Entry->>Queue: 注册弱引用(指向 key)
AppThread-->>Key: 移除所有强引用 (key = null)
Note over JVM,Key: 未来某时刻 GC 发生
JVM->>Key: 回收 Key 对象
JVM->>Entry: 将 Entry 弱引用加入 Queue
Queue->>Entry: 入队
AppThread->>WeakHashMap: get(someKey) / put / size
WeakHashMap->>WeakHashMap: expungeStaleEntries()
loop 遍历 queue 中所有过期 Entry
WeakHashMap->>Queue: poll() 取出过期 Entry e
Queue-->>WeakHashMap: e
WeakHashMap->>WeakHashMap: 从 table 链表中移除 e
WeakHashMap->>Entry: e.value = null (释放 value)
WeakHashMap->>WeakHashMap: size--
end
图表说明:
- 第一层:put 时注册——put 操作创建 Entry,Entry 构造器调用
super(key, queue),将弱引用与队列绑定。此时Queue与Entry之间建立了等待通知关系。 - 第二层:GC 介入——当应用线程不再持有对 key 的强引用后,下一次 GC 周期中,JVM 会回收该 key 对象,并触发弱引用入队动作。入队发生在 GC 阶段,应用线程无法直接感知。
- 第三层:惰性清理——应用线程执行
get/put/size等方法时,WeakHashMap 调用expungeStaleEntries()方法来主动从队列中批量清空所有过期 Entry。注意,此时 Entry 本身尚未被 GC(因为 value 和链表 next 还有引用),清理过程会将其从 table 链表中摘除并释放 value,但 Entry 节点对象本身要等下次 GC 才会回收。 - 关键结论:过期条目的清理不是实时的,而是“按需清理”——只有在对 WeakHashMap 进行操作时才会执行。这意味着
size()返回值可能略大于实际存活条目,因为已经回收键但尚未清理的条目仍计入 size,直到下一次触发expungeStaleEntries。
核心方法 expungeStaleEntries 源码分析:
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); // 根据 hash 定位桶
Entry<K,V> prev = table[i];
Entry<K,V> p = prev;
while (p != null) {
Entry<K,V> next = p.next;
if (p == e) {
if (prev == e)
table[i] = next; // 是链表头
else
prev.next = next; // 从链表中删除
e.value = null; // 解除 value 强引用
size--;
break;
}
prev = p;
p = next;
}
}
}
}
该方法通过轮询 queue,取出所有已入队的过期 Entry,然后通过 indexFor(e.hash, table.length) 定位桶,单链表遍历删除。删除后显式置 e.value = null 是为了帮助 GC 更快回收 value(尤其在 value 占用较大内存时)。
模块 6:put 操作(源码剖析)
put(K key, V value) 的完整流程包含了清理、定位、遍历和扩容,体现了 WeakHashMap 的“操作自带清理”思想。
graph TD
Start(["put K V"]) --> Expunge["调用 expungeStaleEntries 清理过期条目"]
Expunge --> HashCalc["计算哈希 key等于null则为0 否则key.hashCode"]
HashCalc --> Index["定位桶索引 indexFor hash table长度"]
Index --> Iterate["遍历链表"]
Iterate --> Check{"找到相同键"}
Check -->|"是"| UpdateVal["更新value 返回旧值"]
Check -->|"否 遍历完链表"| Insert["创建新Entry 头插法插入链表"]
Insert --> IncMod["modCount加一"]
IncMod --> AddSize["sz等于size自增"]
AddSize --> CheckThres{"sz大于等于阈值"}
CheckThres -->|"是"| Resize["resize 扩容"]
CheckThres -->|"否"| Finish(["结束"])
UpdateVal --> Finish
Resize --> Finish
图表说明:
- 第一层:清理优先——每次
put最先调用expungeStaleEntries(),确保在插入前尽可能清理已回收键的过期条目,释放内存并腾出空间。这是惰性清理的体现。 - 第二层:哈希与定位——null 键的哈希值为 0,其他键使用
key.hashCode()。indexFor使用h & (length-1)取模(数组长度恒为 2 的幂)。注意:这里的哈希未经过二次扰动,直接使用原始 hashCode,与 JDK 8 HashMap 不同。 - 第三层:链表遍历匹配——通过
==或equals判断键是否相同。这里可能存在键已被 GC 回收但尚未清理的 Entry,其getKey()返回null(弱引用返回 null),在源码中会作为不匹配处理(除非传入的 key 也是 null,但 null 键被特殊处理,见模块 9)。 - 第四层:插入与扩容——新节点采用头插法插入链表。插入后
size自增,若size >= threshold则触发resize。注意:因为 WeakHashMap 的size还包含未清理的过期条目,所以扩容实际阈值可能比预期低(部分 size 是“脏”条目)。 - 关键结论:
put操作整体时间复杂度为 O(1) 平均,但由于内部会调用expungeStaleEntries清空队列,其耗时可能受 GC 回收频率影响,导致偶尔出现批量清理的停顿。
put 方法简化源码逻辑:
public V put(K key, V value) {
Object k = maskNull(key); // null 键特殊处理,替换为 NULL_KEY 对象
int h = hash(k);
Entry<K,V>[] tab = getTable(); // 内部调用 expungeStaleEntries
int i = indexFor(h, tab.length);
for (Entry<K,V> e = tab[i]; e != null; e = e.next) {
if (h == e.hash && eq(k, e.get())) {
V oldValue = e.value;
if (value != oldValue) e.value = value;
return oldValue;
}
}
modCount++;
Entry<K,V> e = tab[i];
tab[i] = new Entry<>(k, value, queue, h, e);
if (++size >= threshold)
resize(tab.length * 2);
return null;
}
模块 7:get 与 remove 操作(源码剖析)
get 和 remove 同样遵循“先清理后执行”的原则。
graph TD
subgraph Get["get Object key"]
G1["调用 expungeStaleEntries"] --> G2["计算哈希与桶索引"]
G2 --> G3["遍历链表"]
G3 --> G4{"Entry的key与key匹配"}
G4 -->|"匹配"| G5["返回e的value"]
G4 -->|"不匹配"| G3
G5 --> GEnd["结束"]
end
subgraph Remove["remove Object key"]
R1["调用 expungeStaleEntries"] --> R2["计算哈希与桶索引"]
R2 --> R3["遍历链表 查找节点"]
R3 --> R4{"找到"}
R4 -->|"是"| R5["从链表删除"]
R5 --> R6["置空e的value size减一 modCount加一"]
R6 --> R7["返回旧值"]
R4 -->|"否"| R8["返回 null"]
end
图表说明:
- 第一层:公共前置步骤——与
put相同,get和remove首先调用expungeStaleEntries(),确保不会使用已被 GC 回收的键。 - 第二层:遍历查找——通过
indexFor定位桶,遍历单链表。查找条件为hash 相等 && (引用相等或 equals)。值得注意的是,遍历过程中如果e.get()返回 null(弱引用已失效),则不会匹配,效果等同于跳过该节点。 - 第三层:remove 的额外清理——删除后显式将
e.value = null,帮助 GC 回收 value。如果在遍历时恰好遇到过多过期条目,由于get/remove只负责清理队列中的条目,链表中的过期但未入队节点不会在此次操作中移除(它们会在下一次清理时处理)。不过正常情况下,GC 入队与清理是配套的。 - 关键结论:
get操作因额外的清理步骤,其在热点路径上的开销可能略高于普通 HashMap,但仍在 O(1) 范围。正是这种“带清理”的设计,使得 WeakHashMap 在自动回收的同时,避免显式维护线程进行后台清除。
remove 源码要点:
public V remove(Object key) {
Object k = maskNull(key);
int h = hash(k);
Entry<K,V>[] tab = getTable(); // expungeStaleEntries
int i = indexFor(h, tab.length);
Entry<K,V> prev = tab[i];
Entry<K,V> e = prev;
while (e != null) {
Entry<K,V> next = e.next;
if (h == e.hash && eq(k, e.get())) {
modCount++;
size--;
if (prev == e)
tab[i] = next;
else
prev.next = next;
return e.value;
}
prev = e;
e = next;
}
return null;
}
模块 8:扩容(resize)
WeakHashMap 的扩容机制与 JDK 1.7 的 HashMap 类似,采用“翻倍容量 + 重新哈希”,且不区分高低位链表。
触发条件: 当前 size >= threshold(其中 threshold = capacity * loadFactor),且 getTable() 中的清理操作会导致 size 可能小于实际占用。
void resize(int newCapacity) {
Entry<K,V>[] oldTable = getTable();
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
Entry<K,V>[] newTable = newTable(newCapacity);
transfer(oldTable, newTable); // 迁移数据
table = newTable;
if (size >= threshold / 2) {
threshold = (int)(newCapacity * loadFactor);
} else {
expungeStaleEntries();
transfer(newTable, oldTable); // 部分文献实现不同,实际 JDK 8 中会重新计算阈值并可能再次清理
table = oldTable;
}
}
迁移过程 transfer:
private void transfer(Entry<K,V>[] src, Entry<K,V>[] dest) {
for (int j = 0; j < src.length; j++) {
Entry<K,V> e = src[j];
if (e != null) {
src[j] = null;
do {
Entry<K,V> next = e.next;
Object key = e.get();
if (key == null) { // 键已被 GC 回收
e.next = null;
e.value = null; // 释放 value
size--;
} else {
int i = indexFor(e.hash, dest.length);
e.next = dest[i];
dest[i] = e;
}
e = next;
} while (e != null);
}
}
}
扩容流程图:
graph TD
Start(["size大于等于阈值"]) --> GetTable["调用 getTable 清理过期条目"]
GetTable --> CheckCap{"当前容量是否最大"}
CheckCap -->|"是"| SetThreshold["threshold等于整数的最大值 结束"]
CheckCap -->|"否"| NewTable["创建新表 容量乘以2"]
NewTable --> Transfer["transfer 迁移元素"]
Transfer --> ForEach["遍历旧表每个桶"]
ForEach --> NodeCheck{"节点key是否为空"}
NodeCheck -->|"空已回收"| RemoveNode["跳过 释放value size减一"]
NodeCheck -->|"非空"| Rehash["重新哈希插入新表"]
Rehash --> NextNode["继续下一个节点"]
RemoveNode --> NextNode
NextNode --> EndLoop["全部迁移完成"]
EndLoop --> UpdateThres["更新阈值"]
UpdateThres --> End(["扩容结束"])
图表说明:
- 第一层:扩容前清理——
resize首先调用getTable()触发一次清理,尽可能让真实 live 条目减少。由于扩容是一个重量级操作,提前清理可以避免迁移大量已失效的 Entry。 - 第二层:容量检查与分配——新容量为旧容量的两倍(不超过
MAXIMUM_CAPACITY)。newTable也是直接分配数组。 - 第三层:迁移与草除——
transfer方法遍历每个桶的链表。关键点:如果某个 Entry 的key为null(已回收),说明该条目已过期,直接跳过并清理 value,同时size--。只有 key 仍然存活的 Entry 才会被重新哈希迁移,从而避免脏数据进入新表。这个过程中,过期条目被一并清理了。 - 第四层:阈值更新——迁移完成后,根据新的容量重新计算
threshold = newCapacity * loadFactor。注意:由于transfer中可能进一步清理过期条目,size会减小,因此需要重新计算合理的阈值。
Part 4:特性与约束篇
模块 9:null 键的处理
WeakHashMap 允许 null 键,但内部进行了特殊处理。由于 WeakReference 不能通过 null 构造,源码中定义了一个内部常量:
private static final Object NULL_KEY = new Object();
- put 时:调用
maskNull(key)方法,如果 key 为 null,返回NULL_KEY对象,否则返回原 key。 - get/remove/containsKey:同样先用
maskNull(key)转换,再参与哈希计算和 equals 比较。 - 弱引用回收:
NULL_KEY作为一个普通对象被弱引用包装,当 WeakHashMap 本身不再使用时,这个NULL_KEY对象仍然可能被外部强引用吗?其实NULL_KEY是 static final,始终有强引用,因此 null 键永远不会被 GC 回收。但这符合预期:null 键本身就代表一个特殊标记,它的唯一性由 WeakHashMap 保证,开发人员不应依赖它对 null 的自动回收。实际上,对于 null 键,其行为和普通 HashMap 类似。
设计意图: 允许 null 键是为了保持与 Map 接口的一致性,但实际中应避免依赖 WeakHashMap 对 null 键的自动回收。
模块 10:Value 强引用陷阱与最佳实践
陷阱描述:
这是 WeakHashMap 使用中最隐蔽的陷阱。假设我们使用 WeakHashMap 缓存某个对象 Key 的附加信息,而值对象 Value 内部又强引用了 Key(例如 Value 是 Key 的某个回调,直接持有 Key 的引用),那么即使外部不再持有 Key,由于 Value 仍被 WeakHashMap 的 value 字段强引用,而 Value 又强引用 Key,这就形成了一个 Key → WeakHashMap → Entry → Value → Key 的强引用链,导致 Key 永远不会被 GC 回收,WeakHashMap 退化为普通 HashMap,甚至造成内存泄漏。
演示代码:
import java.util.WeakHashMap;
public class WeakHashMapTrapDemo {
static class Key {
String id;
Key(String id) { this.id = id; }
public String toString() { return "Key(" + id + ")"; }
}
static class Value {
String data;
Key strongRefToKey; // 故意强引用 Key,模拟回调或监听器
Value(String data, Key k) {
this.data = data;
this.strongRefToKey = k;
}
}
public static void main(String[] args) {
WeakHashMap<Key, Value> map = new WeakHashMap<>();
Key key = new Key("k1");
Value val = new Value("important data", key); // Value 持有 Key 强引用
map.put(key, val);
// 移除外部强引用
key = null;
System.gc();
System.out.println("map size: " + map.size()); // 期望 0,实际 1
}
}
运行结果:强烈依赖于 GC 时机,但大概率 size 为 1,因为 Value 强引用 Key,Key 无法被回收。
修复方案:
- 避免 Value 持有 Key 的强引用:如果 Value 确实需要感知 Key,可只存储 Key 的某个派生信息(如 id),或使用
WeakReference<Key>包装,切断强引用链。 - 重新设计 Value 结构:让 Value 不依赖 Key 对象本身。
修复后代码:
static class SafeValue {
String data;
WeakReference<Key> weakRefToKey; // 弱引用,不阻碍 GC
SafeValue(String data, Key k) {
this.data = data;
this.weakRefToKey = new WeakReference<>(k);
}
}
// 使用 SafeValue 后,key = null; gc; map.size() 将为 0。
最佳实践:
- 在将对象作为 WeakHashMap 的值时,检查类型层次,确保值不会反向强引用键。
- 对于缓存实现,最好对 key 和 value 都进行弱引用包装(如 Guava 的
CacheBuilder.weakKeys().weakValues())。 - 若值必须引用键,请改用
WeakReference或SoftReference,并接受值可能需要配合手动清理或延迟更新。
Part 5:对比与陷阱篇
模块 11:WeakHashMap vs HashMap——通用与专用
虽然结构相似,但二者在键的周期和内在开销上有本质区别。
graph TD
Start["需要选择Map实现"] --> Q1{"是否需要键自动回收"}
Q1 -->|"是"| Q2{"值是否强引用键"}
Q1 -->|"否"| UseHM["使用 HashMap"]
Q2 -->|"否"| Q3{"是否需要跨操作即时清理"}
Q2 -->|"是"| Resolve["消除值对键的强引用 或改用其他缓存"]
Q3 -->|"否"| UseWHM["使用 WeakHashMap"]
Q3 -->|"是"| Q4{"需精确控制生命周期"}
Q4 -->|"是"| Cache["使用 Cache 框架 Guava或Caffeine"]
Q4 -->|"否"| UseWHM
对比总结:
| 维度 | WeakHashMap | HashMap |
|---|---|---|
| 键的持有方式 | 弱引用,允许 GC 回收 | 强引用,永不自动移除 |
| 内存占用 | 惰性清理,可能持有额外失效对象 | 确切占用,需手动移除 |
| 性能 | 每次操作可能触发 expungeStaleEntries,有额外开销 | 无清理开销,性能更稳定 |
| 适用场景 | 缓存、元数据映射,生命周期绑定键 | 通用映射 |
| 线程安全 | 非线程安全 | 非线程安全 |
| null 键 | 允许(替换为 NULL_KEY) | 允许(存放在 table[0]) |
图表说明:
- 第一层:键自动回收需求——这是分水岭。如果不需要,HashMap 更简单直接。
- 第二层:值强引用检查——如果值强引用键,WeakHashMap 弱引用失效,必须重构。
- 第三层:即时清理需求——WeakHashMap 是惰性清理,如果要求键被 GC 后立刻可见、size 立即更新,或者需要基于时间的过期策略,应选择成熟的缓存框架而非 WeakHashMap。
- 关键结论:WeakHashMap 是最轻量的自动回收映射,但代价是清理的滞后和无主动驱逐策略,适用于“能被回收就好,晚一点无所谓”的非严格场景。
模块 12:WeakHashMap vs 其他缓存方案
WeakHashMap 常被误称为“缓存”,但实际上它缺少现代缓存框架的关键特性:
| 特性 | WeakHashMap | LruCache (android) | Guava Cache | Caffeine |
|---|---|---|---|---|
| 自动回收键 | 基于 GC | 无 (强引用) | 支持弱/软键 | 支持弱/软键 |
| 淘汰策略 | 无 (仅 GC) | LRU | 基于大小、时间、引用等 | 基于大小、时间、频率等 |
| 统计信息 | 无 | 命中/插入/驱逐计数 | 丰富 | 极丰富 |
| 异步刷新 | 无 | 无 | 支持 | 支持 |
| 清理机制 | 惰性 expungeStaleEntries | 同步逐出 | 同步/异步清理 | 环形缓冲区,极高性能 |
| 线程安全 | 否 | 否 | 是 | 是 |
适用性判断: 如果你的场景仅仅需要“当键对象没人用了,缓存自动消失”,而没有容量限制、时间过期、统计需求,那么 WeakHashMap 零依赖、足够轻量。一旦需求复杂,直接选用 Guava Cache 或 Caffeine 等成熟库。
模块 13:常见陷阱与注意事项
陷阱 1:Value 持有 Key 强引用导致内存泄漏
- 表现:弱引用键永不回收。
- 后果:缓存无限膨胀,失去自动回收意义。
- 措施:审查 value 对象树,剔除对 key 的强引用,或使用
WeakReference包裹。
陷阱 2:依赖 size() 的实时性
- 表现:键已被 GC,但
size()仍返回旧值。 - 原因:
expungeStaleEntries只在特定操作时调用,size()本身并不会主动清理(在 Java 8 中size()方法仅返回size变量,但size是经过expungeStaleEntries调整后的值吗?实际上size()方法实现为:public int size() { if (size == 0) return 0; expungeStaleEntries(); return size; },也就是 size() 会主动调用清理。但注意源码中确实有expungeStaleEntries()调用,所以 size() 本身会清理并返回最新值,但其他未调用清理的操作可能让 size 不实时。陷阱描述可能需要调整:在一些老版本或某些不清理的方法中,size 可能不实时,但 JDK 8 的 size() 会清理。可描述为“如果依赖 for 循环或迭代时的状态,由于迭代器不会主动清理,可能看到过期条目”等。) - 纠正:JDK 8 中
size()会触发expungeStaleEntries(),因此size()返回的值是实时的。但迭代器可能包含过期条目(迭代器的 snapshot 在创建时未清理),或者isEmpty()方法同样会清理。实际陷阱更多在于“迭代期间遇到已回收键”或“多条操作间隙的不一致”。
陷阱 3:多线程并发修改抛出 ConcurrentModificationException
- 表现:多线程同时 put/get,可能破坏链表结构或触发 fail-fast。
- 措施:包装为
Collections.synchronizedMap(new WeakHashMap<>()),但需要注意同步仍然不能解决复合操作的原子性,必须额外同步。
陷阱 4:使用可变对象作为键且未正确覆盖 hashCode/equals
- 表现:键在存入后状态改变,导致
hashCode或equals结果变化,后续无法定位到 Entry。 - 后果:条目变为“僵尸”条目,无法被 GC(强引用仍然存在),也无法被正确查找。
- 措施:始终使用不可变对象作为键,或者确保键的状态在映射生命周期内不会改变。
Part 6:总结与面试篇
模块 14:性能总结
- 时间复杂度:
put、get、remove平均 O(1),最坏 O(n)(哈希冲突严重时)。这点与 HashMap 相同,但常数因子更大,因为每次操作可能调用expungeStaleEntries(),遍历引用队列。 - 空间开销:Entry 继承 WeakReference 本身增加了对象头大小(相比 HashMap 的 Node),同时
queue的维护和过期条目清理前会有额外的内存驻留。 - 清理开销:
expungeStaleEntries()会一次性清空引用队列中所有过期 Entry,如果长时间未调用清理,队列中可能积累大量条目,导致某次put操作出现延迟峰值。 - 适用建议:在键生命周期短、值较小的场景,WeakHashMap 体现内存优势;在键生命周期长、频繁写操作或大 value 场景,清理开销和内存驻留会成为负担,此时应选择合适的缓存库而非 WeakHashMap。
模块 15:面试高频专题
1. WeakHashMap 的工作原理是什么?和 HashMap 有什么本质区别?
- 标准回答:WeakHashMap 的 Entry 继承了 WeakReference,将键封装为弱引用并注册到 ReferenceQueue。当键不再被外部强引用时,GC 回收键并将 Entry 放入 ReferenceQueue。WeakHashMap 在 get、put 等方法中调用 expungeStaleEntries() 从队列中移除对应节点。本质区别在于 HashMap 对键是强引用,键永远不会被 GC 自动回收;而 WeakHashMap 是弱引用,允许键在无强引用时被 GC 回收。
- 追问模拟:“那如果键被回收了,Value 会怎样?”
- 加分回答:Value 必须依赖下一次 expungeStaleEntries 清理,清理时会将 Value 引用置 null 并移除节点,从而使 Value 可被 GC。但若在此之前,Value 自身还持有其他强引用,则需等到那些引用也消失。此外,一定要小心 Value 持有 Key 的强引用导致的循环引用问题。
- 追问:“expungeStaleEntries 如果长时间不调用,会不会内存泄漏?”
- 回答:确实会!因为 queue 中已经移除了键的 Entry 无法被清理,导致 Value 强引用无法释放,内存泄漏。但只要后续有操作触发清理,就会一次性清空。实际上
size()等也会触发清理,所以通常不会无限期驻留。
- 回答:确实会!因为 queue 中已经移除了键的 Entry 无法被清理,导致 Value 强引用无法释放,内存泄漏。但只要后续有操作触发清理,就会一次性清空。实际上
2. WeakHashMap 是如何实现键的自动回收的?引用了哪个特殊的类?
- 回答:通过让 Entry 继承
WeakReference<Object>,构造时传入 key 和内部 ReferenceQueue。当 key 只被弱引用可达时,GC 清除 key 并将此 WeakReference 加入到 ReferenceQueue。WeakHashMap 通过ReferenceQueue轮询获得已回收的 Entry。 - 加分回答:可进一步说明
ReferenceQueue本身是线程安全的,GC 线程和 WeakHashMap 的应用线程通过它通信。WeakHashMap 在 JDK 8 使用synchronized (queue)来保证批量移除的线程安全(尽管整体映射非线程安全)。
3. 什么是弱引用(WeakReference)?在 WeakHashMap 中起到什么作用?
- 回答:弱引用是 Java 引用强度中比软引用更弱的一种引用,只要发生 GC,不管内存是否充足,被弱引用指向的对象都会被回收。在 WeakHashMap 中,Entry 作为弱引用指向 key,使得一旦外部强引用消失,key 可在下次 GC 时回收,进而触发自动清理。这本质上是利用 JVM 的引用处理框架实现“对象消亡时自动移除映射”的通知机制。
4. expungeStaleEntries 方法的作用是什么?在什么时候被调用?
- 回答:该方法从 ReferenceQueue 中取出所有过期 Entry,从哈希表的链表中删除它们,并将 value 置 null,size 递减。它在
getTable()中被调用,而getTable()被size()、isEmpty()、put、get、remove、resize等几乎所有公开方法内部使用,因此是一种按需惰性清理。注意:迭代器创建时可能不会调用,所以迭代过程中可能看到键为 null 的条目,遍历时需处理null键。
5. 为什么说 WeakHashMap 不能用作缓存?有哪些局限?
- 回答:它极度依赖 GC 时机,无法控制过期策略(无 TTL,无最大容量),没有统计,没有主动清理线程,清理滞后,且非线程安全。它更像是一种“GC 触发的内存泄漏防护”工具,而非功能完善的缓存。真正的缓存需要可控的驱逐、过期和并发支持。
- 加分回答:Android 中的
LruCache和 GuavaCacheBuilder提供了强引用 LRU 及基于引用和时间的清理,是合适的缓存方案。
6. 如果 WeakHashMap 的 value 强引用了 key,会发生什么?如何避免?
- 回答:会导致 key 无法被 GC,因为 WeakHashMap → Entry → value → key 形成强引用链,弱引用形同虚设。避免方法:不使用 value 持有 key 的强引用;如果必须有引用,使用 WeakReference 包装,或在设计上将 key 信息提取为不可变标识符。
7. WeakHashMap 线程安全吗?如何在并发中使用它?
- 回答:不是线程安全的。可使用
Collections.synchronizedMap(new WeakHashMap<>())获得线程安全视图,但需要额外同步复合操作。更好的办法是在多线程环境下使用ConcurrentHashMap并自行管理弱引用逻辑,或使用线程安全的缓存库。
8. WeakHashMap 允许 null 键吗?如何实现的?
- 回答:允许。内部使用
maskNull()将 null 替换为NULL_KEY常量对象,该对象是 static final 普通对象,始终被强引用,因此不会被 GC 回收,行为类似普通键。
9. 和 SoftReference 相比,WeakReference 更弱,还是更强?
- 回答:WeakReference 更弱。软引用仅在内存不足时回收,弱引用在每次 GC 时都会被回收(只要没有强引用)。WeakHashMap 使用弱引用意味着它对内存更敏感,缓存项的生命周期更短,更适合那种“只要键不使用了就尽快回收”的场景;而软引用适合实现内存敏感的缓存,在内存充足时保留,不足时回收。
10. 请举一个适合使用 WeakHashMap 的场景。
- 回答:一个典型的场景是反射框架中的类元数据缓存。例如 Java Beans 的
Introspector缓存 BeanInfo,键为 Class 对象,值为 BeanInfo。当 Class 被卸载时(如在 Web 容器中应用重新部署),由于 WeakHashMap 使用弱引用键,这些缓存会被自动清除,避免内存泄漏。另一个例子是对临时对象附加的监听器、属性映射,当对象本身消亡时,相关映射自动消失。