真实面经题目 · 原创解析
ConcurrentHashMap 为什么能保证线程安全?
ConcurrentHashMap 的线程安全不是靠一个全局大锁实现的,而是把可见性、原子性和局部互斥组合起来:数组槽位和节点关键字段用 volatile 保证可见性,空桶插入、初始化和扩容状态切换用 CAS 保证原子更新,非空桶写入时只锁住单个桶的头节点,读操作基本无锁;再配合扩容协作、红黑树化和分散计数,既保证并发访问下结构不被破坏,又尽量降低锁竞争。
真实面经题目 · 原创解析
ConcurrentHashMap 的线程安全不是靠一个全局大锁实现的,而是把可见性、原子性和局部互斥组合起来:数组槽位和节点关键字段用 volatile 保证可见性,空桶插入、初始化和扩容状态切换用 CAS 保证原子更新,非空桶写入时只锁住单个桶的头节点,读操作基本无锁;再配合扩容协作、红黑树化和分散计数,既保证并发访问下结构不被破坏,又尽量降低锁竞争。
JDK 8 的 ConcurrentHashMap 能保证线程安全,核心是分层控制并发冲突,而不是像 Hashtable 那样锁住整张表。它底层仍然是数组加链表,链表过长且数组足够大时会转成红黑树。读操作通过 volatile 读数组槽位、节点 value 和 next,通常不加锁也能看到一致可达的结构;写操作如果目标桶为空,就用 CAS 把新节点放进去,避免多个线程同时插入覆盖;如果桶不为空,则只对该桶头节点 synchronized,加锁范围缩小到单个 bin。扩容时通过 ForwardingNode 标记迁移中的桶,其他线程遇到后会帮助迁移,避免一个线程独占扩容成本。size 统计用 baseCount 和 CounterCell 分散累加,降低热点竞争。因此它的线程安全来自 volatile 的可见性、CAS 的乐观原子更新、桶级 synchronized 的互斥修改,以及扩容和计数机制对并发状态的协调。
JDK 8 的 ConcurrentHashMap 没有继续使用早期 Segment 分段锁作为主要并发模型,而是围绕哈希桶做更细粒度的控制。底层结构是 Node 数组,每个数组槽位是一个桶,桶内可能是链表,也可能在冲突严重时变成红黑树。线程安全并不是单一机制完成的,而是读路径依赖 volatile 可见性,空桶写入依赖 CAS,非空桶写入依赖桶头节点锁,扩容依赖迁移标记和多线程协作,计数依赖分散累加。
JDK 8 中表数组 table 是 volatile 引用,数组中的节点通过 volatile 方式读取和写入;Node 的 key 和 hash 是 final,val 和 next 是 volatile。final 字段保证节点构造完成后的安全发布语义,volatile 字段保证 value 更新、链表追加、迁移标记等变化能被其他线程及时观察到。桶为空时可以直接 CAS 设置数组槽位,桶不为空时会在桶内部串行化修改。
get 操作通常不加锁,它先根据 key 的 hash 定位数组下标,然后以 volatile 语义读取对应桶的头节点。如果头节点命中就直接返回 value;如果是链表就沿着 next 遍历;如果是树桶就按树结构查找。因为节点的 val 和 next 是 volatile,读线程能看到写线程已经发布的节点和值,不会因为无锁读取就读到完全不可达或被破坏的结构。这里的安全不是强一致快照,而是并发语义下的安全可达读取。
put 时如果发现目标桶为空,ConcurrentHashMap 不会先加锁,而是使用 CAS 尝试把新 Node 放到数组槽位中。CAS 能保证只有一个线程把 null 改成自己的新节点成功,其他线程如果失败,会重新读取当前位置并进入后续逻辑。这个设计避免了大量不同 key 首次落入空桶时的锁开销,也避免两个线程同时写同一个槽位导致覆盖。空桶插入成功后,再通过 addCount 更新元素数量并判断是否需要触发扩容。
如果目标桶已经有节点,说明可能要更新已有 key、追加链表节点、或者操作树桶。JDK 8 的做法是 synchronized 锁住当前桶的头节点,也就是只让同一个桶内的写操作互斥,不影响其他桶并发写入。加锁后还会重新检查数组当前位置是否仍然是这个头节点,防止扩容或其他并发修改使锁对象失效。链表场景下会遍历节点,key 相等就更新 volatile value,不存在就追加到尾部。
ConcurrentHashMap 的桶内结构开始是链表,链表长度达到 TREEIFY_THRESHOLD 时并不一定马上树化。如果数组长度还小于 MIN_TREEIFY_CAPACITY,会优先扩容,因为很多冲突可能只是容量不足造成的,扩容后元素会重新分布到更多桶里。只有当数组已经足够大且单桶仍然过长时,才把链表转换为 TreeBin 包装的红黑树。树化能提升极端哈希冲突下的稳定性。
扩容是并发容器最容易出问题的地方,JDK 8 通过 sizeCtl、transferIndex 和 ForwardingNode 协调。某个线程触发扩容后,会创建新数组,并把旧桶逐步迁移过去;迁移完成的旧桶会放置 ForwardingNode,表示这个位置已经移动或正在移动。其他线程 put 或 get 遇到 ForwardingNode 时,会去新表查找,写线程还可能加入 helpTransfer 帮助迁移,避免一个线程独占扩容成本。
ConcurrentHashMap 的 size 不能简单用一个普通 int 自增,因为高并发下会有丢失更新;也不能所有写线程都竞争同一个锁,否则会形成热点。JDK 8 使用 baseCount 加 CounterCell 的方式,低竞争时 CAS 更新 baseCount,高竞争时把计数分散到多个 CounterCell 中,最后统计时把它们累加。并发修改时 size 更适合观察规模,不适合作为严格并发控制条件。
ConcurrentHashMap 的迭代器是弱一致的,它不会像 fail-fast 集合那样在并发修改时抛出 ConcurrentModificationException。迭代期间可以看到创建迭代器之后的部分更新,也可能看不到某些并发变化,但它会保证遍历过程不因结构并发修改而崩溃。这个语义符合并发容器的定位:读多写多场景下优先保证可用性和安全遍历,而不是提供全局一致快照。
HashMap 本身没有任何并发保护,并发 put 可能导致数据覆盖、链表或树结构异常、读到不一致状态等问题。Hashtable 的方法大多使用 synchronized,线程安全但锁粒度是整张表,吞吐量较差。Collections.synchronizedMap 也是通过外层互斥锁包装普通 Map,复合操作和迭代还需要调用方手动同步。ConcurrentHashMap 则把锁拆到桶级别,并让读操作大多无锁,适合高并发读写。
JDK 7 主要基于 Segment 分段锁,每个 Segment 内部类似一个小 HashMap,写入时锁 Segment。JDK 8 取消了固定 Segment 并发模型,改为数组加链表或红黑树,空桶 CAS,非空桶锁桶头节点,粒度更细,并且通过协作扩容提升整体并发能力。
因为它依赖的是安全发布和可见性,而不是互斥。table、数组槽位访问、Node 的 val 和 next 都具备 volatile 语义,读线程能看到写线程发布后的结构。get 不保证读到全局最新快照,但能保证遍历到的节点结构是可达且不会被并发修改破坏的。
空桶插入只需要把某个数组槽位从 null 改成新节点,CAS 正好能用低成本保证只有一个线程成功。非空桶涉及遍历、更新、追加、树化等多步结构修改,单次 CAS 难以覆盖整个临界区,所以用 synchronized 锁住桶头节点来保证桶内修改的互斥。
不会简单等同于 Hashtable 的性能问题,因为这里锁住的是单个桶头节点,而不是整张表。只有哈希到同一桶的写操作才互相阻塞,不同桶之间可以并发执行。加上空桶 CAS 和读操作无锁,大多数场景下竞争范围明显更小。
扩容时部分桶会被迁移到新数组,旧桶位置会放置 ForwardingNode 表示迁移状态。读线程遇到它会转到新表继续查找,写线程可能会帮助完成迁移。这样既避免访问旧位置丢数据,也避免扩容完全由一个线程承担导致长时间停顿。
并发场景下如果允许 value 为 null,get 返回 null 就无法区分是 key 不存在,还是 key 存在但值就是 null。由于查找结果可能和并发删除、插入交错,这种歧义会放大使用风险,所以它直接禁止 null key 和 null value。
在没有并发修改时,统计结果可以认为是准确的;但在并发 put、remove 同时发生时,size 反映的是统计过程中的一个瞬时结果,可能和调用返回后的真实数量不同。它适合观察规模,不适合作为严格并发条件判断。