并发编程必备:ConcurrentHashMap核心机制深度剖析
HashMap在并发场景下是个定时炸弹。多个线程同时修改,可能导致数据丢失、链表死循环,甚至让CPU占用率飙升到100%。这是所有Java工程师迟早要踩的坑。
从HashMap到ConcurrentHashMap的演进之路
早期方案是Collections.synchronizedMap()包装器。它给整个Map对象加synchronized锁,所有操作必须排队执行。线程安全是保证了,但性能代价太大——读操作也要排队,高并发场景根本扛不住。
JDK1.7时代,ConcurrentHashMap引入Segment分段锁机制。每个Segment独立加锁,读操作可以并发,只有写入相同Segment时才冲突。这是一次重大突破。
JDK1.8则更激进——彻底抛弃Segment,用CAS+synchronized实现更细粒度的锁控制。锁的粒度从段级降到桶级,并发度再次大幅提升。
底层数据结构:三种节点各司其职
普通Node节点是基本存储单元。关键在于val和next字段都加了volatile修饰,这保证了修改对其他线程立即可见。不像HashMap需要额外同步,ConcurrentHashMap靠volatile就实现了可见性。
TreeNode继承Node,用于红黑树结构。当链表长度超过8时自动转树,避免最坏情况下O(n)的查找退化成O(logn)。每个树节点还维护了parent、left、right、prev指针,prev用于删除时快速断开链接。
ForwardingNode是扩容期间的临时标识。它指向新数组,并提供find方法引导查询穿透到新表。这种设计让读写操作在扩容期间仍能正常进行。
sizeCtl:控制并发状态的核心变量
这个变量的取值含义极为丰富。-1表示正在初始化,-(1+n)表示正在扩容,高16位存扩容戳记,低16位记录参与扩容的线程数减1。理解这个字段,是看懂扩容机制的前提。
零或正数时含义不同:0使用默认容量16,正数在初始化前表示目标容量,初始化后变成扩容阈值。这种一数多用的设计减少了变量数量,也增加了理解门槛。
putVal流程:五步走战略
第一步检测table是否为空,懒加载模式下首次put才初始化数组。第二步用CAS尝试空桶插入,成功则直接返回。第三步检测到ForwardingNode说明正在扩容,当前线程协助扩容后再重试。
第四步是真正的同步区域——synchronized锁住桶头,在链表或红黑树中查找或插入。这一步保证了对同一桶的写入操作串行化,避免数据竞争。第五步在插入后检查是否达到树化阈值,必要时触发转换。
元素计数采用LongAdder类似的分段思想,避免对baseCount的频繁CAS竞争。
initTable初始化:自旋+CAS的经典模式
多个线程同时创建Map时,只有一个能抢到初始化权。其他线程发现sizeCtl为负,调用Thread.yield()让出CPU,避免空转浪费资源。成功抢到锁的线程完成初始化后恢复sizeCtl为正数,其他线程唤醒后看到非空table直接使用。
双重检查机制必不可少——可能在等待期间另一个线程已经完成了初始化。
get操作:无锁读的实现哲学
get是真正无锁的操作。volatile保证的可见性让读线程总能拿到最新值。定位桶后遍历链表或搜索红黑树,如果遇到ForwardingNode就转向新表查找。这种设计让读性能达到最优,完全没有锁的开销。
工程实践:如何正确使用ConcurrentHashMap
不要先检查再操作——这不是原子语义。正确做法是直接put,由putVal覆盖旧值,或使用putIfAbsent保证原子性。批量操作compute、merge提供原子化的复合语义,比手动检查再更新更安全高效。
迭代器是弱一致性的,遍历期间可能看到部分修改。这是设计权衡——强一致性迭代代价太大,通常应用场景下弱一致迭代器足够使用。
