JAVA · Vol.I · DAY 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 快速回顾:字段、阈值与 hash 算法
在做对比之前,先用 30 秒回顾 HashMap 的核心特征,作为后续比较的基准线:
数组 + 链表 + 红黑树(JDK 1.8+),非线程安全,允许 null key/value,无序,初始容量 16,负载因子 0.75,扩容 ×2
先把"背"下来的常量变成源码字段
面试中经常被问"HashMap 的树化阈值是多少",光记数字不够,要能说出常量名。下面这些字段全部来自 JDK 1.8 的 HashMap:
// ---------- 常量 ----------
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 算法与桶定位:两处位运算
// ① 扰动函数: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 的幂的原因
因为定位用的是 (n - 1) & hash。只有当 n 是 2 的幂时,n - 1 的二进制才是低位全 1,位与结果才恰好等于 hash % n,且不会因为高位被"切掉"而增大碰撞概率。传入构造函数的任意容量,都会先经过 tableSizeFor() 向上取整到最近的 2 的幂——比如 new HashMap(10) 实际容量是 16。
树化 / 退化的两个条件,一张图记牢
- 扩容时机:
size > threshold(threshold = capacity × loadFactor),容量直接 ×2,保持 2 的幂 - 树化是"双重保险":桶长度 ≥ 8 且 数组 ≥ 64;否则先扩容把元素分散
- 退化只发生在扩容
split之后:某桶节点数 ≤ 6 时untreeify()退回链表
接下来的对比,都会以 HashMap 作为基准。我们来看它的两个"亲戚"各自做了什么不同的设计取舍。
HashMap 的 null:从 hash(null)=0 说起
很多人只知道结论"HashMap 允许 null",却说不清底层是怎么处理的。答案就在第 2 站展示的 hash() 方法里——null key 走的是特判分支:
// 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 固定在桶 0:
hash(null) = 0,(n-1) & 0 = 0,所以 null key 永远在table[0] - null value 没有限制:value 不参与哈希,任意多个键都可以映射到 null
Hashtable 为什么拒绝?对比一下就清楚了
Hashtable 的 put() 里 value 为 null 是显式抛 NPE,key 为 null 则是在调用 key.hashCode() 那一行自然抛出 NPE——它根本没有为 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;
// ……
}
根本原因是设计年代与定位不同。Hashtable 诞生于 JDK 1.0,是保守的"老祖宗"设计;HashMap 诞生于 JDK 1.2,从第一天就把"允许 null"作为特性写进文档——它需要在 hash() 入口对 null 做特判,成本只是一次分支判断。而 ConcurrentHashMap 也不允许 null,理由则完全不同(并发语义上的二义性),这个坑我们在第 5 站专门展开。
map.get(key) == null在 HashMap 里无法区分"key 不存在"还是"value 就是 null"——需要区分时必须先containsKey()- 缓存类场景不要存 null value,否则命中与否的判断会失效;可以用
Optional包装或约定哨兵值 - 并发场景根本不存在这个问题:Hashtable / ConcurrentHashMap 的 value 不允许为 null,
get() == null就代表 key 不存在
Hashtable:JDK 1.0 的"活化石"
Hashtable 是 Java 中最古老的集合类之一,诞生于 JDK 1.0(1996 年),比 Map 接口本身还要早。它继承自已废弃的 Dictionary 抽象类,是当年唯一的线程安全 Map 实现。
先看一段最具代表性的源码——Hashtable 的所有公开方法(put / get / remove / containsKey / size ……)都加了 synchronized 关键字:
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 的线程安全策略极其简单粗暴:在每个方法上加 synchronized,锁的是 this(整张表)。同一时刻只有一个线程能访问整张表,高并发下所有线程都要排队争抢同一把锁,性能急剧下降——这就是"全表锁"。
相比之下,ConcurrentHashMap 采用 CAS + synchronized(只锁单个桶的头节点),锁粒度从"整张表"细化到"单个桶",并发性能高出一个数量级。具体拆解见第 5 站。
容易被追问的冷知识:Enumerator
Hashtable 除了迭代器 Iterator,还保留了古老的 Enumeration 接口。一个容易被忽略的细节是:JDK 8 里这两者是同一个类实现的:
// 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同时实现Enumeration和Iterator,且都是 fail-fast
新代码中不要使用 Hashtable。如果需要线程安全的 Map,首选 ConcurrentHashMap;如果只是单线程场景,直接用 HashMap 即可。Hashtable 唯一出现的场景可能是维护老旧代码时的遗留引用。
Hashtable 的继承者:ConcurrentHashMap 凭什么快一个数量级
答案只有四个字:锁的粒度。Hashtable 锁整张表(1 把锁),ConcurrentHashMap 锁单个桶(N 把锁)。JDK 1.8 起,ConcurrentHashMap 的结构与 HashMap 几乎一致(数组 + 链表 + 红黑树),但并发控制完全重做:
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
/**
* 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 分片——牺牲一点即时性,换取并发度
① 写入:桶空用 CAS 无锁写入,桶非空只 synchronized 头节点,不同桶互不阻塞;② 读取:Node 的 val 和 next 都是 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 不存在
LinkedHashMap:数据结构与关键字段
HashMap 是无序的——你插入 A、B、C,遍历时可能是 C、A、B。但在很多场景下,我们希望 Map 能记住元素的顺序。LinkedHashMap 正是为此而生。
LinkedHashMap 继承自 HashMap,在复用其全部哈希表能力的基础上,额外维护了一条双向链表,将所有 entry 串联起来:
Entry 节点与三个关键字段
LinkedHashMap 的 Entry 在 HashMap 的 Node 基础上,增加了 before 和 after 两个指针;类本身则额外持有 head、tail、accessOrder 三个字段:
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 会被移到链表尾部
// 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 源码的精髓。
LinkedHashMap 源码精髓:三个钩子方法
LinkedHashMap 几乎没有重写任何核心算法,它只是重写了 HashMap 预留的三个空钩子:afterNodeAccess()、afterNodeInsertion()、afterNodeRemoval()。这三个方法在 HashMap 里是空实现(模板方法模式),在 LinkedHashMap 里负责维护双向链表。
钩子 ①:afterNodeAccess —— 访问后把节点移到尾部
在 get() / put() 命中已有 key 时被调用。配合 accessOrder = true,这就是 LRU 的"最近使用过"标记:
// 把被访问的节点从链表中间"摘"出来,重新挂到尾部
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
}
}
注意 afterNodeAccess 里的 ++modCount。它意味着在 accessOrder = true 时,读操作 get() 也会引发结构性变化。如果你在遍历 LinkedHashMap 的同时调用 get(),迭代器检测到 modCount 变化,会抛出 ConcurrentModificationException。这是很多人在实现"访问计数缓存"时踩过的坑。
钩子 ②:afterNodeInsertion —— 插入后检查是否淘汰
每次 put()/putAll() 结束时被调用,是 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 —— 删除后同步摘链
// 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() 新 key | afterNodeInsertion(true) | 尝试淘汰 eldest(LRU 入口) |
put() 覆盖已有 key | afterNodeAccess(e) | 访问序下把节点移到尾部 |
get() / getOrDefault() 命中 | afterNodeAccess(e) | 访问序下把节点移到尾部 |
remove() / clear() | afterNodeRemoval(e) | 从双向链表摘除节点 |
重写的方法:get / getOrDefault / containsValue / clear
// 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)),只是多了常数级的链表维护开销
用 LinkedHashMap 实现 LRU 缓存
这是 LinkedHashMap 最经典的实战应用。当 accessOrder = true 时,最近访问的元素自动移到尾部,最久没访问的元素留在头部。配合 removeEldestEntry() 钩子方法,可以轻松实现 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
三个必须说清的边界条件
- 为什么是
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 的最佳练习:
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 方案并自行加锁。
三者全面对比:一张表说清楚
前面几站分别讲了三者的设计哲学,现在把它们放在一起做系统性对比。这张表覆盖了面试中可能被追问的所有维度:
| 对比维度 | HashMap | Hashtable | LinkedHashMap |
|---|---|---|---|
| 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 诞生于 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 永远不选
边界条件与生产踩坑清单
为什么是 8 / 6 / 64?
JDK 源码注释给出了答案:在负载因子 0.75 且哈希分布理想(泊松分布,λ ≈ 0.5)的前提下,桶内出现 8 个元素的概率约为 0.00000006(6 × 10-8)——亿分之六。也就是说,链表长度到 8 基本只可能是hashCode 分布被恶意/意外破坏(比如对象 hashCode 恒等),此时树化是必要的兜底。
至于退化阈值 6:如果树化阈值和退化阈值都设成 8,元素数量在 8 附近抖动时,链表↔红黑树会反复转换,白白消耗 CPU。中间留出 2 个数的滞回区间(hysteresis),转换就不那么频繁了。而 64 意味着:桶快满时优先扩容分散元素,而不是急着树化——扩容后一半元素会挪到新桶,碰撞自然缓解。
初始容量给多少?一个公式
// 扩容阈值 = 容量 × 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 排序) - 多线程:
ConcurrentHashMap,get() == null即可断定 key 不存在 - 多线程 + 容量上限:Caffeine / Guava Cache(注意非严格 LRU);强 LRU 语义则 LinkedHashMap + 锁
- 禁止 Hashtable:它的存在只用于回答面试题和读懂历史代码
真实面试场景与标准回答
以下是面试中关于 Map 选型最常出现的追问,以及能够展现深度的回答框架:
标准回答:当业务需要可预测的遍历顺序时,选 LinkedHashMap。典型场景有三种:
- LRU 缓存:设置
accessOrder = true,配合removeEldestEntry()实现淘汰策略,代码量极少 - 保持插入顺序:比如解析 YAML/JSON 配置文件后,希望遍历时保持原有的键顺序
- 展示层数据:某些 UI 组件需要按插入顺序展示数据,LinkedHashMap 天然保证这一点
但要注意,LinkedHashMap 维护双向链表有额外开销:每次 put 多一次 afterNodeInsertion()、每次命中多一次 afterNodeAccess() 的链表摘挂。如果只是做 key-value 查找、不需要有序遍历,HashMap 性能更好。
标准回答(分层次展开):
- 方案一:继承 LinkedHashMap,设置
accessOrder = true,重写removeEldestEntry()返回size() > capacity。最简洁,十几行代码(见第 8 站) - 方案二:HashMap + 双向链表手动实现。HashMap 负责 O(1) 查找,双向链表维护访问顺序,put/get 把节点移到头部、淘汰删尾部。LeetCode 146 的标准解法
- 方案三:生产环境用 Caffeine / Guava Cache,支持过期策略、异步刷新、弱引用等高级特性;但淘汰算法非严格 LRU
标准回答:核心差异在锁粒度——Hashtable 锁整张表(并发度 1),ConcurrentHashMap 用 CAS + synchronized 锁单个桶头(并发度 ≈ 桶数),读操作基于 volatile 完全无锁。此外 CHM 还有:弱一致性迭代器(不抛 CME)、putIfAbsent() / computeIfAbsent() / merge() 原子操作、多线程协同扩容。细节参见第 5 站。
标准回答:不一样。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)。
Map 选型决策树
说了这么多,最终回到实战中最核心的问题:面对一个具体需求,该选哪个 Map? 下面这张决策树可以帮你在 3 秒内做出判断:
这张决策树的核心思路是逐层排除:
- 第一问:是否多线程? 是 → ConcurrentHashMap(不要用 Hashtable)
- 第二问:是否需要有序遍历? 否 → HashMap(性能最优)
- 第三问:按什么排序? key 自然顺序 → TreeMap;插入/访问顺序 → LinkedHashMap
当面试官问"你一般怎么选 Map"时,可以按这个顺序展开:
- 先说决策树思路(展现结构化思维)
- 再补充具体场景案例(比如"我项目中用 LinkedHashMap 做过配置缓存")
- 最后点出Hashtable 已淘汰以及 ConcurrentHashMap 的锁粒度优势(展现对底层原理的理解)
这一篇你掌握了什么
核心知识点回顾
- HashMap:数组+链表+红黑树,非线程安全,允许 null(key 1 个落桶 0 / value 多个),无序,初始容量 16,负载因子 0.75,扩容 ×2(保持 2 的幂,
(n-1) & hash定位) - 关键阈值:树化
TREEIFY_THRESHOLD = 8且MIN_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 · 评论