Lesson 03 · 集合框架源码
HashMap vs Hashtable vs LinkedHashMap 横向对比
面试官的下一个追问
很多候选人能把 HashMap 的底层原理讲得头头是道,但当面试官话锋一转——"Hashtable 为什么没人用了?""LinkedHashMap 怎么实现 LRU?"——就开始支支吾吾。这不是因为知识有多难,而是大家很少把三者放在一起系统性地对比。
事实上,HashMap、Hashtable、LinkedHashMap 虽然都实现了 Map 接口,但它们的设计目标截然不同:
- HashMap —— 追求极致性能,无序、非线程安全,是日常开发的主力
- Hashtable —— JDK 1.0 的"活化石",粗暴的全表锁,已被 ConcurrentHashMap 取代
- LinkedHashMap —— 在 HashMap 基础上增加了有序性,是实现 LRU 缓存的利器
这篇文章我们就从线程安全、有序性、性能三个维度,做一次彻底的横向对比。读完之后,面试中任何关于"Map 选型"的追问,你都能从容应对。
HashMap 30 秒快速回顾
在做对比之前,先用 30 秒回顾 HashMap 的核心特征,作为后续比较的基准线:
数组 + 链表 + 红黑树(JDK 1.8+),非线程安全,允许 null key/value,无序,初始容量 16,扩容 ×2
接下来的对比,都会以 HashMap 作为基准。我们来看它的两个"亲戚"各自做了什么不同的设计取舍。
Hashtable:JDK 1.0 的"活化石"
Hashtable 是 Java 中最古老的集合类之一,诞生于 JDK 1.0(1996 年),比 Map 接口本身还要早。它继承自已废弃的 Dictionary 抽象类,是当年唯一的线程安全 Map 实现。
先看一段最具代表性的源码——Hashtable 几乎所有公开方法都加了 synchronized 关键字:
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 的线程安全策略极其简单粗暴:在每个方法上加 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 唯一出现的场景可能是维护老旧代码时的遗留引用。
LinkedHashMap:当 HashMap 需要"记住顺序"
HashMap 是无序的——你插入 A、B、C,遍历时可能是 C、A、B。但在很多场景下,我们希望 Map 能记住元素的顺序。LinkedHashMap 正是为此而生。
LinkedHashMap 继承自 HashMap,在复用其全部哈希表能力的基础上,额外维护了一条双向链表,将所有 entry 串联起来:
来看 LinkedHashMap 的 Entry 节点——它在 HashMap 的 Node 基础上,增加了 before 和 after 两个指针:
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 会被移到链表尾部
// 每次 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 淘汰策略:
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 被淘汰(最久未访问)
双向链表的维护是有代价的。每次 put 都要执行 afterNodeInsertion(),每次 get 都要执行 afterNodeAccess()(accessOrder=true 时)。相比 HashMap,LinkedHashMap 的插入和访问多出了一次链表节点的摘除和拼接操作,常数时间开销略高。在对性能极度敏感且不需要有序性的场景下,应该优先使用 HashMap。
三者全面对比:一张表说清楚
前面三站分别讲了三者的设计哲学,现在把它们放在一起做系统性对比。这张表覆盖了面试中可能被追问的所有维度:
| 对比维度 | HashMap | Hashtable | LinkedHashMap |
|---|---|---|---|
| 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 诞生于 1996 年,当时还没有"容量必须是 2 的幂"这种优化意识。它使用 hash % table.length 做取模运算,因此不限制容量形式。而 HashMap 从 JDK 1.2 开始就采用 2 的幂容量,用 hash & (n-1) 位运算替代取模,性能更优。这也是 Hashtable 扩容用 ×2+1(保持奇数,试图让取模分布更均匀)而不是 ×2 的原因。
真实面试场景与标准回答
以下是面试中关于 Map 选型最常出现的三个追问,以及能够展现深度的回答框架:
标准回答:
当业务需要可预测的遍历顺序时,选 LinkedHashMap。典型场景有三种:
- LRU 缓存:设置
accessOrder = true,配合removeEldestEntry()实现淘汰策略,代码量极少 - 保持插入顺序:比如解析 YAML/JSON 配置文件后,希望遍历时保持原有的键顺序
- 展示层数据:某些 UI 组件需要按插入顺序展示数据,LinkedHashMap 天然保证这一点
但要注意,LinkedHashMap 维护双向链表有额外开销。如果只是做 key-value 查找、不需要有序遍历,HashMap 性能更好。
标准回答(分层次展开):
方案一:继承 LinkedHashMap,设置 accessOrder = true,重写 removeEldestEntry()。这是最简洁的方式,10 行代码搞定(参见第 4 站的 LRU 代码)。
方案二:HashMap + 双向链表手动实现。HashMap 负责 O(1) 查找,双向链表维护访问顺序。put/get 时把节点移到链表头部,淘汰时删除尾部节点。这也是 LeetCode 146 题的标准解法。
方案三:使用 Guava 的 CacheBuilder 或 Caffeine 库。生产环境更推荐这种方式,因为它们支持过期策略、异步刷新、弱引用等高级特性。
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); // 淘汰最久未使用的
}
}
}
}
标准回答:
核心差异在于锁粒度:
- 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
Map 选型决策树
说了这么多,最终回到实战中最核心的问题:面对一个具体需求,该选哪个 Map? 下面这张决策树可以帮你在 3 秒内做出判断:
这张决策树的核心思路是逐层排除:
- 第一问:是否多线程? 是 → ConcurrentHashMap(不要用 Hashtable)
- 第二问:是否需要有序遍历? 否 → HashMap(性能最优)
- 第三问:按什么排序? 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 是历史包袱——新代码中永远有更好的选择。