别再死记硬背了!从哈希碰撞到红黑树,从扩容死链到 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+)
// 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()方法的执行流程(面试必画流程图)
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()方法——扰动函数)
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。
// 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 缓存(最近最少使用淘汰) |
// 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 缓存的经典写法(面试加分项)
// 实现一个固定大小的 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 7concurrenthashmap(分段锁,已过时)
- 将数据分成 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):并发扩容,多个线程可以同时参与迁移数据(把一个大任务拆成小任务)。
// 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同时操作同一个数组位置,后覆盖前),不建议多线程使用。
八、思考题(检验是否真的懂了)
// 问题 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
}
// 问题 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();
}
}
}
// 问题 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 |
以上就是java中hashmap及其相关类的底层原理与实现详解的详细内容,更多关于java hashmap的资料请关注代码网其它相关文章!
发表评论