尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

并发容器全家桶:ConcurrentHashMap / CopyOnWriteArrayList 源码级对比

并发容器全家桶:ConcurrentHashMap / CopyOnWriteArrayList 源码级对比 「Java 进阶之路」系列 Day08写在前面前七篇讲的都是锁、CAS 这些底层机制这篇开始进入更贴近日常业务代码的话题——并发容器。写业务代码时很少会自己手写synchronized/Lock去保护一个HashMap绝大多数场景直接用java.util.concurrent包里现成的线程安全容器就够了。这篇重点拆两个最常被问到的ConcurrentHashMap和CopyOnWriteArrayList顺带把它们各自的底层原理讲清楚而不是停留在能用就行的层面。一、是什么为什么不能直接把 HashMap 扔进多线程环境先看三者的对比HashMapHashtableConcurrentHashMap线程安全不安全安全全表加锁安全桶级别加锁并发度不适用1完全串行高默认16且JDK8之后更细null key value允许不允许不允许适用场景单线程基本被淘汰多线程首选HashMap本身没有任何同步措施多线程并发put可能导致链表成环、数据丢失是实打实的 bug 来源Hashtable虽然线程安全但靠的是给整张表加一把锁同一时刻只能有一个线程操作并发度约等于单线程ConcurrentHashMap则是既要线程安全又要尽量高并发这个目标下的产物。二、ConcurrentHashMap从 JDK7 到 JDK8 的进化实现方式的重大变化JDK7JDK8及之后数据结构Segment数组加HashEntry链表Node数组加链表或红黑树锁粒度Segment默认16个单个桶即Node头节点加锁方式ReentrantLocksynchronized加CAS并发度固定等于Segment数量动态最高等于桶的数量JDK7 的思路是分段加锁——把整个表切成 16 个Segment每个Segment各自加锁理论并发度就是 16。JDK8 把锁粒度进一步细化到每一个桶数组里的每个位置桶的数量通常远大于 16并发度也就跟着上去了。JDK8 核心机制桶的四种状态数组Node数组桶为空桶有链表 长度小于8桶有红黑树 长度大于等于8桶是ForwardingNodeCAS无锁插入synchronized锁头节点做链表操作synchronized锁头节点做树操作正在扩容 其他线程会协助迁移put 的完整流程1 计算hash 定位到具体的桶 2 桶为空 直接CAS插入 成功就返回 不用加锁 3 桶是ForwardingNode 说明正在扩容 调用helpTransfer协助扩容后重试 4 桶里已经有节点 synchronized锁住桶的头节点 链表就遍历更新或者尾插 红黑树就走树插入 5 如果链表长度到达8并且数组长度到达64 触发树化 6 元素数量加1 检查是否需要整体扩容这里最值得记住的一点JDK8 的ConcurrentHashMap在桶为空时完全靠 CAS 无锁插入只有桶里已经有数据、发生哈希冲突时才需要synchronized锁住这一个桶的头节点——绝大多数插入操作走的都是无锁路径这也是它并发度能做到接近桶数量的原因。扩容多个线程一起干活扩容时ConcurrentHashMap会把旧数组按区段分给多个线程各自认领一段数据去搬迁谁先来谁先干不是单线程慢慢等。迁移完的桶会放一个ForwardingNode作为标记其他线程put时如果碰到这个标记就知道这里正在扩容会主动加入协助迁移而不是傻等。size() 是怎么统计的高并发下如果size()用一把全局锁去统计会成为新的性能瓶颈。JDK8 的做法和 Day07 讲的LongAdder思路一致维护一个baseCount加一个CounterCell数组分散计数减少多线程写同一个计数器时的竞争。最容易踩的坑复合操作不是原子的// 错误 两步操作之间可能被别的线程插队if(!map.containsKey(key)){map.put(key,value);}// 正确 用原子的复合方法map.putIfAbsent(key,value);map.computeIfAbsent(key,k-newArrayList());map.merge(key,1,Integer::sum);ConcurrentHashMap只保证单个方法调用内部是线程安全的但先检查再操作这种由两个方法拼起来的复合逻辑中间是有窗口期的——containsKey返回 false 之后、put真正执行之前别的线程完全可能已经把这个 key 塞进去了。要保证复合逻辑的原子性必须用putIfAbsent/computeIfAbsent/merge这类内置的原子复合方法。三、CopyOnWriteArrayList用写时复制换读无锁核心思想读操作完全不加锁直接读当前的数组引用写操作时先复制一份完整的底层数组在副本上修改改完再用新数组替换旧的引用原数组在替换之前始终保持不变。读 任何时刻 直接读array引用 无锁 写 add remove set等操作 1 加ReentrantLock锁 2 复制当前array为一个新数组 长度加1 3 在新数组上做修改 4 把引用指向新数组 5 解锁适用场景读极多写极少才划算场景是否合适读多写极少 比如事件监听器列表非常合适读写频率相近不合适 每次写都要复制整个数组数据量很大不合适 复制的内存开销太高需要强一致性读不合适 读到的是快照弱一致性迭代器拿到的是快照CopyOnWriteArrayListIntegerlistnewCopyOnWriteArrayList();list.add(1);IteratorIntegeritlist.iterator();// 拿到的是当前数组的快照list.add(2);// 新元素只加到了新的副本数组里it.next();// 这个迭代器依然只能看到1 看不到2这是CopyOnWriteArrayList和普通加锁方案最大的行为差异——它的迭代器创建那一刻起就固定绑定了当时的数组快照之后的写操作只会替换底层引用不会影响已经创建好的迭代器所以遍历过程中不会抛ConcurrentModificationException但也看不到遍历开始之后的新增元素。和 synchronizedList 的对比CopyOnWriteArrayListsynchronizedList读无锁需要加锁写复制数组加锁加锁迭代不需要额外加锁 拿到的是快照需要手动加锁遍历一致性弱 读到的是快照强四、全家桶里的其他成员除了这两个重点java.util.concurrent里还有基于 CAS 无锁实现的ConcurrentLinkedQueue适合不需要阻塞等待的高并发队列场景这篇先不展开。下一篇会专门讲ThreadLocal内存泄漏原理是面试超高频考点和BlockingQueue家族ArrayBlockingQueue/LinkedBlockingQueue怎么选把并发容器这块彻底讲完。五、面试追问Q1ConcurrentHashMap 在 JDK7 和 JDK8 里的实现有什么本质区别JDK7 用 Segment 分段加锁把整个表切成默认16个段每段用 ReentrantLock 独立加锁并发度固定等于段数JDK8 把锁粒度细化到每一个桶数组的每个位置底层结构也从数组加链表升级为数组加链表或红黑树加锁方式换成 synchronized 加 CAS并发度不再固定理论上能到桶的数量通常远高于JDK7的16。Q2ConcurrentHashMap 的 put 操作在什么情况下不需要加锁当计算出的目标桶当前是空的时候直接用 CAS 无锁插入即可成功不需要 synchronized。只有当目标桶已经存在节点、发生哈希冲突时才需要 synchronized 锁住这个桶的头节点去做链表或红黑树的插入操作。这也是JDK8并发度大幅提升的关键原因。Q3为什么 containsKey 加 put 这种写法在 ConcurrentHashMap 上不安全因为 ConcurrentHashMap 只保证单个方法调用内部的原子性不保证多个方法调用组合在一起的原子性。containsKey 返回 false 到 put 真正执行之间存在时间窗口这段时间里别的线程完全可能已经把同一个 key 插入进去导致两个线程都以为自己是第一个插入者。要保证这种先检查后操作逻辑的原子性必须用 putIfAbsent、computeIfAbsent 或 merge 这些内置的原子复合方法。Q4CopyOnWriteArrayList 为什么适合读多写少的场景不适合写多的场景它的读操作完全无锁、直接读当前数组性能很好但每次写操作都要复制一份完整的底层数组、在副本上修改、再替换引用写的开销和数组长度成正比。如果写操作频繁或者数据量很大每次都要复制整个数组内存分配和拷贝的开销会变得很高这时候用它反而比普通加锁方案更慢所以只适合读远多于写、且数据量不大的场景比如事件监听器列表。Q5CopyOnWriteArrayList 的迭代器为什么不会抛 ConcurrentModificationException因为它的迭代器在创建的那一刻就绑定了当时底层数组的一个快照引用遍历过程中即使有其他线程执行了写操作也只是创建了一个新的数组副本并替换了 CopyOnWriteArrayList 内部的引用完全不会影响已经创建好、绑定着旧数组的迭代器所以遍历时不存在结构被修改的冲突自然不会抛这个异常。代价是迭代器看到的是遍历开始那一刻的旧数据看不到遍历过程中新增的内容这就是它的弱一致性。下一篇预告Day09 讲ThreadLocal的内存泄漏原理以及BlockingQueue家族里ArrayBlockingQueue和LinkedBlockingQueue的实现差异怎么根据场景选阻塞队列。
返回列表