08-Java并发容器源码剖析:ConcurrentHashMap与CopyOnWriteArrayList

13 阅读5分钟

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. 容器特性对比

容器类型线程安全实现读性能写性能适用场景
ConcurrentHashMapCAS + synchronized分段锁O(1)O(1)高频读写K-V存储
CopyOnWriteArrayList写时复制(ReentrantLock)O(1)O(n)读多写少(白名单/黑名单)
Collections.synchronizedList方法级synchronizedO(1)O(1)兼容旧代码(不推荐新用)

二、ConcurrentHashMap深度解析

1. JDK7 vs JDK8实现对比

版本数据结构锁粒度哈希冲突解决并发度控制
JDK7Segment数组+链表Segment级别锁拉链法构造时固定(不可扩容)
JDK8Node数组+链表/红黑树桶级别锁(头节点)链表转红黑树动态扩容

2. JDK8核心源码剖析

2.1 putVal()关键流程
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. 核心设计思想

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复合操作陷阱

// 错误用法:非原子性判断+修改
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内存溢出案例

List<byte[]> list = new CopyOnWriteArrayList<>();
while (true) {
    list.add(new byte[1024 * 1024]); // 每次复制1MB数组
    // 快速触发OutOfMemoryError
}

解决方案:对于大数据集,考虑使用ConcurrentLinkedQueueBlockingQueue

五、并发容器性能测试

1. 百万次操作耗时对比(单位:ms)

操作HashMapHashtableConcurrentHashMapCollections.synchronizedMap
100万次put120450180400
100万次get80220100210
100并发put+get混合崩溃32008503000

2. 不同写比例下的QPS

# 测试命令: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对象)

最佳实践建议

  1. 优先使用ConcurrentHashMap而非synchronizedMap
  2. CopyOnWriteArrayList适用于读多写少数据量小的场景
  3. 警惕复合操作的线程安全问题(使用原子方法如computeIfAbsent
  4. 监控容器内存占用(尤其COW类容器)

通过jmap -histo <pid>可查看容器内存占用情况