设计核心:从分段锁到桶级别同步
JDK8中,ConcurrentHashMap摒弃了Segment分段锁,改为对每个桶独立加锁。当插入键值对时,如果对应桶为空,直接通过CAS操作无锁写入,避免上下文切换。如果桶非空,则对桶的头节点使用synchronized加锁。这种细粒度锁策略在大多数场景下能显著降低锁竞争,只在写操作真正冲突时才加锁,而读操作始终不需要加锁,从而大幅提高并发吞吐量。开发者应关注hash冲突严重时的锁粒度,但通常无需额外配置。
适用场景上,如果你的应用以读操作为主,写请求分布均匀,这个设计能天然发挥CAS+局部锁的优势。而若某些key的hash冲突极高(例如大量对象hashCode相同),会导致多个写入竞争同一个桶的锁,此时并发性能可能下降。检查手段是通过jstack或Async-profiler观察线程阻塞栈,如果大量线程停留在synchronized (ConcurrentHashMap的Node)上,说明hash分布需要优化——可以考虑重写key的hashCode方法或增加初始容量减少冲突。操作上,创建Map时可指定initialCapacity和loadFactor,避免频繁扩容导致的锁代价。
扩容过程如何不丢数据
扩容过程采用多线程协助机制。当某个线程发现需要扩容时,会先初始化一个两倍大小的新数组,然后通过‘transfer’方法将旧桶数据迁移到新桶。迁移时,每个线程负责一部分桶(由步长控制),通过CAS更新迁移进度。其他线程在后续操作中检测到扩容正在进行,会主动加入协助,直到所有桶迁移完毕。这个过程确保每个桶在同一时刻只有一个线程操作,且借助forwadingNode标记已迁移的位置,避免数据丢失或重复。
这个机制的验证方式比较简单:在低并发下你可以写一个测试,循环put并观察CPU和内存,通常扩容不会造成明显停顿。但需要注意,扩容期间所有写操作(put/remove/replace)都会先协助迁移相关桶,然后再写入新数组,因此如果扩容时写并发高,整体响应时间可能会阶段性地变长。可以通过在监控中观察GC日志(扩容会创建新数组)和线程dump确认是否存在大量线程参与transfer。风险边界在于,如果旧数组特别大(比如上千万桶),迁移过程中线程需要遍历桶链表,此时若其他线程频繁触发写操作,每个写操作都会被迫检查并协助迁移,可能导致CPU飙升。保守建议是:对于大尺寸Map,可以在初始化时指定合理容量以减少扩容次数,或者提前预判数据量。
计数与迭代的弱一致性
size()方法需要统计所有键值对数量。在JDK8中,它并非实时精确值,而是一个近似值。内部维护一个baseCount变量,通过CAS更新;同时如果并发竞争激烈,会使用CounterCell数组分散计数,每个线程更新自己的CounterCell。最终统计时累加baseCount和所有CounterCell的值。这种方法避免了全局锁,但可能在极端并发下统计延迟。开发者如果要求严格一致的计数,应依赖其他同步机制,但ConcurrentHashMap的设计本意就是弱一致性。
对应的,迭代器也是弱一致性的——它基于迭代器创建时刻的快照遍历,但不保证反映后续修改。如果你在遍历过程中其他线程插入或删除了元素,迭代器不会抛出ConcurrentModificationException,且可能看到部分或全部新数据。这种设计避免了快照复制带来的性能开销,但在需要精确快照的业务场景下(如统计所有元素后做批处理),建议在遍历前先获取size近似值做判断,或者使用Collections.synchronizedMap加外部锁。检查点:如果业务代码依赖迭代器的实时性且出现数据遗漏,可以改用entrySet().toArray()复制一份,但需评估内存开销。
原子替换与常见误用
ConcurrentHashMap提供了replace(K key, V oldValue, V newValue)和putIfAbsent(K key, V value)等原子方法。这些方法内部通过CAS或synchronized确保复合操作的原子性,无需外部加锁。例如,putIfAbsent仅在key不存在时才插入,实现‘没有则写入’的安全逻辑。使用这些方法可避免常见的竞态条件,如在更新缓存时先检查后插入导致的数据覆盖。常见坑是误用put而非putIfAbsent导致覆盖已有值,或在需要比较后再替换时不用replace方法而自行组合导致安全漏洞。
在实际排查中,经常看到这样的代码:if (!map.containsKey(key)) { map.put(key, value); }。这个检查-然后-动作的组合并非原子,两个线程可能同时通过检查并先后put,造成覆盖。正确的做法是直接使用putIfAbsent,或者需要替换旧值时使用replace。另外,如果你需要批量原子更新多个key,单靠ConcurrentHashMap的单个方法是不够的,需要外部锁或分段控制。验证方法很简单:编写多线程测试,并用AtomicLong统计被覆盖的次数,通常使用putIfAbsent后覆盖计数为零。
并发读写互斥边界
当多个线程同时进行扩容和数据读写时,存在特定风险边界。处于迁移中的桶会被标记为forwardingNode,读操作遇到forwardingNode会跳转到新数组继续查找,写操作(如put)则会在辅助扩容后在新数组上进行。这个机制保证了迁移过程中读写的连贯性,但若写操作在迁移前已拿到旧桶的锁,而迁移线程尚未处理该桶,写操作依然在旧桶上执行,之后迁移线程会将旧桶数据整体迁移,这可能导致写操作的结果被覆盖?实际上不会:写操作在旧桶上完成后,迁移线程在迁移前会重新检查该桶的引用,若已有新数据则跳过,或通过CAS保证一致性。理解这个边界有助于诊断偶发的数据不一致问题,但在合理使用下很少出现。
那么这个边界在实际中如何观察?如果怀疑数据丢失,可以先检查代码中是否混合使用了普通put和putIfAbsent——后者在迁移过程中可能会因桶标记为forwardingNode而重复插入。但JDK8源码保证了每种写操作的路径都能正确处理forwardingNode。保守建议是不要自己实现迁移逻辑,完全依赖ConcurrentHashMap内置机制;如果确实需要跨桶原子操作,考虑使用外部锁。