集合-Map-WeakHashMap

0 阅读27分钟

概述

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 接口——定义映射的基本契约,包含 putgetremovesize 等核心方法。
  • 第二层:AbstractMap 抽象类——提供 Map 接口的大部分骨架实现,如 containsKeycontainsValuesizeisEmpty 等,子类只需实现 entrySet() 即可获得完整映射功能。WeakHashMap 重写了大部分方法以注入清理逻辑。
  • 第三层:WeakHashMap 具体实现——引入 ReferenceQueueEntry[] tableexpungeStaleEntriesresize 等私有成员,实现弱引用键的自动回收。它同时实现了 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 的自动清理能力源于 弱引用 + 引用队列 的配合。

弱引用回收与入队过程:

  1. 创建 Entryput 时,Entry 构造器执行 super(key, queue),将 key 包装为一个弱引用,同时指定回调队列 queue
  2. 外部强引用消失:当程序中所有指向该 key 的强引用(方法局部变量、成员字段等)不可达后,只剩 Entry 内部的弱引用。
  3. GC 触发:JVM 在下次 GC 时检测到该对象只有弱引用可达,于是回收 key 对象,并将对应的 WeakReference(即该 Entry)加入 queue
  4. 惰性清理: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),将弱引用与队列绑定。此时 QueueEntry 之间建立了等待通知关系。
  • 第二层: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 操作(源码剖析)

getremove 同样遵循“先清理后执行”的原则。

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 相同,getremove 首先调用 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 的 keynull(已回收),说明该条目已过期,直接跳过并清理 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 无法被回收。

修复方案:

  1. 避免 Value 持有 Key 的强引用:如果 Value 确实需要感知 Key,可只存储 Key 的某个派生信息(如 id),或使用 WeakReference<Key> 包装,切断强引用链。
  2. 重新设计 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())。
  • 若值必须引用键,请改用 WeakReferenceSoftReference,并接受值可能需要配合手动清理或延迟更新。

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

对比总结:

维度WeakHashMapHashMap
键的持有方式弱引用,允许 GC 回收强引用,永不自动移除
内存占用惰性清理,可能持有额外失效对象确切占用,需手动移除
性能每次操作可能触发 expungeStaleEntries,有额外开销无清理开销,性能更稳定
适用场景缓存、元数据映射,生命周期绑定键通用映射
线程安全非线程安全非线程安全
null 键允许(替换为 NULL_KEY)允许(存放在 table[0])

图表说明:

  • 第一层:键自动回收需求——这是分水岭。如果不需要,HashMap 更简单直接。
  • 第二层:值强引用检查——如果值强引用键,WeakHashMap 弱引用失效,必须重构。
  • 第三层:即时清理需求——WeakHashMap 是惰性清理,如果要求键被 GC 后立刻可见、size 立即更新,或者需要基于时间的过期策略,应选择成熟的缓存框架而非 WeakHashMap。
  • 关键结论:WeakHashMap 是最轻量的自动回收映射,但代价是清理的滞后和无主动驱逐策略,适用于“能被回收就好,晚一点无所谓”的非严格场景。

模块 12:WeakHashMap vs 其他缓存方案

WeakHashMap 常被误称为“缓存”,但实际上它缺少现代缓存框架的关键特性:

特性WeakHashMapLruCache (android)Guava CacheCaffeine
自动回收键基于 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

  • 表现:键在存入后状态改变,导致 hashCodeequals 结果变化,后续无法定位到 Entry。
  • 后果:条目变为“僵尸”条目,无法被 GC(强引用仍然存在),也无法被正确查找。
  • 措施:始终使用不可变对象作为键,或者确保键的状态在映射生命周期内不会改变。

Part 6:总结与面试篇

模块 14:性能总结

  • 时间复杂度putgetremove 平均 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() 等也会触发清理,所以通常不会无限期驻留。

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()putgetremoveresize 等几乎所有公开方法内部使用,因此是一种按需惰性清理。注意:迭代器创建时可能不会调用,所以迭代过程中可能看到键为 null 的条目,遍历时需处理 null 键。

5. 为什么说 WeakHashMap 不能用作缓存?有哪些局限?

  • 回答:它极度依赖 GC 时机,无法控制过期策略(无 TTL,无最大容量),没有统计,没有主动清理线程,清理滞后,且非线程安全。它更像是一种“GC 触发的内存泄漏防护”工具,而非功能完善的缓存。真正的缓存需要可控的驱逐、过期和并发支持。
  • 加分回答:Android 中的 LruCache 和 Guava CacheBuilder 提供了强引用 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 使用弱引用键,这些缓存会被自动清除,避免内存泄漏。另一个例子是对临时对象附加的监听器、属性映射,当对象本身消亡时,相关映射自动消失。