真实面经题目 · 原创解析
ConcurrentHashMap 的实现原理是什么?
ConcurrentHashMap 的核心是把哈希表操作拆成可并发执行的小粒度步骤:读操作大多依赖 volatile 可见性和链表或树查找,无需整体加锁;写操作在空桶上用 CAS 放入节点,在冲突桶上锁住桶头节点;扩容时通过 ForwardingNode 和协助迁移让多个线程共同搬迁数据。
真实面经题目 · 原创解析
ConcurrentHashMap 的核心是把哈希表操作拆成可并发执行的小粒度步骤:读操作大多依赖 volatile 可见性和链表或树查找,无需整体加锁;写操作在空桶上用 CAS 放入节点,在冲突桶上锁住桶头节点;扩容时通过 ForwardingNode 和协助迁移让多个线程共同搬迁数据。
回答时最好限定 JDK 8 之后的实现。ConcurrentHashMap 底层是 Node 数组,每个桶可能是链表、红黑树或迁移标记。get 基本不加锁,通过 volatile table、volatile value、next 等保证可见性;put 时先计算 hash,桶为空就 CAS 放入新节点,桶不为空就 synchronized 锁住桶头,在链表或树中插入或更新。链表过长且数组容量足够时会树化为红黑树。扩容不是单线程独占完成,而是通过 sizeCtl、transferIndex、ForwardingNode 让多个写线程协助迁移。size 统计使用 baseCount 和 CounterCell 分散热点,降低高并发计数冲突。
JDK 7 的 ConcurrentHashMap 以 Segment 分段锁为主,每个 Segment 类似一个小 HashMap。JDK 8 之后取消固定 Segment 作为主要并发单元,改为数组加桶级控制:空桶用 CAS,冲突桶锁桶头节点,读操作尽量无锁。面试中如果不说明版本,很容易把过时的分段锁实现当成当前主线实现。
get 根据 key 的 hash 定位数组下标,然后读取桶头节点。如果首节点匹配就返回,否则沿链表或红黑树继续查找。读路径通常不加 synchronized,因为 table、节点 value、next 等关键字段具备可见性保证。它牺牲的是强一致快照语义,不保证遍历期间看到完全静止的表,但单次 key 查询能安全读取到符合并发语义的结果。
put 首先确保 table 初始化,然后定位桶。如果桶为空,直接 CAS 新节点进去,避免加锁;如果桶不为空,则对桶头节点加 synchronized,在临界区内检查链表或树,完成插入、覆盖或树节点更新。锁粒度只落在单个桶上,不会阻塞其他桶的并发读写,这也是它相比整体锁 HashMap 包装方案吞吐更高的关键。
当单个桶中的链表过长时,查找成本会从近似常数退化为线性。JDK 8 引入红黑树结构,在链表长度达到阈值且数组容量足够时树化,使极端哈希冲突下的查找和更新保持对数级成本。如果数组还太小,通常优先扩容而不是树化,因为很多冲突可以通过增大数组被重新分散。
扩容时会创建新数组,并把旧桶逐步迁移过去。迁移过的桶会放置 ForwardingNode,后续线程遇到它就知道该桶已迁移或需要协助迁移。transferIndex 用来分配搬迁区间,多个线程可以一起推进扩容。sizeCtl 则承担初始化、扩容阈值和扩容状态控制,避免所有线程重复初始化或无序扩容。
因为节点和数组引用的发布依赖 volatile、CAS 与同步块的内存语义,读线程能看到安全发布后的结构。get 只沿当前可见链路查找,不需要独占桶即可完成单 key 查询。
并发环境下 get 返回 null 既可能表示 key 不存在,也可能表示 value 就是 null。禁止 null 可以让返回值语义明确,避免调用方再做不可靠的 containsKey 二次判断。
不一定。目标桶为空时使用 CAS 放入节点,不需要加锁。只有桶已存在节点、需要在链表或树中修改结构时,才会锁住桶头节点。
高并发写入下,单个计数器会成为热点。ConcurrentHashMap 使用 baseCount 加 CounterCell 分散更新冲突,读取 size 时再聚合,换取更好的写入扩展性。