先说结论
JDK 8 的 ConcurrentHashMap 使用数组、链表和红黑树存储数据。读操作主要依靠
volatile可见性无锁完成;写入空桶时使用 CAS,桶冲突时锁定桶头节点;扩容时多个线程可以协助迁移。
JDK 7 使用 Segment 分段锁,JDK 8 取消固定分段,降低冲突粒度并提升扩容协作能力。
关键机制
桶数组和节点链接中的关键字段具备可见性保证。扩容期间旧桶会放置转移标记,其他线程遇到后可参与迁移或去新表查找。树化还要求数组达到一定容量,避免小表过早转成红黑树。
JDK 8 的 put 流程
一次写入可以按以下路径理解:
- 表未初始化时,通过 CAS 完成初始化,避免多个线程重复创建数组。
- 目标桶为空时,CAS 直接放入新节点,不获取桶锁。
- 遇到
MOVED转移节点时,当前线程协助扩容。 - 桶内已有数据时,对桶头节点加
synchronized,再次确认桶没有变化后遍历链表或红黑树。 - 找到相同键则替换值,否则追加节点;达到阈值后可能树化。
- 更新分散计数,必要时触发或协助扩容。
锁住桶头而不是整个表,使不同桶上的写操作可以并行。加锁后必须再次校验当前桶头仍是刚才观察到的节点,否则扩容或其他写线程可能已经改变结构。
为什么读操作通常不用锁
get 先根据扩散后的 hash 定位桶,再读取链表或树节点。数组引用、节点值和链接中的关键字段通过 volatile 或安全发布建立可见性,使读线程能看到完整节点结构。
无锁读不等于读取一张全局快照。并发更新两个键时,读线程可能看到一个新值和一个旧值;如果业务要求两个键一起变化,应该把状态放入同一不可变对象原子替换,或使用外部锁和事务。
扩容为什么可以多线程协作
容量不足时,线程会申请一段桶区间进行迁移,处理完再领取其他区间。旧桶完成迁移后放置 ForwardingNode,后续访问可以找到新表,也能帮助未完成的迁移。
迁移时新容量通常是旧容量的两倍,节点要么留在原索引,要么移动到 oldIndex + oldCapacity,可以根据 hash 的一位快速拆分,无需重新计算完整位置。
典型业务写法
ConcurrentHashMap<String, LongAdder> counters = new ConcurrentHashMap<>();
void record(String api) {
counters.computeIfAbsent(api, key -> new LongAdder()).increment();
}
这个模式保证计数器只按键原子安装,并用 LongAdder 降低热点自增竞争。但 computeIfAbsent 的映射函数可能在竞争下被调用或失败后重试,因此函数应无外部副作用。
容量与哈希攻击
初始容量过小会带来多次扩容,过大则浪费数组空间。预计元素数还要结合负载因子换算数组容量。恶意或质量差的 hashCode 会让元素聚集在少数桶,即使树化后复杂度改善,热点桶锁竞争仍然存在。
正确使用
get 后再 put 不是原子操作,应使用 putIfAbsent、compute、merge 等复合 API。compute 内的函数应短小,不能执行慢 I/O 或递归修改同一个键。
容易踩坑的地方
ConcurrentHashMap 不允许 null 键和值,因为并发读取时无法区分“键不存在”和“值就是 null”。遍历是弱一致的,不会锁住全表,也不保证看到遍历期间的全部变化。
常见问题
追问:size 在并发下准确吗?
它通过分散计数并汇总来降低竞争,返回的是调用时刻附近的统计值;若业务要求与其他状态形成严格一致快照,需要额外同步。
追问:为什么 JDK 8 又使用 synchronized?
锁粒度已经缩小到单个冲突桶,JVM 对短临界区监视器有成熟优化。相比维护固定 Segment,桶锁结构更简单,也能让不同桶和扩容线程协作。
追问:computeIfAbsent 可以执行远程调用吗?
不建议。计算期间可能占用桶级同步路径,慢调用会阻塞同桶更新;函数异常也不会写入结果。远程加载通常需要独立的异步缓存和并发合并策略。
追问:遍历时删除元素安全吗?
容器不会因并发修改损坏,迭代器也允许弱一致观察,但“遍历到什么就删除什么”仍不是全局原子操作。若要求删除符合某一时刻条件的完整集合,需要额外协调。