Lesson 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 30 秒快速回顾

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

HashMap 一句话总结
数组 + 链表 + 红黑树(JDK 1.8+),非线程安全,允许 null key/value,无序,初始容量 16,扩容 ×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 作为基准。我们来看它的两个"亲戚"各自做了什么不同的设计取舍。

第 3 站

Hashtable:JDK 1.0 的"活化石"

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

先看一段最具代表性的源码——Hashtable 几乎所有公开方法都加了 synchronized 关键字:

Hashtable.java · JDK 源码(核心方法摘录)
public class Hashtable<K,V>
    extends Dictionary<K,V>
    implements Map<K,V>, Cloneable, java.io.Serializable {

    // 存储桶的数组(注意:不是 2 的幂!)
    private transient Entry<?,?>[] table;

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

    // put:整个方法加 synchronized —— 锁住整张表
    public synchronized V put(K key, V value) {
        if (value == null)        // ① 不允许 null value
            throw new NullPointerException();
        // ... 计算 hash、定位桶、插入
        if (key == null)          // ② 不允许 null key
            throw new NullPointerException();
    }

    // get:同样加 synchronized
    public synchronized V get(Object key) { ... }

    // 扩容:newCapacity = oldCapacity * 2 + 1
    protected void rehash() {
        int oldCapacity = table.length;
        int newCapacity = (oldCapacity << 1) + 1;  // ×2+1,不保证 2 的幂
        // ... 逐个 rehash 转移
    }
}
Hashtable 为什么在实际开发中被淘汰?

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

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

总结 Hashtable 的几个关键特征:

  • 继承 Dictionary:这是 JDK 1.0 遗留的抽象类,不是 Collection 框架的一部分
  • 全方法 synchronized:读和写都加锁,即使是纯读操作也会阻塞
  • 不允许 null:key 和 value 都不能为 null,直接抛 NullPointerException
  • 初始容量 11:扩容时 ×2+1,不要求 2 的幂,无法使用位运算优化
  • 只有链表:没有红黑树优化,极端碰撞下查找退化为 O(n)
  • Enumeration 迭代器:除了 Iterator,还保留了古老的 Enumeration 接口
生产环境建议

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

第 4 站

LinkedHashMap:当 HashMap 需要"记住顺序"

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 双向链表
图 2LinkedHashMap 内部结构:在 HashMap 的哈希表之上,额外维护一条按顺序串联所有 entry 的双向链表

来看 LinkedHashMap 的 Entry 节点——它在 HashMap 的 Node 基础上,增加了 beforeafter 两个指针:

LinkedHashMap.Entry · 双向链表节点
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);  // 复用 HashMap.Node 的全部字段
    }
}

// LinkedHashMap 持有头尾引用
transient LinkedHashMap.Entry<K,V> head;  // 链表头(最早插入 / 最久未访问)
transient LinkedHashMap.Entry<K,V> tail;  // 链表尾(最近插入 / 最近访问)

两种排序模式

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

  • accessOrder = false(默认):按插入顺序遍历。先 put 的排前面,put 已有 key 不改变顺序
  • accessOrder = true:按访问顺序遍历。每次 get() 或 put() 命中已有 key 时,该 entry 会被移到链表尾部
LinkedHashMap · 访问顺序调整的核心方法
// 每次 get/put 命中已有 key 后调用
void afterNodeAccess(Node<K,V> e) {
    LinkedHashMap.Entry<K,V> last;
    // accessOrder = true 时,把被访问的节点移到链表尾部
    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 是尾节点
        if (last == null) head = p;  // 链表只有一个节点
        else { p.before = last; last.after = p; }
        tail = p;                   // p 成为新的尾节点
    }
}

用 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) {
        // initialCapacity, loadFactor, accessOrder = true
        super(capacity, 0.75f, true);
        this.capacity = capacity;
    }

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

// 使用示例
LRUCache<String, Integer> cache = new LRUCache<>(3);
cache.put("A", 1);   // {A=1}
cache.put("B", 2);   // {A=1, B=2}
cache.put("C", 3);   // {A=1, B=2, C=3}
cache.get("A");       // {B=2, C=3, A=1}  ← A 被访问,移到尾部
cache.put("D", 4);   // {C=3, A=1, D=4}  ← B 被淘汰(最久未访问)
面试加分点:LinkedHashMap 的性能开销

双向链表的维护是有代价的。每次 put 都要执行 afterNodeInsertion(),每次 get 都要执行 afterNodeAccess()(accessOrder=true 时)。相比 HashMap,LinkedHashMap 的插入和访问多出了一次链表节点的摘除和拼接操作,常数时间开销略高。在对性能极度敏感且不需要有序性的场景下,应该优先使用 HashMap。

第 5 站

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

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

核心对比表
对比维度HashMapHashtableLinkedHashMap
JDK 版本 JDK 1.2 JDK 1.0(最古老) JDK 1.4
父类 AbstractMap Dictionary(已废弃) HashMap
底层结构 数组 + 链表 + 红黑树(1.8+) 数组 + 链表(无红黑树) 数组 + 链表 + 红黑树 + 双向链表
线程安全 是(全方法 synchronized)
null key 允许(1 个) 不允许(抛 NPE) 允许(1 个)
null value 允许(多个) 不允许(抛 NPE) 允许(多个)
遍历顺序 无序 无序 插入顺序 / 访问顺序
初始容量 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(链表维护开销)
典型用途 通用 Map 场景 已淘汰,遗留代码 LRU 缓存、有序遍历
面试追问:Hashtable 的初始容量为什么是 11?

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

第 6 站

真实面试场景与标准回答

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

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

标准回答:

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

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

但要注意,LinkedHashMap 维护双向链表有额外开销。如果只是做 key-value 查找、不需要有序遍历,HashMap 性能更好。

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

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

方案一:继承 LinkedHashMap,设置 accessOrder = true,重写 removeEldestEntry()。这是最简洁的方式,10 行代码搞定(参见第 4 站的 LRU 代码)。

方案二:HashMap + 双向链表手动实现。HashMap 负责 O(1) 查找,双向链表维护访问顺序。put/get 时把节点移到链表头部,淘汰时删除尾部节点。这也是 LeetCode 146 题的标准解法。

方案三:使用 Guava 的 CacheBuilder 或 Caffeine 库。生产环境更推荐这种方式,因为它们支持过期策略、异步刷新、弱引用等高级特性。

HashMap + 双向链表 · 手写 LRU 缓存核心逻辑
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);  // 淘汰最久未使用的
            }
        }
    }
}
Q3:"为什么多线程环境下推荐 ConcurrentHashMap 而不是 Hashtable?"

标准回答:

核心差异在于锁粒度

  • Hashtable:所有方法加 synchronized,锁住的是 this(整张表)。100 个线程同时读写,同一时刻只有 1 个线程能工作,其余 99 个阻塞等待
  • ConcurrentHashMap(JDK 1.8+):用 CAS + synchronized 锁住单个桶的头节点。不同桶的操作互不干扰,理论上最多支持 table.length 个线程并发

此外,ConcurrentHashMap 还有以下优势:

  • 读操作不加锁:基于 volatile + CAS 保证可见性,读性能极高
  • 弱一致性迭代器:不会抛 ConcurrentModificationException
  • 丰富的原子操作putIfAbsent()computeIfPresent()merge()
锁粒度对比
Hashtable:1 把锁 → 全表互斥 → 并发度 = 1
ConcurrentHashMap:N 把锁(每桶一把)→ 桶级并发 → 并发度 = table.length
第 7 站

Map 选型决策树

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

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

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

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

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

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

这篇你掌握了什么

核心知识点回顾

  • HashMap:数组+链表+红黑树,非线程安全,允许 null,无序,初始容量 16,扩容 ×2,性能最优
  • Hashtable:JDK 1.0 遗留类,全方法 synchronized,不允许 null,初始容量 11,扩容 ×2+1,已被淘汰
  • LinkedHashMap:继承 HashMap,额外维护双向链表,支持插入顺序/访问顺序遍历,可实现 LRU 缓存
  • LRU 实现accessOrder = true + 重写 removeEldestEntry(),10 行代码搞定
  • 选型决策:线程安全 > 有序性 > 性能。多线程用 ConcurrentHashMap,需要有序用 LinkedHashMap,其余用 HashMap
  • Hashtable vs ConcurrentHashMap:锁粒度差异——全表锁 vs 桶级锁,并发性能相差一个数量级

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