HashMap 灵魂 30 问:从数据结构到红黑树,从扩容到 ConcurrentHashMap,一篇彻底通关
别再死记硬背了!从哈希碰撞到红黑树,从扩容死链到 CAS,这篇带你真正理解 Map
HashMap 是 Java 中最常用的集合类之一,也是面试中必考、深挖、连环问的绝对核心。
而它的"兄弟姐妹"们------HashSet(底层就是 HashMap)、LinkedHashMap(双向链表 + HashMap)和 ConcurrentHashMap(并发安全的 HashMap)------则是构建高阶知识体系的拼图。
今天这篇文章,我们从底层数据结构 、哈希算法 、扩容机制 、Java 8+ 红黑树优化 、三种遍历顺序 和并发演进六个维度,把这四个类彻底讲透。
一、先上结论(一张表看懂四兄弟)
| 对比维度 | HashMap |
HashSet |
LinkedHashMap |
ConcurrentHashMap |
|---|---|---|---|---|
| 底层数据结构 | 数组 + 链表 + 红黑树(Java 8+) | 就是 HashMap (值存占位符 PRESENT) |
HashMap + 双向链表(维护顺序) |
数组 + 链表 + 红黑树(CAS + synchronized 实现并发) |
| 存储内容 | Key-Value 键值对 |
只存 Key(去重) |
Key-Value 键值对 |
Key-Value 键值对 |
是否允许 null Key |
✅ 允许(存在数组第 0 位) | ✅ 允许(一个) | ✅ 允许 | ❌ 禁止(会 NPE) |
是否允许 null Value |
✅ 允许 | N/A(存的是 PRESENT,非 null) |
✅ 允许 | ❌ 禁止(会 NPE) |
| 线程安全 | ❌ 不安全(Fast-Fail 机制) | ❌ 不安全 | ❌ 不安全 | ✅ 安全(并发读写) |
| 顺序性 | 无序(插入顺序不保证) | 无序 | 有序(插入顺序 或 访问顺序) | 无序(与 HashMap 一致) |
| 适用场景 | 单线程通用存储 | 单线程去重集合 | 需要可预测顺序的 Map | 多线程并发场景 |
🚀 黄金选型原则:
- 单线程存 Key-Value →
HashMap- 单线程存 Key 去重 →
HashSet- 需要保持插入顺序或做 LRU 缓存 →
LinkedHashMap- 多线程并发读写 →
ConcurrentHashMap(千万别用HashTable,已淘汰)
二、HashMap:底层结构与核心原理(源码级)
1. 底层数据结构(Java 8+)
java
// JDK 1.8 源码核心字段
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {
// 1. 核心数组(Node 数组,即哈希桶)
transient Node<K,V>[] table;
// 2. 实际元素个数
transient int size;
// 3. 扩容阈值(capacity * loadFactor)
int threshold;
// 4. 负载因子(默认 0.75)
final float loadFactor;
// 5. 静态内部类 Node(链表节点)
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next; // 指向下一个节点(链表指针)
}
// 6. 树化阈值(链表长度 >= 8 且数组长度 >= 64 时转红黑树)
static final int TREEIFY_THRESHOLD = 8;
// 7. 退树化阈值(红黑树节点数 <= 6 时退化为链表)
static final int UNTREEIFY_THRESHOLD = 6;
// 8. 最小树化容量
static final int MIN_TREEIFY_CAPACITY = 64;
}
2. 数据结构示意图
HashMap 底层结构(Java 8+)
┌─────────────────────────────────────────────────────────────────┐
│ table 数组(哈希桶) │
│ ┌────┬────┬────┬────┬────┬────┬────┬────┬────┬────┐ │
│ │ 0 │ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │ 7 │ 8 │ 9 │ ... │
│ └────┴────┴────┴────┴────┴────┴────┴────┴────┴────┘ │
│ │ │ │
│ ↓ ↓ │
│ Node(链表) 红黑树(TreeNode) │
│ ┌─────┐ ┌─────┐ │
│ │Key1 │──→ │Key4 │ (红黑节点,自平衡) │
│ ├─────┤ next ├─────┤ │
│ │Key2 │──→ │Key5 │ │
│ ├─────┤ next ├─────┤ │
│ │Key3 │ │Key6 │ │
│ └─────┘ └─────┘ │
└─────────────────────────────────────────────────────────────────┘
3. put() 方法的执行流程(面试必画流程图)
java
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 1. table 为空则初始化(resize)
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 2. 计算数组下标 (n - 1) & hash,若该位置为空则直接放入
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
// 3. 如果 key 相同(hash 相同 && (引用相同 || equals 为 true))→ 覆盖
if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 4. 如果已经是红黑树节点 → 走红黑树的 putTreeVal
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
// 5. 链表情况:遍历链表
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 5a. 如果链表长度 >= 8 → 尝试转红黑树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 6. 覆盖旧值
if (e != null) {
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
return oldValue;
}
}
++modCount; // 记录修改次数(用于 Fast-Fail)
if (++size > threshold) // 7. 超过阈值则扩容
resize();
return null;
}
4. 哈希算法(hash() 方法------扰动函数)
java
static final int hash(Object key) {
int h;
// 让 hashCode 的高 16 位参与低 16 位的运算(混合扰动),减少哈希碰撞
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
计算数组下标的公式 :(n - 1) & hash(n 是数组长度,必须是 2 的幂次方)。
5. 扩容机制(resize()------最耗时的操作)
- 触发条件 :
size >= threshold(容量 × 负载因子),默认16 × 0.75 = 12。 - 新容量 :翻倍 (
oldCap << 1),所以容量始终是 2 的幂。 - 重哈希(Rehash) :扩容后,每个元素要么留在原位置
i,要么移动到i + oldCap位置(利用 2 的幂取模特性,效率极高)。
🚨 扩容性能提示 :如果能预知元素数量,请在构造时指定
initialCapacity,避免频繁扩容带来的性能损耗。
6. 为什么链表长度 ≥ 8 时转红黑树?
- 理想情况下 ,链表的平均长度为
0.75(负载因子),出现 8 个碰撞的概率低于千万分之一(泊松分布)。 - 如果大于等于 8,说明哈希函数有严重问题或数据量极大,此时转为红黑树(时间复杂度从 O(n) 降为 O(log n)),保证性能。
- 退树化:红黑树节点数 ≤ 6 时,退化为链表(节点数少时,树维护开销反而更大)。
三、HashSet:披着 Set 外衣的 HashMap
HashSet 的底层非常简单------直接复用 HashMap ,元素作为 Key,Value 统一用一个虚拟占位对象 PRESENT。
java
// JDK 源码
public class HashSet<E> extends AbstractSet<E>
implements Set<E>, Cloneable, java.io.Serializable {
private transient HashMap<E,Object> map; // 底层就是 HashMap!
// 虚拟占位值(所有 Value 都指向它)
private static final Object PRESENT = new Object();
// 构造方法(直接 new HashMap)
public HashSet() {
map = new HashMap<>();
}
// 添加元素:存到 map 的 Key 位置
public boolean add(E e) {
return map.put(e, PRESENT) == null;
}
// 删除元素
public boolean remove(Object o) {
return map.remove(o) == PRESENT;
}
}
📌 结论 :
HashSet的所有操作 都是委托给内部的HashMap完成的,它的特性和HashMap完全一致(线程不安全、允许null、无序)。
四、LinkedHashMap:有序的 HashMap(LRU 缓存的基础)
LinkedHashMap 在 HashMap 的基础上,额外维护了一个双向链表来记录元素的顺序。
1. 两种顺序模式
| 模式 | 构造参数 accessOrder |
顺序逻辑 | 典型用途 |
|---|---|---|---|
| 插入顺序(默认) | false(默认) |
按照元素插入的顺序遍历 | 需要可预测迭代顺序的普通 Map |
| 访问顺序 | true |
按照元素最近被访问(get/put) 的顺序遍历 | LRU 缓存(最近最少使用淘汰) |
java
// 1. 插入顺序(默认)
LinkedHashMap<String, String> map1 = new LinkedHashMap<>();
map1.put("A", "1");
map1.put("B", "2");
map1.put("C", "3");
// 遍历输出:A, B, C(按插入顺序)
// 2. 访问顺序(accessOrder = true)
LinkedHashMap<String, String> map2 = new LinkedHashMap<>(16, 0.75f, true);
map2.put("A", "1");
map2.put("B", "2");
map2.put("C", "3");
map2.get("A"); // 访问 A,A 会被移动到链表尾部
// 遍历输出:B, C, A(最近访问的放在最后)
2. 实现 LRU 缓存的经典写法(面试加分项)
java
// 实现一个固定大小的 LRU 缓存
class LRUCache<K, V> extends LinkedHashMap<K, V> {
private final int maxSize;
public LRUCache(int maxSize) {
super(maxSize, 0.75f, true); // accessOrder = true
this.maxSize = maxSize;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
// 当元素数量超过最大容量时,自动移除最老的元素(链表头部)
return size() > maxSize;
}
}
// 使用示例
LRUCache<String, Integer> cache = new LRUCache<>(3);
cache.put("A", 1);
cache.put("B", 2);
cache.put("C", 3);
cache.get("A"); // 访问 A
cache.put("D", 4); // 触发淘汰,淘汰最久未使用的 B
// 缓存中:C, A, D
五、ConcurrentHashMap:并发安全的终极方案
1. 为什么不用 HashTable?(面试送命题)
| 对比维度 | HashTable |
ConcurrentHashMap |
|---|---|---|
| 锁粒度 | 整张表(所有操作锁住整个对象) | 分段锁(Java 7)/ 节点锁(Java 8+) |
| 并发度 | 极低(同一时刻只有一个线程能操作) | 极高(多线程可以同时操作不同段/不同节点) |
| 性能 | 差(已被淘汰) | 优(并发王者) |
null 支持 |
不支持(会 NPE) | 不支持(会 NPE) |
💡 结论 :
HashTable已被官方标记为"遗留类",新代码中绝不要使用。
2. Java 7 ConcurrentHashMap(分段锁,已过时)
- 将数据分成 16 个 Segment(段) ,每个 Segment 独立加锁(继承
ReentrantLock)。 - 并发度 = 16。
- 缺点:Segment 数量固定,无法动态调整,且 16 个锁的并发度在大规模并发下不够高。
3. Java 8+ ConcurrentHashMap(CAS + synchronized------当前的实现)
Java 8 彻底抛弃了 Segment 分段锁,采用了更细粒度的锁机制:
put操作 :- 如果该数组位置为空 → CAS(Compare And Swap) 自旋插入,无锁。
- 如果该位置不为空 →
synchronized(头节点),只锁住当前链表的头节点或红黑树的根节点。
get操作 :完全无锁 (通过volatile保证可见性),读取效率极高。- 扩容(
transfer) :并发扩容,多个线程可以同时参与迁移数据(把一个大任务拆成小任务)。
java
// JDK 8+ 源码核心(putVal 方法片段)
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException(); // 禁止 null
int hash = spread(key.hashCode());
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0)
tab = initTable();
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
// 1. 位置为空 → CAS 尝试插入(无锁)
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
break;
} else if ((fh = f.hash) == MOVED)
// 2. 正在扩容 → 帮助迁移
tab = helpTransfer(tab, f);
else {
V oldVal = null;
// 3. 位置不为空 → synchronized 锁住头节点
synchronized (f) {
// ... 插入或覆盖逻辑
}
break;
}
}
addCount(1L, binCount);
return null;
}
4. 为什么 ConcurrentHashMap 不允许 null?
- 设计歧义性 :在并发环境下,
map.get(key)返回null,无法区分是"这个 key 不存在"还是"这个 key 存在,但 Value 就是null"。 - 在非并发的
HashMap中,可以通过containsKey()区分,但在并发环境下,两次调用之间状态可能已经改变,区分变得毫无意义。因此 JDK 设计者直接禁止了nullKey 和nullValue。
六、四兄弟对比总结(面试速记版)
| 特性 | HashMap |
HashSet |
LinkedHashMap |
ConcurrentHashMap |
|---|---|---|---|---|
| 底层 | 哈希表 + 红黑树 | 就是 HashMap |
HashMap + 双向链表 |
CAS + synchronized + 红黑树 |
| 是否有序 | ❌ | ❌ | ✅(插入或访问顺序) | ❌ |
| 线程安全 | ❌ | ❌ | ❌ | ✅ |
允许 null Key |
✅ | ✅ | ✅ | ❌ |
允许 null Value |
✅ | N/A | ✅ | ❌ |
| 适用场景 | 单线程通用 Map | 单线程去重 | 有序 Map / LRU 缓存 | 多线程并发读写 |
七、高频面试连环追问
Q1:HashMap 中 hashCode() 和 equals() 的关系?
- 两个对象
equals()为true,则hashCode()必须相等。 - 两个对象
hashCode()相等,equals()不一定 为true(哈希碰撞)。 - 实践 :重写
equals()必须重写hashCode()。
Q2:HashMap 的容量为什么是 2 的幂次方?
- 方便
(n - 1) & hash快速取模(位运算比%快)。 - 扩容时,元素要么在原位,要么在
i + oldCap,迁移逻辑简单高效。
Q3:ConcurrentHashMap 在 Java 7 和 Java 8 的区别?
- Java 7 :
Segment(分段锁),一个锁管一个 Segment。 - Java 8+ :
CAS+synchronized(锁头节点),并发度更高,锁粒度更细。
Q4:HashMap 在多线程下有什么问题?
- JDK 1.7 :并发扩容可能产生死链(环形链表),导致 CPU 100%。
- JDK 1.8 :扩容算法优化,不会死链,但仍存在数据覆盖 问题(两个
put同时操作同一个数组位置,后覆盖前),不建议多线程使用。
八、思考题(检验是否真的懂了)
java
// 问题 1:下面代码输出什么?为什么?
public static void main(String[] args) {
HashSet<String> set = new HashSet<>();
String s1 = new String("Hello");
String s2 = new String("Hello");
set.add(s1);
set.add(s2);
System.out.println(set.size()); // A
}
java
// 问题 2:下面代码在多线程环境下会有什么问题?
public class Test {
private static final Map<String, String> map = new HashMap<>();
public static void main(String[] args) {
for (int i = 0; i < 100; i++) {
new Thread(() -> {
map.put(Thread.currentThread().getName(), "value");
}).start();
}
}
}
java
// 问题 3:下面哪个 Map 最合适用来实现一个带有过期淘汰策略的缓存?
// A. HashMap B. LinkedHashMap C. ConcurrentHashMap D. HashTable
答案(选中下方空白区域查看):
- 输出 1 。因为
HashSet底层是HashMap,通过equals()和hashCode()去重。s1和s2虽然是不同对象,但String重写了equals()和hashCode(),它们内容相同,所以只能存 1 个。- 数据覆盖、脏读,甚至无限循环或
ConcurrentModificationException。HashMap线程不安全,多线程并发put可能导致内部数组结构被破坏(如 size 不准确、键值覆盖等)。- B.
LinkedHashMap。因为它的accessOrder=true配合removeEldestEntry()方法,可以非常轻松地实现 LRU 缓存淘汰策略。
总结(终极速查表)
| 知识点 | 一句话记忆 |
|---|---|
HashMap 底层 |
数组 + 链表 + 红黑树(Java 8) |
| 扩容因子 | 默认 0.75(时间和空间的权衡) |
| 树化阈值 | 链表长度 ≥ 8 且数组 ≥ 64 时转红黑树 |
HashSet |
底层就是 HashMap,只存 Key |
LinkedHashMap |
HashMap + 双向链表,可以按插入/访问顺序迭代 |
ConcurrentHashMap(Java 8) |
CAS + synchronized(锁头节点),读操作无锁 |
| 线程安全选择 | 多线程 → ConcurrentHashMap (绝不用 HashTable) |
null 限制 |
ConcurrentHashMap 和 HashTable 禁止 null |
💬 互动话题 :你在面试中遇到过关于 HashMap 的"变态"追问吗?比如为什么负载因子是 0.75?或者有没有多线程使用 HashMap 导致死循环或数据丢失的血泪史?欢迎评论区分享!🚀
如果觉得有收获,别忘了点赞 、收藏 、转发,让更多 Javaer 彻底搞懂 Map 家族的底层原理!我们下篇见!👋
发布日期:2026-08-28