为什么不能并发用 HashMap
两个血案。JDK 7:扩容时并发操作,链表头插法把节点指成了环,之后 get 这个桶就是CPU 100% 的死循环(面试题常客);JDK 8 换成尾插修掉了成环问题,但并发 put 依然会丢数据——两个线程同时写同一个空桶,一个覆盖另一个。补救办法里,Hashtable 全表一把锁太笨重,Collections.synchronizedMap 同样全表锁;ConcurrentHashMap 才是正解,而且它的锁粒度进化史,本身就是一部并发优化教材。
JDK 7:分段锁,16 段的折中
JDK 7 把整个 map 切成 16 个 Segment(小 HashMap),每个段自己一把锁:写不同的段互不干扰,并发度 16。比全表锁强多了,但粒度依然粗——两个 key 落在不同桶、同一分段,也得排队。16 是默认并发度,可配但要初始化时定死,改起来麻烦。
JDK 8:把锁做到单个桶
JDK 8 推倒重来:数组 + 链表 + 红黑树的结构(树化阈值 8、退化阈值 6),锁粒度直接砍到单个桶。put 的判定链:
1. 桶是空的 → CAS 放入新节点,无锁成功 // 大多数场景到这就结束
2. 桶头是 ForwardingNode → 正在扩容,先帮忙搬数据
3. 桶上有节点 → synchronized 锁住桶头,链表/红黑树里插入真正的写冲突(同一桶)才加锁,且锁的是桶头对象——并发度约等于数组长度,而不是写死的 16。至于为什么用 synchronized 不用 ReentrantLock:锁的是单个桶头、冲突概率低,偏向/轻量级锁的路径就够用(第 4 篇的锁升级在这兑现价值),而且 synchronized 不占对象额外的内存头。
size 统计:LongAdder 的亲兄弟
拿 CAS 维护一个全局 size,在高并发下就是 CAS 失败风暴。CHM 的解法与第 8 篇的 LongAdder 同宗:base 加 CounterCell 数组,冲突时分摊到各自的 Cell。同一个思路在两个容器里各长了一遍——并发优化的招式是通用的。
迭代是弱一致的
HashMap 迭代途中被改,直接抛 ConcurrentModificationException;CHM 的迭代器是弱一致的:不抛异常,迭代开始后新写入的数据可能看到也可能看不到。所以别指望「边遍历边修改」有精确语义——需要「取出并删除」这类原子组合操作,用 putIfAbsent、computeIfAbsent、remove(key, value) 这些方法,而不是先 get 再 put 两步走。
下一篇补全并发容器拼图:CopyOnWriteArrayList 的写时复制,以及 BlockingQueue 家族——生产者消费者的现代标准答案。
评论 (0)