Java并发容器源码剖析:ConcurrentHashMap与CopyOnWriteArrayList
一、并发容器概览与选型
1. JDK并发容器体系
graph TD
A[并发容器] --> B[Map体系]
A --> C[List体系]
A --> D[Queue体系]
B --> B1[ConcurrentHashMap]
B --> B2[ConcurrentSkipListMap]
C --> C1[CopyOnWriteArrayList]
C --> C2[ConcurrentLinkedQueue]
D --> D1[BlockingQueue]
D --> D2[TransferQueue]
2. 容器特性对比
容器类型 | 线程安全实现 | 读性能 | 写性能 | 适用场景 |
---|---|---|---|---|
ConcurrentHashMap |
CAS + synchronized分段锁 | O(1) | O(1) | 高频读写K-V存储 |
CopyOnWriteArrayList |
写时复制(ReentrantLock) | O(1) | O(n) | 读多写少(白名单/黑名单) |
Collections.synchronizedList |
方法级synchronized | O(1) | O(1) | 兼容旧代码(不推荐新用) |
二、ConcurrentHashMap深度解析
1. JDK7 vs JDK8实现对比
版本 | 数据结构 | 锁粒度 | 哈希冲突解决 | 并发度控制 |
---|---|---|---|---|
JDK7 | Segment数组+链表 | Segment级别锁 | 拉链法 | 构造时固定(不可扩容) |
JDK8 | Node数组+链表/红黑树 | 桶级别锁(头节点) | 链表转红黑树 | 动态扩容 |
2. JDK8核心源码剖析
2.1 putVal()关键流程
java
final V putVal(K key, V value, boolean onlyIfAbsent) {
// 1. 计算hash(扰动函数优化)
int hash = spread(key.hashCode());
int binCount = 0;
// 2. 自旋插入节点
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) {
// 3. CAS尝试插入新节点(无锁优化)
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value)))
break;
}
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f); // 协助扩容
else {
// 4. 锁住桶头节点处理冲突
synchronized (f) {
if (tabAt(tab, i) == f) {
if (fh >= 0) { // 链表处理
// ...遍历链表更新或插入
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, i); // 转红黑树
}
else if (f instanceof TreeBin) { // 红黑树处理
// ...调用树节点插入方法
}
}
}
}
}
// 5. 检查扩容
addCount(1L, binCount);
return null;
}
2.2 扩容机制(transfer())
- 多线程协同 :通过
sizeCtl
标记扩容状态,其他线程检测到后会协助迁移数据 - 桶迁移策略:每个线程负责一定范围的桶,迁移时按链表/树结构拆分
3. 性能优化技巧
- 避免长链表:确保key的hashCode()分布均匀
- 设置初始容量 :减少扩容次数,建议
预计元素数/负载因子 + 1
- 并行计算 :使用
forEach()
/reduce()
等并行方法
三、CopyOnWriteArrayList实现原理
1. 核心设计思想
java
public boolean add(E e) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
Object[] elements = getArray();
int len = elements.length;
// 1. 复制新数组
Object[] newElements = Arrays.copyOf(elements, len + 1);
// 2. 写入新元素
newElements[len] = e;
// 3. 替换引用
setArray(newElements);
return true;
} finally {
lock.unlock();
}
}
2. 读写分离实现
操作 | 实现机制 | 性能影响 |
---|---|---|
读 | 直接访问当前数组引用 | 无锁,极快 |
写 | 加锁→复制数组→修改→替换引用 | 复制开销大,阻塞其他写操作 |
3. 适用场景与限制
- 推荐场景 :
- 事件监听器列表(注册/注销少,触发多)
- 配置黑白名单(更新频率低)
- 规避问题 :
- 大数据量时写操作导致Young GC频繁
- 迭代器弱一致性(反映创建时的数组状态)
四、并发问题实战案例
1. ConcurrentHashMap复合操作陷阱
java
// 错误用法:非原子性判断+修改
if (!map.containsKey(key)) {
map.put(key, value); // 可能被其他线程打断
}
// 正确方案1:使用putIfAbsent
map.putIfAbsent(key, value);
// 正确方案2:compute原子方法
map.compute(key, (k, v) -> v == null ? value : v);
2. CopyOnWriteArrayList内存溢出案例
java
List<byte[]> list = new CopyOnWriteArrayList<>();
while (true) {
list.add(new byte[1024 * 1024]); // 每次复制1MB数组
// 快速触发OutOfMemoryError
}
解决方案 :对于大数据集,考虑使用ConcurrentLinkedQueue
或BlockingQueue
五、并发容器性能测试
1. 百万次操作耗时对比(单位:ms)
操作 | HashMap |
Hashtable |
ConcurrentHashMap |
Collections.synchronizedMap |
---|---|---|---|---|
100万次put | 120 | 450 | 180 | 400 |
100万次get | 80 | 220 | 100 | 210 |
100并发put+get混合 | 崩溃 | 3200 | 850 | 3000 |
2. 不同写比例下的QPS
java
# 测试命令:JMH基准测试
@Benchmark
@Threads(8)
public void testCopyOnWrite(Blackhole bh) {
list.get(0); // 读操作
if (writeRatio > 0 && rnd.nextDouble() < writeRatio) {
list.add("newItem"); // 写操作
}
}
结果:
- 读占比99%时:QPS 120,000
- 读占比90%时:QPS 23,000
- 读写各50%时:QPS 1,200
六、扩展容器选型
1. 其他高并发容器
容器 | 实现原理 | 推荐场景 |
---|---|---|
ConcurrentSkipListMap |
跳表(CAS) | 需要有序的并发Map |
ConcurrentLinkedQueue |
无锁队列(CAS) | 高吞吐的生产者-消费者模式 |
LinkedBlockingQueue |
双锁队列(put/take分离) | 有界阻塞队列(线程池任务队列) |
2. 第三方库推荐
- Caffeine:高性能本地缓存(替代Guava Cache)
- Disruptor:无锁环形队列(金融级低延迟)
- Agrona:零拷贝并发数据结构(航空级性能)
七、QA高频问题
💬 Q1:ConcurrentHashMap的size()为什么可能不精确?
✅ 答案:
- 采用分段计数(
baseCount
+CounterCell[]
) - 并发更新时优先尝试CAS修改
baseCount
,冲突时使用CounterCell
分流 - 最终结果是弱一致性的,但实际误差通常可忽略
💬 Q2:为什么COW迭代器不支持remove()?
✅ 设计原理:
- 迭代器持有的是旧数组的快照
- 删除操作会影响新数组,导致数据不一致
- 解决方案:使用
CopyOnWriteArraySet
或手动记录待删除元素
💬 Q3:JDK8的ConcurrentHashMap为什么不使用ReentrantLock?
✅ 性能考量:
synchronized
在JDK6后已大幅优化(偏向锁/轻量级锁)- 锁粒度细化到桶头节点后,竞争概率极低
- 减少内存开销(每个Node无需携带Lock对象)
最佳实践建议:
- 优先使用
ConcurrentHashMap
而非synchronizedMap
CopyOnWriteArrayList
适用于读多写少 且数据量小的场景- 警惕复合操作的线程安全问题(使用原子方法如
computeIfAbsent
) - 监控容器内存占用(尤其COW类容器)
通过
jmap -histo <pid>
可查看容器内存占用情况