首页 / Java 学习笔记 / 03

JAVA · Vol.I · DAY 03 · 集合框架源码

HashMap vs Hashtable vs LinkedHashMap 横向对比

中级极高#集合#对比
第 1 站

面试官的下一个追问

"HashMap 你说得不错——那 Hashtable 和 LinkedHashMap 呢?它们跟 HashMap 有什么区别?什么场景下会用到?" —— 面试中,HashMap 只是起点,横向对比才是真正拉开差距的地方。

很多候选人能把 HashMap 的底层原理讲得头头是道,但当面试官话锋一转——"Hashtable 为什么没人用了?""LinkedHashMap 怎么实现 LRU?"——就开始支支吾吾。这不是因为知识有多难,而是大家很少把三者放在一起系统性地对比

事实上,HashMap、Hashtable、LinkedHashMap 虽然都实现了 Map 接口,但它们的设计目标截然不同

  • HashMap —— 追求极致性能,无序、非线程安全,是日常开发的主力
  • Hashtable —— JDK 1.0 的"活化石",粗暴的全表锁,已被 ConcurrentHashMap 取代
  • LinkedHashMap —— 在 HashMap 基础上增加了有序性,是实现 LRU 缓存的利器

这篇文章我们就从线程安全、有序性、性能三个维度,做一次彻底的横向对比。而且不止停留在"结论"层面——我们会落到方法名、字段名、阈值,回答每个"为什么"。读完之后,面试中任何关于"Map 选型"的追问,你都能从容应对。

第 2 站

HashMap 快速回顾:字段、阈值与 hash 算法

在做对比之前,先用 30 秒回顾 HashMap 的核心特征,作为后续比较的基准线:

HashMap 一句话总结
数组 + 链表 + 红黑树(JDK 1.8+),非线程安全,允许 null key/value,无序,初始容量 16,负载因子 0.75,扩容 ×2
HashMap 底层结构速览 Node<K,V>[] table(默认长度 16,容量必须是 2 的幂) [0] null [1] Node(k,v) Node(k,v) 链表(长度 < 8) [n] root 红黑树(长度 ≥ 8 且数组 ≥ 64) 非线程安全 | 允许 null | 无序 | O(1) 查找 | 负载因子 0.75
图 1HashMap 核心结构回顾:数组为主干,链表和红黑树解决哈希冲突

先把"背"下来的常量变成源码字段

面试中经常被问"HashMap 的树化阈值是多少",光记数字不够,要能说出常量名。下面这些字段全部来自 JDK 1.8 的 HashMap

HashMap.java · 关键常量与字段
// ---------- 常量 ----------
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;   // 默认容量 16
static final float DEFAULT_LOAD_FACTOR = 0.75f;
static final int MAXIMUM_CAPACITY = 1 << 30;      // 容量上限 2^30
static final int TREEIFY_THRESHOLD = 8;          // 链表长度 ≥ 8 → 考虑树化
static final int UNTREEIFY_THRESHOLD = 6;        // 树节点 ≤ 6 → 退化为链表
static final int MIN_TREEIFY_CAPACITY = 64;       // 树化要求数组容量 ≥ 64

// ---------- 字段 ----------
transient Node<K,V>[] table;    // 桶数组(首次 put 才分配)
transient int size;             // 键值对个数
int threshold;                   // 扩容阈值 = 容量 × 负载因子
final float loadFactor;          // 负载因子,默认 0.75
transient int modCount;         // 结构性修改次数,迭代器 fail-fast 的依据

hash 算法与桶定位:两处位运算

HashMap.java · 扰动函数与定位
// ① 扰动函数:key.hashCode() 的高 16 位与低 16 位异或,让高位参与定位
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

// ② putVal 中定位桶下标:n 是 2 的幂,用位运算代替取模
i = (n - 1) & hash;   // 等价于 hash % n,但快得多;这也是容量必须为 2 的幂的原因
为什么容量必须是 2 的幂?

因为定位用的是 (n - 1) & hash。只有当 n 是 2 的幂时,n - 1 的二进制才是低位全 1,位与结果才恰好等于 hash % n,且不会因为高位被"切掉"而增大碰撞概率。传入构造函数的任意容量,都会先经过 tableSizeFor() 向上取整到最近的 2 的幂——比如 new HashMap(10) 实际容量是 16。

树化 / 退化的两个条件,一张图记牢

树化与退化:8 / 64 / 6 三个数字 0 8 桶内节点数 → 链表(1 ~ 7) 查找最坏 O(n) ≥ 8 触发 treeifyBin 还需数组容量 ≥ 64 红黑树 查找 O(log n) 扩容 split 后 ≤ 6 untreeify 退回链表 TREEIFY_THRESHOLD = 8 · MIN_TREEIFY_CAPACITY = 64 · UNTREEIFY_THRESHOLD = 6 6 与 8 之间留 2 个数的滞回区间,防止元素在链表/红黑树之间反复横跳
图 2树化需要"桶长度 ≥ 8 且数组容量 ≥ 64"两个条件同时满足;容量不足 64 时先扩容而非树化
核心要点
  • 扩容时机:size > thresholdthreshold = capacity × loadFactor),容量直接 ×2,保持 2 的幂
  • 树化是"双重保险":桶长度 ≥ 8 数组 ≥ 64;否则先扩容把元素分散
  • 退化只发生在扩容 split 之后:某桶节点数 ≤ 6 时 untreeify() 退回链表

接下来的对比,都会以 HashMap 作为基准。我们来看它的两个"亲戚"各自做了什么不同的设计取舍。

第 3 站

HashMap 的 null:从 hash(null)=0 说起

追问:"HashMap 允许 null key 和 null value,Hashtable 不允许——源码层面,这个差异到底差在哪一行?"

很多人只知道结论"HashMap 允许 null",却说不清底层是怎么处理的。答案就在第 2 站展示的 hash() 方法里——null key 走的是特判分支

HashMap.java · null 的底层路径
// null key 的 hash 恒为 0 → 永远落在桶 0
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);

// putVal 中的写入流程:桶 0 空了直接挂节点,非空就遍历桶内链表找 key 是否已存在
if ((p = tab[i = (n - 1) & hash]) == null)
    tab[i] = newNode(hash, key, value, null);
else {
    // …… 遍历链表/红黑树:key == 已存在节点的 key 或 equals 相等 → 覆盖 value ……
}

由此得出三个可以脱口而出的结论:

  • null key 永远只有一个:Map 的 key 是唯一的,第二次 put null 只是覆盖 value
  • null key 固定在桶 0hash(null) = 0(n-1) & 0 = 0,所以 null key 永远在 table[0]
  • null value 没有限制:value 不参与哈希,任意多个键都可以映射到 null

Hashtable 为什么拒绝?对比一下就清楚了

Hashtable 的 put()value 为 null 是显式抛 NPEkey 为 null 则是在调用 key.hashCode() 那一行自然抛出 NPE——它根本没有为 null 做任何分支处理:

Hashtable.java · 拒绝 null 的两处
public synchronized V put(K key, V value) {
    if (value == null)                    // ① value 为 null:显式抛 NPE
        throw new NullPointerException();
    Entry<?,?> tab[] = table;
    int hash = key.hashCode();             // ② key 为 null 时,这一行直接 NPE
    int index = (hash & 0x7FFFFFFF) % tab.length;
    // ……
}
为什么 HashMap 敢允许 null,Hashtable 不敢?

根本原因是设计年代与定位不同。Hashtable 诞生于 JDK 1.0,是保守的"老祖宗"设计;HashMap 诞生于 JDK 1.2,从第一天就把"允许 null"作为特性写进文档——它需要在 hash() 入口对 null 做特判,成本只是一次分支判断。而 ConcurrentHashMap 也不允许 null,理由则完全不同(并发语义上的二义性),这个坑我们在第 5 站专门展开。

生产建议:null value 是"定时炸弹"
  • map.get(key) == null 在 HashMap 里无法区分"key 不存在"还是"value 就是 null"——需要区分时必须先 containsKey()
  • 缓存类场景不要存 null value,否则命中与否的判断会失效;可以用 Optional 包装或约定哨兵值
  • 并发场景根本不存在这个问题:Hashtable / ConcurrentHashMap 的 value 不允许为 null,get() == null 就代表 key 不存在
第 4 站

Hashtable:JDK 1.0 的"活化石"

Hashtable 是 Java 中最古老的集合类之一,诞生于 JDK 1.0(1996 年),比 Map 接口本身还要早。它继承自已废弃的 Dictionary 抽象类,是当年唯一的线程安全 Map 实现。

先看一段最具代表性的源码——Hashtable 的所有公开方法put / get / remove / containsKey / size ……)都加了 synchronized 关键字:

Hashtable.java · JDK 源码(核心方法摘录)
public class Hashtable<K,V>
    extends Dictionary<K,V>                  // 继承 1.0 遗留抽象类
    implements Map<K,V>, Cloneable, java.io.Serializable {

    private transient Entry<?,?>[] table;      // 桶数组,长度不必是 2 的幂

    public Hashtable() {
        this(11, 0.75f);                       // 默认容量 11(不是 16)
    }

    // put:整个方法 synchronized —— 锁住整张表
    public synchronized V put(K key, V value) {
        if (value == null)                    // 不允许 null value
            throw new NullPointerException();
        Entry<?,?> tab[] = table;
        int hash = key.hashCode();             // key 为 null 时在此抛 NPE
        int index = (hash & 0x7FFFFFFF) % tab.length;  // 取模定位,非位运算
        // …… 遍历桶内链表,已存在则覆盖,否则头插法插入 ……
    }

    public synchronized V get(Object key) { ... }   // 纯读也要抢同一把锁
    public synchronized V remove(Object key) { ... }
    public synchronized boolean containsKey(Object key) { ... }

    // 扩容:×2+1,刻意避开 2 的幂,让取模分布更均匀
    protected void rehash() {
        int newCapacity = (oldCapacity << 1) + 1;
        // …… 逐个元素重新计算 index 并转移(无高低位拆分优化)……
    }
}
Hashtable 为什么在实际开发中被淘汰?

Hashtable 的线程安全策略极其简单粗暴:在每个方法上加 synchronized,锁的是 this(整张表)。同一时刻只有一个线程能访问整张表,高并发下所有线程都要排队争抢同一把锁,性能急剧下降——这就是"全表锁"。

相比之下,ConcurrentHashMap 采用 CAS + synchronized(只锁单个桶的头节点),锁粒度从"整张表"细化到"单个桶",并发性能高出一个数量级。具体拆解见第 5 站。

容易被追问的冷知识:Enumerator

Hashtable 除了迭代器 Iterator,还保留了古老的 Enumeration 接口。一个容易被忽略的细节是:JDK 8 里这两者是同一个类实现的:

Hashtable.java · 一个类同时实现两个迭代接口
// Enumerator 同时实现 Enumeration 和 Iterator,还带 fail-fast 的 modCount 校验
private class Enumerator<T>
    implements Enumeration<T>, Iterator<T> {
    int expectedModCount = modCount;
    ...
    public T nextElement() {
        if (modCount != expectedModCount)
            throw new ConcurrentModificationException();  // fail-fast
        ...
    }
}

总结 Hashtable 的几个关键特征:

  • 继承 Dictionary:这是 JDK 1.0 遗留的抽象类,不是 Collection 框架的一部分
  • 全方法 synchronized:读和写都加锁,即使是纯读操作也会阻塞
  • 不允许 null:key 和 value 都不能为 null,value 显式抛 NPE,key 由 hashCode() 抛 NPE
  • 初始容量 11:扩容时 ×2+1,不要求 2 的幂,无法使用位运算优化
  • 只有链表:没有红黑树优化,极端碰撞下查找退化为 O(n)
  • 双迭代接口Enumerator 同时实现 EnumerationIterator,且都是 fail-fast
生产环境建议

新代码中不要使用 Hashtable。如果需要线程安全的 Map,首选 ConcurrentHashMap;如果只是单线程场景,直接用 HashMap 即可。Hashtable 唯一出现的场景可能是维护老旧代码时的遗留引用。

第 5 站

Hashtable 的继承者:ConcurrentHashMap 凭什么快一个数量级

追问:"既然 Hashtable 是线程安全的,为什么还要发明 ConcurrentHashMap?同样是线程安全,差别在哪?"

答案只有四个字:锁的粒度。Hashtable 锁整张表(1 把锁),ConcurrentHashMap 锁单个桶(N 把锁)。JDK 1.8 起,ConcurrentHashMap 的结构与 HashMap 几乎一致(数组 + 链表 + 红黑树),但并发控制完全重做:

ConcurrentHashMap.java · putVal 核心流程(JDK 1.8+)
final V putVal(K key, V value, boolean onlyIfAbsent) {
    if (key == null || value == null)      // 与 Hashtable 一样拒绝 null
        throw new NullPointerException();
    int hash = spread(key.hashCode());
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i;
        if (tab == null || (n = tab.length) == 0)
            tab = initTable();                        // 并发初始化由 sizeCtl 协调
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            // 桶为空 → CAS 原子写入,完全无锁
            if (casTabAt(tab, i, null, new Node<>(hash, key, value, null)))
                break;
        }
        else {
            synchronized (f) {                   // 桶非空 → 只锁桶头节点
                // …… 链表/红黑树插入或覆盖,复用 HashMap 的树化逻辑 ……
            }
        }
    }
    addCount(1L, binCount);                    // 走 CounterCell 分片计数
    return null;
}

两个面试官爱问的字段:sizeCtl 与 CounterCell

ConcurrentHashMap.java · 并发控制字段
/**
 * sizeCtl 状态机:
 *   -1         → 正在初始化(其他线程在 initTable 里自旋等待)
 *   -(1 + n)   → 正在扩容,n 为参与扩容的线程数(多线程协同扩容)
 *    0         → 尚未初始化
 *    正数       → 下一次扩容阈值(容量 × 0.75)
 */
private transient volatile int sizeCtl;

// 分片计数器:每个线程只 CAS 更新自己的分片,避免全局计数器争用
@sun.misc.Contended
static final class CounterCell {
    volatile long value;
}
// size() 时累加 baseCount + 所有 CounterCell 分片——牺牲一点即时性,换取并发度
回答"为什么 ConcurrentHashMap 更强"的完整话术

写入:桶空用 CAS 无锁写入,桶非空只 synchronized 头节点,不同桶互不阻塞;② 读取Nodevalnext 都是 volatile,读操作完全不加锁;③ 扩容:多线程通过 sizeCtl 协同搬移,transferIndex 分配任务;④ 计数CounterCell 分片累加,size() 不再抢一把全局锁。Hashtable 一个都没有。

为什么 ConcurrentHashMap 也不允许 null?

这是面试中非常容易踩的"双标"问题:Hashtable 拒绝 null 是因为老,ConcurrentHashMap 拒绝 null 则是因为并发语义。Doug Lea 在《Java 并发编程实战》中给出的经典理由:

  • 非并发 Map 中,get() 返回 null 后可以先 containsKey() 确认,是"不存在"还是"值为 null"
  • 但在并发环境下,containsKey()get() 之间存在竞态窗口:两次调用之间其他线程可能已经修改了 map,无法可靠地消除二义性
  • 所以干脆禁止 null key/value,让 get() == null 永远等价于"key 不存在"
锁粒度对比
Hashtable:1 把锁 → 全表互斥 → 并发度 = 1
ConcurrentHashMap:N 把锁(每桶一把)→ 桶级并发 → 并发度 ≈ table.length
核心要点
  • JDK 1.8 的 ConcurrentHashMap 去掉了 JDK 1.7 的 Segment 分段锁,改为 CAS + synchronized 锁桶头
  • 读无锁(volatile 保证可见性),迭代器是弱一致性的,不会抛 ConcurrentModificationException
  • 提供 putIfAbsent()computeIfAbsent()computeIfPresent()merge() 等原子复合操作,是分布式缓存的基石
  • 不允许 null key/value,get() == null 即 key 不存在
第 6 站

LinkedHashMap:数据结构与关键字段

HashMap 是无序的——你插入 A、B、C,遍历时可能是 C、A、B。但在很多场景下,我们希望 Map 能记住元素的顺序。LinkedHashMap 正是为此而生。

LinkedHashMap 继承自 HashMap,在复用其全部哈希表能力的基础上,额外维护了一条双向链表,将所有 entry 串联起来:

LinkedHashMap 内部结构:HashMap + 双向链表 HashMap 的 table[] 数组 [0] [1] [2] [3] Entry A before | after Entry B before | after Entry C before | after head (A) tail (C) after before 双向链表与哈希表并行存在 哈希表负责 O(1) 查找 | 双向链表负责维护遍历顺序(插入顺序 or 访问顺序) 每个 Entry 同时存在于两条链中:桶内的 next 链表 + 全局的 before/after 双向链表
图 3LinkedHashMap 内部结构:在 HashMap 的哈希表之上,额外维护一条按顺序串联所有 entry 的双向链表

Entry 节点与三个关键字段

LinkedHashMap 的 Entry 在 HashMap 的 Node 基础上,增加了 beforeafter 两个指针;类本身则额外持有 headtailaccessOrder 三个字段:

LinkedHashMap.java · 节点与字段
static class Entry<K,V> extends HashMap.Node<K,V> {
    Entry<K,V> before, after;    // 全局双向链表的前驱/后继

    Entry(int hash, K key, V value, Node<K,V> next) {
        super(hash, key, value, next);  // 桶内 next 由 HashMap.Node 负责
    }
}

// LinkedHashMap 额外持有的三个字段
transient LinkedHashMap.Entry<K,V> head;   // 链表头:最早插入 / 最久未访问
transient LinkedHashMap.Entry<K,V> tail;   // 链表尾:最近插入 / 最近访问
final boolean accessOrder;               // false = 插入序(默认) true = 访问序

两种排序模式

LinkedHashMap 支持两种遍历顺序,由构造方法中的 accessOrder 参数决定:

  • accessOrder = false(默认):按插入顺序遍历。先 put 的排前面,put 已有 key 只覆盖 value,不改变顺序
  • accessOrder = true:按访问顺序遍历。每次 get() 或 put() 命中已有 key 时,该 entry 会被移到链表尾部
LinkedHashMap.java · 构造方法与 linkNodeLast
// 3 参构造是打开"访问序"的钥匙,LRU 就靠它
public LinkedHashMap(int initialCapacity,
                      float loadFactor,
                      boolean accessOrder) {
    super(initialCapacity, loadFactor);  // 复用 HashMap 的容量/扩容逻辑
    this.accessOrder = accessOrder;
}

// 每次插入新节点后调用:把 p 挂到双向链表尾部
private void linkNodeLast(LinkedHashMap.Entry<K,V> p) {
    LinkedHashMap.Entry<K,V> last = tail;
    tail = p;
    if (last == null)
        head = p;                  // 空链表:p 既是头也是尾
    else {
        p.before = last;
        last.after = p;            // 非空:接到末尾
    }
}

到这里你已经看到了 LinkedHashMap 的全部家底:哈希表管查找,双向链表管顺序。下一步,我们看这三个字段是怎么被"钩子方法"驱动起来的——这是 LinkedHashMap 源码的精髓。

第 7 站

LinkedHashMap 源码精髓:三个钩子方法

LinkedHashMap 几乎没有重写任何核心算法,它只是重写了 HashMap 预留的三个空钩子:afterNodeAccess()afterNodeInsertion()afterNodeRemoval()。这三个方法在 HashMap 里是空实现(模板方法模式),在 LinkedHashMap 里负责维护双向链表。

钩子 ①:afterNodeAccess —— 访问后把节点移到尾部

get() / put() 命中已有 key 时被调用。配合 accessOrder = true,这就是 LRU 的"最近使用过"标记:

LinkedHashMap.java · afterNodeAccess
// 把被访问的节点从链表中间"摘"出来,重新挂到尾部
void afterNodeAccess(Node<K,V> e) {
    LinkedHashMap.Entry<K,V> last;
    if (accessOrder && (last = tail) != e) {   // 只在访问序模式下生效
        LinkedHashMap.Entry<K,V> p = (LinkedHashMap.Entry<K,V>)e,
                                  b = p.before, a = p.after;
        p.after = null;
        if (b == null) head = a;    // p 是头 → head 指向其后继
        else b.after = a;           // 前驱跳过 p
        if (a != null) a.before = b;
        else last = b;              // p 原本是尾,则 last 前移
        if (last == null) head = p;  // 链表只有一个节点
        else { p.before = last; last.after = p; }
        tail = p;                     // p 成为新的 tail
        ++modCount;               // 结构变化:访问序模式下遍历中 get 会抛 CME
    }
}
一个隐蔽的面试考点:modCount

注意 afterNodeAccess 里的 ++modCount。它意味着在 accessOrder = true 时,读操作 get() 也会引发结构性变化。如果你在遍历 LinkedHashMap 的同时调用 get(),迭代器检测到 modCount 变化,会抛出 ConcurrentModificationException。这是很多人在实现"访问计数缓存"时踩过的坑。

钩子 ②:afterNodeInsertion —— 插入后检查是否淘汰

每次 put()/putAll() 结束时被调用,是 removeEldestEntry() 钩子的唯一调用入口:

LinkedHashMap.java · afterNodeInsertion + removeEldestEntry
// 插入后回调:evict=true 时,检查是否需要淘汰链表头
void afterNodeInsertion(boolean evict) {
    LinkedHashMap.Entry<K,V> first;
    if (evict && (first = head) != null
            && removeEldestEntry(first)) {
        K key = first.key;
        removeNode(hash(key), key, null, false, true);  // 淘汰头节点
    }
}

// 默认不淘汰;子类重写即可定制策略 —— LRU 的容量上限就写在这
protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
    return false;
}

钩子 ③:afterNodeRemoval —— 删除后同步摘链

LinkedHashMap.java · afterNodeRemoval
// remove() / clear() 删除节点后,同步从双向链表摘除
void afterNodeRemoval(Node<K,V> e) {
    LinkedHashMap.Entry<K,V> p = (LinkedHashMap.Entry<K,V>)e,
                              b = p.before, a = p.after;
    p.before = p.after = null;
    if (b == null) head = a;    // 删的是头 → head 后移
    else b.after = a;
    if (a == null) tail = b;    // 删的是尾 → tail 前移
    else a.before = b;
}

钩子的调用时机一览

操作触发方法作用
put() 新 keyafterNodeInsertion(true)尝试淘汰 eldest(LRU 入口)
put() 覆盖已有 keyafterNodeAccess(e)访问序下把节点移到尾部
get() / getOrDefault() 命中afterNodeAccess(e)访问序下把节点移到尾部
remove() / clear()afterNodeRemoval(e)从双向链表摘除节点

重写的方法:get / getOrDefault / containsValue / clear

LinkedHashMap.java · 有序相关的重写
// get:accessOrder=true 时,读也会改变顺序 —— LRU 的核心
public V get(Object key) {
    Node<K,V> e;
    if ((e = getNode(hash(key), key)) == null)
        return null;
    if (accessOrder)
        afterNodeAccess(e);
    return e.value;
}

// containsValue:沿双向链表遍历,天然有序
public boolean containsValue(Object value) {
    for (LinkedHashMap.Entry<K,V> e = head; e != null; e = e.after) {
        V v = e.value;
        if (v == value || (value != null && value.equals(v)))
            return true;
    }
    return false;
}

// clear:双向链表一并清空,否则 head/tail 会悬空
public void clear() {
    super.clear();
    head = tail = null;
}
核心要点
  • entrySet() / keySet() / values() 的迭代器都沿 e.after 链遍历,所以遍历顺序 = 双向链表顺序(插入序或访问序),与桶下标无关
  • 迭代器同样 fail-fast;accessOrder=true 时连 get() 都会改 modCount
  • 所有查找复杂度与 HashMap 一致(O(1),最坏 O(log n)),只是多了常数级的链表维护开销
第 8 站

用 LinkedHashMap 实现 LRU 缓存

这是 LinkedHashMap 最经典的实战应用。当 accessOrder = true 时,最近访问的元素自动移到尾部,最久没访问的元素留在头部。配合 removeEldestEntry() 钩子方法,可以轻松实现 LRU 淘汰策略:

LRUCache.java · 基于 LinkedHashMap 实现 LRU 缓存
public class LRUCache<K, V> extends LinkedHashMap<K, V> {
    private final int capacity;

    public LRUCache(int capacity) {
        super(capacity, 0.75f, true);  // 第三个参数 accessOrder = true
        this.capacity = capacity;
    }

    /**
     * 每次 put/putAll 之后由 afterNodeInsertion 回调。
     * 返回 true → 自动删除链表头(最久未访问)的元素。
     */
    @Override
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        return size() > capacity;   // 超容量就淘汰最老的
    }
}

// 使用示例:注意每一步 head/tail 的变化
LRUCache<String, Integer> cache = new LRUCache<>(3);
cache.put("A", 1);   // {A=1}           head=A tail=A
cache.put("B", 2);   // {A=1, B=2}      head=A tail=B
cache.put("C", 3);   // {A=1, B=2, C=3} head=A tail=C
cache.get("A");       // {B=2, C=3, A=1} A 被访问 → 移到尾部,head=B
cache.put("D", 4);   // {C=3, A=1, D=4} size=4 > 3 → 淘汰 B
① 依次 put A、B、C(容量 3,插入序) A B C head tail ② get(A) 命中 → A 移到尾部(访问序) B C A head tail 最久未访问,下次淘汰候选 ③ put(D) → 超容量,afterNodeInsertion 淘汰 head(B) C A D head tail head 永远指向最久未访问,tail 永远指向最近访问 淘汰 = 删除 head;命中 = 移到 tail。O(1) 完成,这就是 LRU 的天然形态
图 4LRU 状态变化:head 最久未访问、tail 最近访问,命中移到尾部、淘汰删头部

三个必须说清的边界条件

  • 为什么是 size() > capacity 而不是 >= afterNodeInsertion 在 put 完成、size 已经自增之后才执行。若用 >=,容量为 1 时第一个元素刚插入(size=1)就会被自己淘汰,缓存永远为空。用 > 才能保证"装满但不超过"
  • put 已存在的 key 会触发淘汰吗? 不会。覆盖已有 key 时 size 不变,size() > capacity 为 false;但 afterNodeAccess 会把该 key 移到尾部——这是正确的 LRU 语义
  • get() 会改变遍历结果:访问序模式下,读操作本身就是"使用",会重排链表,这也是 LRU 与 LFU 的本质区别

方案二:HashMap + 双向链表手写实现

不继承 LinkedHashMap,用 HashMap 做 O(1) 查找、自建双向链表维护顺序,是 LeetCode 146 题的标准解法,也是理解 LRU 的最佳练习:

LRUCache2.java · 手写核心逻辑(HashMap + 哨兵节点双向链表)
public class LRUCache2<K, V> {
    private final int capacity;
    private final HashMap<K, DLinkedNode<K,V>> map;
    private final DLinkedNode<K,V> head, tail; // 哨兵节点,简化边界判断

    public V get(K key) {
        DLinkedNode<K,V> node = map.get(key);
        if (node == null) return null;
        moveToHead(node);          // 标记为"最近使用"
        return node.value;
    }

    public void put(K key, V value) {
        DLinkedNode<K,V> node = map.get(key);
        if (node != null) {
            node.value = value;
            moveToHead(node);
        } else {
            node = new DLinkedNode<>(key, value);
            map.put(key, node);
            addToHead(node);
            if (map.size() > capacity) {
                DLinkedNode<K,V> removed = removeTail();
                map.remove(removed.key);   // 淘汰最久未使用的
            }
        }
    }
    // addToHead / removeTail / moveToHead:双向链表的常规操作,O(1)
}
后端类比:本地缓存选型

LinkedHashMap 的 LRU 适合单线程或低频访问的本地缓存(比如读取一次配置、短生命周期的会话数据)。生产级高并发缓存,优先考虑 Caffeine / Guava Cache:它们支持过期、异步刷新、容量权重、命中率统计,但要注意其淘汰算法(Caffeine 是 W-TinyLFU)并非严格 LRU——如果业务强依赖"最久未访问优先淘汰"的严格语义,还是得回到 LinkedHashMap 方案并自行加锁。

第 9 站

三者全面对比:一张表说清楚

前面几站分别讲了三者的设计哲学,现在把它们放在一起做系统性对比。这张表覆盖了面试中可能被追问的所有维度:

核心对比表
对比维度HashMapHashtableLinkedHashMap
JDK 版本 JDK 1.2 JDK 1.0(最古老) JDK 1.4
父类 AbstractMap Dictionary(已废弃) HashMap
底层结构 数组 + 链表 + 红黑树(1.8+) 数组 + 链表(无红黑树) 数组 + 链表 + 红黑树 + 双向链表
线程安全 是(全方法 synchronized,全表锁)
null key 允许(1 个,固定桶 0) 不允许(抛 NPE) 允许(1 个,固定桶 0)
null value 允许(多个) 不允许(抛 NPE) 允许(多个)
遍历顺序 无序(按桶下标 + 桶内链表序) 无序 插入顺序 / 访问顺序
entrySet 遍历 不保证顺序 不保证顺序 保持插入序或访问序(沿 after 链)
迭代器 fail-fast fail-fast(Enumerator 兼 Enumeration/Iterator) fail-fast(accessOrder 下 get 也会触发)
初始容量 16 11 16(继承 HashMap)
扩容策略 ×2(保持 2 的幂) ×2+1(非 2 的幂) ×2(继承 HashMap)
负载因子 0.75 0.75 0.75
查找复杂度 O(1),最坏 O(log n) O(1),最坏 O(n) O(1),最坏 O(log n)
性能 最高 最低(锁竞争严重) 略低于 HashMap(链表维护开销)
get()==null 语义 有歧义(可能 value 就是 null) 确定:key 不存在 有歧义(同 HashMap)
并发替代品 ConcurrentHashMap Caffeine / Guava Cache(如需并发缓存)
典型用途 通用 Map 场景 已淘汰,遗留代码 LRU 缓存、有序遍历
面试追问:Hashtable 的初始容量为什么是 11?

Hashtable 诞生于 1996 年,当时还没有"容量必须是 2 的幂"这种优化意识。它使用 (hash & 0x7FFFFFFF) % table.length 做取模运算,因此不限制容量形式。而 HashMap 从 JDK 1.2 开始就采用 2 的幂容量,用 (n-1) & hash 位运算替代取模,性能更优。Hashtable 扩容用 ×2+1(保持奇数,试图让取模分布更均匀)而不是 ×2,正是因为它依赖取模。

核心要点
  • 三者底层数据结构一脉相承,LinkedHashMap 只是多了一条双向链表;Hashtable 则停留在"数组 + 链表"的 1.0 时代
  • 允许 null 的只有 HashMap 系(HashMap / LinkedHashMap);Hashtable 与 ConcurrentHashMap 都拒绝 null,但理由不同:一个因为老,一个因为并发二义性
  • 选型口诀:并发用 ConcurrentHashMap,有序用 LinkedHashMap,其余用 HashMap,Hashtable 永远不选
第 10 站

边界条件与生产踩坑清单

追问:"树化阈值为什么是 8 不是 10?退化阈值为什么是 6 不是 8?""已知要存 1 万个元素,HashMap 初始容量给多少合适?"

为什么是 8 / 6 / 64?

JDK 源码注释给出了答案:在负载因子 0.75 且哈希分布理想(泊松分布,λ ≈ 0.5)的前提下,桶内出现 8 个元素的概率约为 0.00000006(6 × 10-8——亿分之六。也就是说,链表长度到 8 基本只可能是hashCode 分布被恶意/意外破坏(比如对象 hashCode 恒等),此时树化是必要的兜底。

至于退化阈值 6:如果树化阈值和退化阈值都设成 8,元素数量在 8 附近抖动时,链表↔红黑树会反复转换,白白消耗 CPU。中间留出 2 个数的滞回区间(hysteresis),转换就不那么频繁了。而 64 意味着:桶快满时优先扩容分散元素,而不是急着树化——扩容后一半元素会挪到新桶,碰撞自然缓解。

初始容量给多少?一个公式

Capacity.java · 预知元素数量时的正确姿势
// 扩容阈值 = 容量 × 0.75。想让 10000 个元素一次都不扩容:
int expected = 10_000;
Map<String, Integer> map =
    new HashMap<>((int) (expected / 0.75f) + 1);
// ≈ 13334 → tableSizeFor 取整为 16384,threshold = 16384 × 0.75 = 12288 > 10000
// 等价写法:Guava 的 Maps.newHashMapWithExpectedSize(expected)

生产踩坑清单

  • 可变 key 陷阱:key 放进 HashMap 后再修改其 hashCode() 参与计算的字段,会导致永远找不到这个 key(它在旧桶里,hash 却算到新桶)。key 应设计为不可变对象
  • 遍历时删除:for-each 里直接 map.remove() 会抛 ConcurrentModificationException,应使用 iterator.remove() 或 JDK 8 的 removeIf()
  • accessOrder=true 的遍历陷阱:遍历 LinkedHashMap 时调用 get() 会抛 CME(afterNodeAccess 改了 modCount)
  • LinkedHashMap 非线程安全:需要并发时外层加锁或换 Caffeine;Collections.synchronizedMap() 包 LinkedHashMap 可行,但淘汰逻辑依赖 put 调用栈,仍需整体串行化
  • HashMap 扩容代价:扩容是"全量重散列",大 Map 扩容会 STW(单线程场景下表现为明显卡顿)。预知容量务必按上面的公式初始化
  • LinkedHashMap 默认淘汰语义别混淆:继承它做 LRU 时,淘汰的是 head(最久未访问);如果业务要"先进先出",用 accessOrder=false 同样可以做到(FIFO 队列缓存)
生产最佳实践
  • 单线程无顺序要求:HashMap,容量按 expected / 0.75f + 1 预估
  • 单线程需有序:LinkedHashMap(accessOrder 按需),或 TreeMap(需按 key 排序)
  • 多线程:ConcurrentHashMapget() == null 即可断定 key 不存在
  • 多线程 + 容量上限:Caffeine / Guava Cache(注意非严格 LRU);强 LRU 语义则 LinkedHashMap + 锁
  • 禁止 Hashtable:它的存在只用于回答面试题和读懂历史代码
第 11 站

真实面试场景与标准回答

以下是面试中关于 Map 选型最常出现的追问,以及能够展现深度的回答框架:

Q1:"什么场景下你会选择 LinkedHashMap 而不是 HashMap?"

标准回答:当业务需要可预测的遍历顺序时,选 LinkedHashMap。典型场景有三种:

  • LRU 缓存:设置 accessOrder = true,配合 removeEldestEntry() 实现淘汰策略,代码量极少
  • 保持插入顺序:比如解析 YAML/JSON 配置文件后,希望遍历时保持原有的键顺序
  • 展示层数据:某些 UI 组件需要按插入顺序展示数据,LinkedHashMap 天然保证这一点

但要注意,LinkedHashMap 维护双向链表有额外开销:每次 put 多一次 afterNodeInsertion()、每次命中多一次 afterNodeAccess() 的链表摘挂。如果只是做 key-value 查找、不需要有序遍历,HashMap 性能更好。

Q2:"如何用 Java 实现一个 LRU 缓存?"

标准回答(分层次展开):

  • 方案一:继承 LinkedHashMap,设置 accessOrder = true,重写 removeEldestEntry() 返回 size() > capacity。最简洁,十几行代码(见第 8 站)
  • 方案二:HashMap + 双向链表手动实现。HashMap 负责 O(1) 查找,双向链表维护访问顺序,put/get 把节点移到头部、淘汰删尾部。LeetCode 146 的标准解法
  • 方案三:生产环境用 Caffeine / Guava Cache,支持过期策略、异步刷新、弱引用等高级特性;但淘汰算法非严格 LRU
Q3:"为什么多线程环境下推荐 ConcurrentHashMap 而不是 Hashtable?"

标准回答:核心差异在锁粒度——Hashtable 锁整张表(并发度 1),ConcurrentHashMap 用 CAS + synchronized 锁单个桶头(并发度 ≈ 桶数),读操作基于 volatile 完全无锁。此外 CHM 还有:弱一致性迭代器(不抛 CME)、putIfAbsent() / computeIfAbsent() / merge() 原子操作、多线程协同扩容。细节参见第 5 站。

Q4(进阶):"Hashtable 和 ConcurrentHashMap 都不允许 null,原因一样吗?"

标准回答:不一样。Hashtable 是因为老——JDK 1.0 的保守设计,源码里 value 为 null 显式抛 NPE,key 为 null 在 key.hashCode() 处抛 NPE;ConcurrentHashMap 是因为并发语义——并发下 get() 返回 null 无法区分"key 不存在"与"value 为 null",而 containsKey()get() 之间存在竞态窗口,无法原子地消除二义性,于是直接禁止 null,让 get() == null 永远等价于 key 不存在。HashMap 允许 null 则是因为非并发场景可以用 containsKey 事后确认,且它是 JDK 1.2 的新设计,在 hash() 入口为 null 做了特判(hash=0,落桶 0)。

第 12 站

Map 选型决策树

说了这么多,最终回到实战中最核心的问题:面对一个具体需求,该选哪个 Map? 下面这张决策树可以帮你在 3 秒内做出判断:

需要一个 Map 多线程并发 读写? ConcurrentHashMap 需要可预测的 遍历顺序? 按 key 自然 顺序排序? TreeMap O(log n) LinkedHashMap 插入/访问顺序 HashMap 最常用的选择 决策优先级:线程安全 > 有序性 > 性能 先确认并发需求,再确认排序需求,最后选最快的那个。Hashtable 不在选项内。
图 5Map 选型决策树:三个问题帮你快速定位最合适的实现

这张决策树的核心思路是逐层排除

  1. 第一问:是否多线程? 是 → ConcurrentHashMap(不要用 Hashtable)
  2. 第二问:是否需要有序遍历? 否 → HashMap(性能最优)
  3. 第三问:按什么排序? key 自然顺序 → TreeMap;插入/访问顺序 → LinkedHashMap
面试中的万能回答框架

当面试官问"你一般怎么选 Map"时,可以按这个顺序展开:

  • 先说决策树思路(展现结构化思维)
  • 再补充具体场景案例(比如"我项目中用 LinkedHashMap 做过配置缓存")
  • 最后点出Hashtable 已淘汰以及 ConcurrentHashMap 的锁粒度优势(展现对底层原理的理解)
总结

这一篇你掌握了什么

核心知识点回顾

  • HashMap:数组+链表+红黑树,非线程安全,允许 null(key 1 个落桶 0 / value 多个),无序,初始容量 16,负载因子 0.75,扩容 ×2(保持 2 的幂,(n-1) & hash 定位)
  • 关键阈值:树化 TREEIFY_THRESHOLD = 8MIN_TREEIFY_CAPACITY = 64;退化 UNTREEIFY_THRESHOLD = 6;滞回区间防止链表/红黑树反复横跳
  • Hashtable:JDK 1.0 遗留类,继承 Dictionary,全方法 synchronized(全表锁,并发度 1),不允许 null(value 显式 NPE、key 由 hashCode() 抛 NPE),初始容量 11,扩容 ×2+1,无红黑树,已淘汰
  • ConcurrentHashMap:CAS + synchronized 锁桶头,读无锁(volatile),sizeCtl 状态机、CounterCell 分片计数,弱一致性迭代器,不允许 null(并发二义性),多线程唯一正解
  • LinkedHashMap:继承 HashMap,额外维护 head/tail 双向链表 + accessOrder 参数;entrySet 遍历保持插入序或访问序
  • 三大钩子:afterNodeAccess(命中移尾部,含 ++modCount)、afterNodeInsertion(插入后触发 removeEldestEntry)、afterNodeRemoval(删除摘链)
  • LRU 实现accessOrder = true + 重写 removeEldestEntry() 返回 size() > capacity;淘汰 head、命中移 tail,全部 O(1)
  • 选型决策:线程安全 > 有序性 > 性能。多线程用 ConcurrentHashMap,需要有序用 LinkedHashMap,按 key 排序用 TreeMap,其余用 HashMap

三者的关系一句话总结:HashMap 追求速度,LinkedHashMap 在速度之上加了顺序,Hashtable 是历史包袱——新代码中永远有更好的选择。

Comments · 评论