Java 基础核心复习笔记 (Day 05) —— Map体系深度解析
Java 基础核心复习笔记 (Day 05) —— Map 体系深度解析
1. HashMap:王者归来
HashMap 是 Java 中使用频率最高的键值对容器。理解它,是理解 Java 集合框架的关键。
1.1 底层结构 (JDK 1.7 vs JDK 1.8)
- JDK 1.7: 数组 + 链表。
- Entry 数组。即使哈希冲突了,也只是拉一条链表。
- 缺点:如果黑客故意构造 hash 冲突,链表会变得无限长,查询退化为 O(n)。
- JDK 1.8: 数组 + 链表 + 红黑树。
- 当链表长度 > 8 且 数组长度 >= 64 时,链表会转为 红黑树。
- 优点:查询效率从 O(n) 提升到 O(log n)。
🛠️ 图解:HashMap 内存结构 (JDK 1.8)
1 | [Node[] table 数组] |
📝 图解说明: HashMap 就像一个巨大的停车场(数组)。
- 每辆车(Key-Value)进来,通过 Hash 算法计算它该停在哪个车位(Index)。
- 如果车位是空的,直接停进去。
- 如果车位已经有车了(哈希冲突),JDK 1.7 说是“串糖葫芦”(链表),后来的车挂在前面的车后面。
- JDK 1.8 说:如果串得太长(超过8个),找车太慢了,我就把它改造成立体停车库(红黑树),这样找车就快多了。
1.2 核心源码解析:tableSizeFor (2 的幂次方)
为什么 HashMap 的容量总是 2 的 n 次方?即使你传了 10,它也会把你改成 16。秘密就在这个方法:
1 | /** |
🤔 为什么要这样?(菜鸟解惑) 想象二进制数 0001 0100 (20)。
- 我们希望把它变成
0010 0000(32)。 - 上面的移位操作,就是为了把
1后面的0全部填满变成1,得到0001 1111(31)。 - 最后
n + 1,31 + 1 = 32。搞定!
1.3 核心源码解析:hash 方法 (扰动函数)
1 | // JDK 1.8 源码 |
🛠️ 图解:为什么要异或?
1 | hashCode (32位): 1111 1111 1111 1111 0000 0000 0000 0000 |
解释: HashMap 计算数组下标的公式是 (n - 1) & hash。 如果数组长度 n 很小(比如 16),n-1 就是 1111 (二进制)。 如果不做扰动,只有 hash 值的最后 4 位参与运算,高位全被忽略了,这会导致严重的哈希冲突。 通过 ^ (h >>> 16),让高 16 位也参与到低位的运算中,增加了随机性,减少了冲突。
🔥 大厂面试通关检查 (HashMap 篇)
Q1: 为什么 HashMap 的长度必须是 2 的幂次方?
A: (这是必考题,回答要分两层)
- 为了性能 (位运算替代取模): 我们希望数据均匀分布,通常做法是
hash % length。但是,CPU 做取模运算很慢。如果length是 2 的幂次方,那么hash % length等价于hash & (length - 1)。位运算(&)比取模(%)快得多。- 为了分布均匀:
length - 1的二进制位全是 1 (如 16-1=15 -> 1111)。这样在进行&运算时,hash 值的最后几位能完全保留下来,每一位都有机会参与索引计算,能最大程度保证散列均匀。如果 length 是奇数,最后一位是 0,那么所有算出来的下标都是偶数,浪费了一半空间。
Q2: 为什么 JDK 1.8 引入红黑树?阈值为什么是 8 和 64?
A:
- 引入红黑树: 解决哈希冲突导致的链表过长问题。链表查询 O(n),红黑树 O(log n)。防止发生 Hash DoS 攻击(黑客构造大量相同 hash 值的 key 让服务器 CPU 飙升)。
- 阈值 8 (树化): 这是一个概率学问题。根据泊松分布,在负载因子 0.75 下,链表长度达到 8 的概率极低(亿分之六)。如果真的到了 8,说明哈希冲突极其严重,必须变树来优化。
- 阈值 64 (最小数组容量): 如果数组很短(<64),哈希冲突大概率是因为数组太挤了,这时候应该优先扩容(resize),而不是急着变树。变树结构复杂,开销大,扩容能直接解决拥挤问题。
Q3: HashMap 是线程安全的吗?会出现什么问题?
A: 不是线程安全的。
- JDK 1.7: 在多线程扩容时,采用头插法移动链表元素。如果两个线程同时扩容,可能导致链表中的节点指针互相指,形成死循环 (Infinite Loop),导致 CPU 100%。
- JDK 1.8: 改成了尾插法,解决了死循环问题。但是,多线程下仍然存在数据覆盖的问题(两个线程同时判断一个坑位为空,同时写入,后写的覆盖先写的),导致数据丢失。
2. 并发噩梦的终结者:ConcurrentHashMap (CHM)
为了解决 HashMap 的线程不安全,又不想用慢如蜗牛的 Hashtable(全表一把锁),Doug Lea 大神贡献了 CHM。
2.1 结构演进 (JDK 1.7 vs 1.8)
JDK 1.7: 分段锁 (Segment)
- 核心思想: 既然锁全表太慢,那我就把数组切成一段一段 (Segment),每段配一把锁。
- 结构:
Segment 数组 + HashEntry 数组 + 链表。 - 锁:
ReentrantLock。默认并发度 16。
🛠️ 图解: 1.7 分段锁
1 | Map |
JDK 1.8: CAS + synchronized
- 核心思想: 放弃 Segment,回归 HashMap 的大数组结构。锁的粒度细化到每一个桶 (Node/Head)。
- 结构:
Node 数组 + 链表 + 红黑树。 - 锁:
synchronized锁住链表/红黑树的头节点。
🛠️ 图解: 1.8 节点锁
1 | Map (Node[]) |
📝 图解说明:
- 1.7: 像是 16 个小的 HashMap 拼在一起,操作之前先判断你在哪个小 Map 里,然后锁住那个小 Map。
- 1.8: 只要哈希不冲突,大家随便写(CAS 保证)。如果发生冲突(撞车了),只锁住撞车的那一列(头节点),其他列完全不受影响。这是并发度的极限。
2.2 核心源码解析:JDK 1.8 put 方法
这是 CHM 最精华的部分,展示了它是如何利用 CAS 和 synchronized 的。
1 | final V putVal(K key, V value, boolean onlyIfAbsent) { |
🔥 大厂面试通关检查 (ConcurrentHashMap 篇)
Q1: JDK 1.7 和 1.8 的 ConcurrentHashMap 实现有什么本质区别?
A: (高频对比题,建议分三点回答)
- 数据结构: 1.7 是
Segment数组 +HashEntry数组,实现了分段锁;1.8 去掉了Segment,直接用Node数组 + 链表 + 红黑树,结构与 HashMap 一致。- 锁机制: 1.7 使用
ReentrantLock(Segment 继承自它);1.8 使用CAS+synchronized。JDK 1.8 中synchronized做了大量优化,在低竞争下性能很好,且节省了 Segment 对象的内存开销。- 锁粒度: 1.7 的锁粒度是 Segment(默认为 16,即并发度 16);1.8 的锁粒度是 Node(Hash 桶),并发度等于数组长度,大幅提升。
Q2: 为什么 ConcurrentHashMap 的 Key 和 Value 不允许为 null,而 HashMap 可以?
A: (参考下文深度解析,简述版)
- 这是为了避免二义性 (Ambiguity)。
- 在多线程环境下,如果你
get(key)返回null,你无法判断是“这个 key 根本不存在”还是“这个 key 对应的值就是 null”。- 在单线程 HashMap 中,你可以用
containsKey(key)再查一次来确认。但在多线程 CHM 中,两次调用之间 map 可能已经被其他线程改了,所以containsKey的结果不可信。为了安全,CHM 直接禁止 null。
Q3: ConcurrentHashMap 能保证复合操作的原子性吗?比如“如果不存在则插入”?
A: 不能保证完全的原子性,必须使用它提供的专用原子方法。
错误写法:
1
2
3
4 // 这种写法不是线程安全的!因为 get 和 put 是两个独立动作
if (map.get(key) == null) {
map.put(key, value);
}正确写法: 使用
putIfAbsent(key, value)。CHM 内部在源码层面对这个方法进行了整体加锁/CAS 处理,保证了“判断+写入”是一个原子操作。
3. 其他 Map 家族成员
3.1 Hashtable
- 状态: 只有考古学家在用。
- 特点: 全表一把
synchronized锁。效率极低。key/value 都不允许 null。
3.2 HashSet 保证不重复原理
本质:
HashSet底层完全就是一个HashMap。源码:
1
2
3
4
5
6
7
8// HashSet 源码
private static final Object PRESENT = new Object(); // 占位符
public boolean add(E e) {
// 调用 map.put,value 存一个固定的 Object (PRESENT)
// HashMap 的 put 方法:如果 key 存在,会返回旧值;如果不存在,返回 null
return map.put(e, PRESENT) == null;
}解释: 因为 HashMap 的 Key 是唯一的(依赖 hashCode 和 equals),所以 HashSet 的元素也就唯一了。
4. 思考题与深度解析
❓ 深度解析:为什么 ConcurrentHashMap 严禁 null 键值?
题目: 请从并发编程的设计角度,深入分析为什么 ConcurrentHashMap (CHM) 不支持 null key 和 null value,而 HashMap 支持?(500字以上)
答案解析:
这是一个非常经典的架构设计问题,不仅仅关乎代码实现,更关乎并发语义的不确定性。
1. “二义性” (Ambiguity) 问题是核心原因 在 Map 接口中,get(key) 方法如果返回 null,通常有两种含义:
- 含义 A: 该 key 从未存入过 Map(不存在)。
- 含义 B: 该 key 存在,但它对应的 value 被显式存为了
null。
2. 单线程环境的可解性 (HashMap) 在 HashMap(单线程环境)中,我们可以通过额外的 containsKey(key) 方法来消除这种二义性:
1 | if (map.get(key) == null) { |
因为是单线程,在执行 get 和 containsKey 之间,Map 的数据状态是稳定的,没有人会去修改它。
3. 多线程环境的不可解性 (ConcurrentHashMap) 在并发环境中,情况完全变了。假设 CHM 允许 value 为 null:
- 线程 T1 调用
map.get(key),返回了null。 - T1 此时无法确定是含义 A 还是 B。
- T1 试图调用
map.containsKey(key)来确认。 - 但在 T1 执行这两步操作的微小时间间隙里,线程 T2 可能横插一脚,删除了这个 key,或者刚刚把这个 key 放入了 Map。
- 因此,T1 得到的
containsKey的结果,已经无法印证刚才get的结果了。刚才的状态已经“过期”了。这就是并发中的竞态条件。
为了避免这种无法解决的逻辑悖论,Doug Lea(CHM 的作者)决定直接在源头切断——禁止 null。如果你拿到 null,那就只有一种可能:key 不存在。
4. 源码实现的容错性 此外,从源码实现角度看,CHM 在很多地方利用 value == null 来判断节点状态(例如判断节点是否被迁移、是否需要加锁等)。如果允许用户存 null,会极大地增加底层并发控制逻辑的复杂度,容易引入难以排查的 Bug。
5. 总结
- HashMap 设计用于单线程,可以容忍二义性,通过组合调用解决。
- ConcurrentHashMap 设计用于高并发,必须消除二义性,保证原子操作的语义清晰。
- 这也是大多数并发集合(如
ArrayBlockingQueue,CopyOnWriteArrayList)都不允许 null 的共同原因。这是一个为了线程安全和逻辑严谨性所做的设计取舍 (Trade-off)。





