第 06 期:集合底层------HashMap、ConcurrentHashMap、CopyOnWriteArrayList
关键词:哈希散列、负载因子、树化阈值、弱一致迭代器、写时复制、CAS/桶锁、帮助迁移(help transfer)
1) 面试原题
- 解释 HashMap 的底层结构、扩容与树化机制。为什么有 负载因子 0.75?
- ConcurrentHashMap 如何在不使用全局锁的情况下实现并发安全?为何不允许
null
键/值? - CopyOnWriteArrayList 是如何做到读无锁的?适用于哪些场景,代价是什么?
- 如果让你在读多写少 与写多读多两种场景做集合选型,你如何权衡?
2) 第一性拆解(约束 → 成本 → 原语 → 结论)
约束(Constraints)
- 集合需要在 时间(查找/插入) 与 空间(内存占用/碎片) 之间权衡。
- 并发场景下必须避免 数据竞争 与 长时间阻塞 ,同时保持 可伸缩性。
- 大多数键具有良好的分布,但哈希碰撞总会发生;热点桶需要退化处理。
成本模型(Cost Model)
- 哈希表 :平均 O(1) 查找,碰撞时链表 O(k) 或树 O(log k);扩容涉及 重新散列 的整体成本。
- 锁/屏障:互斥带来上下文切换与队列等待;CAS 在高冲突下自旋浪费 CPU。
- 写时复制(COW) :每次写入都会 复制整块数组,读几乎零开销,但写入成本与内存瞬时占用都很高。
最小原语(Primitives)
- 数组 + 链表/红黑树 (HashMap 桶结构)、扰动函数 (spread)、负载因子 与 阈值。
- CAS + 桶级锁 (ConcurrentHashMap,JDK 8+)、sizeCtl /ForwardingNode /help transfer(并发扩容)。
- ReentrantLock + 数组复制 (CopyOnWriteArrayList 的写时复制),弱一致迭代器(snapshot)。
可验证结论(Check)
- 通过构造高碰撞键观察 HashMap 的 链表→树化 与 扩容行为。
- 在并发写读下对比 CHM 与
Collections.synchronizedMap(new HashMap<>())
的吞吐差异。 - 在读多写少场景验证 COWAL 的迭代性能与写入成本。
3) 总览:三者的核心取舍
- HashMap :单线程高性能,允许
null
,迭代 fail-fast;并发不安全。 - ConcurrentHashMap :并发安全、弱一致迭代 、不允许
null
;在高并发下优于全局锁方案。 - CopyOnWriteArrayList :读无锁、迭代基于快照;写入开销大 ,适用于读远多于写的配置类/监听器列表。
4) HashMap 底层设计(JDK 8+)
4.1 结构与散列
- 底层为
Node<K,V>[] table
,桶(bin)冲突时使用 链表 ,当桶内结点数超过 树化阈值(默认 8) 且容量达到 最小树化容量(默认 64) 时转为 红黑树 ;当树结点数低于 反树化阈值(默认 6) 时退回链表。 - 扰动函数 :
(hash = key.hashCode()) ^ (hash >>> 16)
,减少高位信息丢失导致的桶集中。 - 负载因子 :默认
0.75
,在空间与查找冲突率之间折中;阈值threshold = capacity * loadFactor
触发扩容。
4.2 插入/查找/扩容
- 插入:定位桶 → 桶空则放入 → 冲突则链表遍历或树插入;当链表长度超过阈值,且容量≥64,树化。
- 查找:定位桶 → 链表/树内查找;红黑树保证近似 O(log k)。
- 扩容:容量翻倍,元素按新掩码分布到 原位或原位+旧容量 桶(因为 2 的幂容量,散列重分布可优化为"低位不变/高位位翻转")。
- 允许
null
键与值(null
键固定落在桶 0)。
4.3 迭代与并发风险
- 迭代器为 fail-fast :并发结构性修改(非迭代器自身的
remove
)会抛ConcurrentModificationException
。 - 并发下使用 HashMap 可能导致数据丢失或在旧版本出现链表环(JDK 7 的 rehash 竞争风险),因此不要在并发下使用。
4.4 常见坑
equals/hashCode
契约:不符合将导致查找失败或桶分布异常。- 可变键 :将对象作键但其参与
equals/hashCode
的字段发生改变,会导致"找不回去"。 - 高碰撞攻击:不可信输入(例如键都落在同一哈希值)会导致退化到 O(n),需防御(限流或改用树化 + 验证)。
5) ConcurrentHashMap(JDK 8+)工作原理
5.1 设计演进
- JDK 7 使用 Segment 分段锁;JDK 8 改为 Node[] + 桶级锁/ CAS + 并发扩容,减少锁粒度并提升伸缩性。
5.2 写入与桶级锁
- 初次插入:
CAS
初始化table
;桶为空时使用CAS
放置首节点;桶非空时对桶头节点(或TreeBin
)进行synchronized
桶级锁 以串行化更新。 - 计算类方法(如
compute/computeIfAbsent/merge
)在桶级锁保护下执行,避免 ABA 与不变量破坏。
5.3 并发扩容(transfer)
- 扩容由
sizeCtl
控制并发度;出现ForwardingNode
表示该桶正在迁移,其他线程会 help transfer。 - 重分布遵循 HashMap 的"原位/原位+旧容量"规则,迁移过程中读操作通过 转发节点 寻址到新表,保证弱一致。
5.4 读取与迭代
- 读取无锁(除非遇到正在迁移的桶需跟随指针),迭代器为 弱一致 :能看到一部分新写入,但不抛
ConcurrentModificationException
。 - 不允许
null
键/值:避免歧义(无法区分"键不存在"与"键存在但值为 null"),也是并发计算方法的前提。
5.5 大小与统计
size()
可能是近似值(并发下),JDK 8+ 进行了分布式计数;若需要准确性,遍历或使用mappingCount()
(JDK 8 没有该方法,JDK 11+ 提供mappingCount()
返回 long)。
5.6 常见坑与建议
- 在 高冲突键 下,
computeIfAbsent
可能成为热点;可引入 分区(striping) 或 分层缓存。 forEach
等批处理 API 在大表上可能与迁移竞争,注意任务切分与批量大小。
6) CopyOnWriteArrayList(COWAL)机制与取舍
6.1 读路径
- 读操作直接基于 快照数组 ,无锁 ,迭代器遍历的是 创建迭代器那一刻的数组副本 ,因此遍历期间的修改 不可见且不抛异常。
6.2 写路径
add/remove/set
等写操作会在 独占锁(ReentrantLock) 下复制出 新数组 ,修改后 原子地替换 引用。- 代价:写入是 O(n) + 复制内存;频繁写入会导致 大量短期垃圾 与 瞬时内存峰值。
6.3 适用场景
- 读远多于写:如监听器列表、全局白名单/黑名单、配置快照。
- 迭代需要 稳定视图,并且能够容忍写入成本。
6.4 常见坑
- 大列表或频繁写入下,COWAL 会产生显著 GC 压力与延迟抖动;应改用
CopyOnWriteArrayList
之外的结构(如ConcurrentHashMap
+ 有序视图,或ImmutableList
+ 引用切换)。
7) 代码示例(可运行/可改造)
7.1 HashMap:高碰撞与树化观察
java
import java.util.*;
public class HashMapCollisionDemo {
static class BadKey {
final int id;
BadKey(int id) { this.id = id; }
@Override public int hashCode() { return 42; } // 故意制造高碰撞
@Override public boolean equals(Object o) {
return (o instanceof BadKey) && ((BadKey) o).id == id;
}
}
public static void main(String[] args) {
Map<BadKey, Integer> m = new HashMap<>();
for (int i = 0; i < 100; i++) m.put(new BadKey(i), i);
System.out.println("size=" + m.size());
// 可在调试器中观察桶长度变化与是否树化(需要自行打印内部结构或借助 JFR/可视化)
}
}
7.2 并发:CHM vs 全局锁 HashMap
java
import java.util.*;
import java.util.concurrent.*;
public class MapThroughputCompare {
static final int THREADS = 32, OPS = 200_000;
public static void main(String[] args) throws Exception {
compare(new ConcurrentHashMap<>());
compare(Collections.synchronizedMap(new HashMap<>()));
}
static void compare(Map<Integer, Integer> map) throws Exception {
ExecutorService es = Executors.newFixedThreadPool(THREADS);
long t0 = System.nanoTime();
for (int i = 0; i < THREADS; i++) {
es.submit(() -> {
ThreadLocalRandom rnd = ThreadLocalRandom.current();
for (int j = 0; j < OPS; j++) {
int k = rnd.nextInt(100_000);
map.put(k, k);
map.get(k);
}
});
}
es.shutdown(); es.awaitTermination(1, TimeUnit.MINUTES);
long t1 = System.nanoTime();
System.out.printf("%s took %.2f ms, size=%d%n", map.getClass().getSimpleName(), (t1 - t0) / 1e6, map.size());
}
}
7.3 COWAL:读多写少与迭代快照
java
import java.util.*;
import java.util.concurrent.*;
public class CowalDemo {
public static void main(String[] args) throws Exception {
CopyOnWriteArrayList<Integer> list = new CopyOnWriteArrayList<>();
for (int i = 0; i < 10; i++) list.add(i);
// 迭代快照:下面的写入对本次迭代不可见
for (Integer v : list) {
if (v == 5) list.add(999); // 写时复制,迭代仍遍历旧数组,不抛异常
System.out.print(v + " ");
}
System.out.println("\nlist size after add=" + list.size());
}
}
8) 选型与实践建议
- 读多写少 :优先
CopyOnWriteArrayList
(列表场景)或ConcurrentHashMap
+ 定期生成不可变快照(Map 场景)。 - 写多读多 :
ConcurrentHashMap
,并根据热点键/值进行 分区(striping) 、分层缓存 或 批量操作。 - 有序需求 :
LinkedHashMap
(单线程)或ConcurrentSkipListMap
(并发,按键有序)。 - 迭代稳定性 :需要强一致遍历用快照或不可变视图;接受弱一致可选 CHM 迭代。
- 大对象/值复制 :避免 COWAL,用共享结构(如
List<WeakReference<T>>
或分段结构)降低复制成本。
9) 常见面试追问(准备好这几句)
- HashMap 为什么在 8 个结点树化、6 个反树化、最小树化容量 64?
- 平衡常见负载下的桶长度与树维护成本;低容量下宁可链表避免红黑树的额外指针与旋转开销。
- CHM 为什么不允许
null
?- 并发语义下需区分"键不存在"与"键存在但值为 null",否则
computeIfAbsent
等 API 将出现歧义。
- 并发语义下需区分"键不存在"与"键存在但值为 null",否则
- COWAL 为什么读无锁?
- 迭代器持有的是数组快照引用,在写入复制时不会影响已有快照;因此读路径无需锁。
10) 速答卡(30 秒)
- HashMap:数组 + 链表/红黑树,负载因子 0.75,扩容翻倍 + 原位/原位+旧容量;并发不安全,迭代 fail-fast。
- ConcurrentHashMap :桶级锁 + CAS + 并发扩容(ForwardingNode/sizeCtl/help transfer),弱一致迭代,不允许
null
。 - CopyOnWriteArrayList:读基于快照无锁,写入复制整数组,适合读多写少;写入成本与内存峰值高。
11) 作业(可验证、可评分)
- 构造 1e5 个高碰撞键,记录 HashMap 在不同容量下的桶最大长度与树化比例,解释观察到的拐点。
- 用上面的并发基准,对比 CHM 与全局锁 HashMap 的吞吐,调整
THREADS/OPS
,写结论。 - 在 COWAL 上进行 1e5 次
add/remove
写入,记录 GC 日志与暂停,评估是否满足你的 SLO;给出替代方案。 - 复述"约束→成本→原语→结论"选择列表/Map 的方法论(200--300 字)。
12) 工程化清单
- 对 键的
equals/hashCode
写单元测试;避免可变键。 - 将 集合选择(HashMap/LinkedHashMap/ConcurrentHashMap/COWAL/SkipListMap)写进编码规约与模板。
- 对读多写少的数据结构引入 快照发布 (不可变视图)与 定时刷新,避免误用 COWAL。
- 在并发热点路径加入 可观测性(命中率、碰撞率、桶长度分布、扩容次数)。
注:示例基于 JDK 8+ 常见实现与行为。不同 JDK 版本可能在细节上略有差异,但整体取舍与语义一致。