面试考察点

  • 是否区分 JDK 7 分段锁和 JDK 8 桶级并发方案。
  • 能否说明无锁读、CAS、synchronized 的协作。
  • 是否理解单个方法线程安全不等于组合操作原子。

核心答案

JDK 8 的 ConcurrentHashMap 使用数组、链表和红黑树存储数据。读操作主要依靠 volatile 可见性无锁完成;写入空桶时使用 CAS,桶冲突时锁定桶头节点;扩容时多个线程可以协助迁移。

JDK 7 使用 Segment 分段锁,JDK 8 取消固定分段,降低冲突粒度并提升扩容协作能力。

关键机制

桶数组和节点链接中的关键字段具备可见性保证。扩容期间旧桶会放置转移标记,其他线程遇到后可参与迁移或去新表查找。树化还要求数组达到一定容量,避免小表过早转成红黑树。

JDK 8 的 put 流程

一次写入可以按以下路径理解:

  1. 表未初始化时,通过 CAS 完成初始化,避免多个线程重复创建数组。
  2. 目标桶为空时,CAS 直接放入新节点,不获取桶锁。
  3. 遇到 MOVED 转移节点时,当前线程协助扩容。
  4. 桶内已有数据时,对桶头节点加 synchronized,再次确认桶没有变化后遍历链表或红黑树。
  5. 找到相同键则替换值,否则追加节点;达到阈值后可能树化。
  6. 更新分散计数,必要时触发或协助扩容。

锁住桶头而不是整个表,使不同桶上的写操作可以并行。加锁后必须再次校验当前桶头仍是刚才观察到的节点,否则扩容或其他写线程可能已经改变结构。

为什么读操作通常不用锁

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 不是原子操作,应使用 putIfAbsentcomputemerge 等复合 API。compute 内的函数应短小,不能执行慢 I/O 或递归修改同一个键。

常见误区

ConcurrentHashMap 不允许 null 键和值,因为并发读取时无法区分“键不存在”和“值就是 null”。遍历是弱一致的,不会锁住全表,也不保证看到遍历期间的全部变化。

高频追问与参考回答

追问:size 在并发下准确吗?

它通过分散计数并汇总来降低竞争,返回的是调用时刻附近的统计值;若业务要求与其他状态形成严格一致快照,需要额外同步。

追问:为什么 JDK 8 又使用 synchronized?

锁粒度已经缩小到单个冲突桶,JVM 对短临界区监视器有成熟优化。相比维护固定 Segment,桶锁结构更简单,也能让不同桶和扩容线程协作。

追问:computeIfAbsent 可以执行远程调用吗?

不建议。计算期间可能占用桶级同步路径,慢调用会阻塞同桶更新;函数异常也不会写入结果。远程加载通常需要独立的异步缓存和并发合并策略。

追问:遍历时删除元素安全吗?

容器不会因并发修改损坏,迭代器也允许弱一致观察,但“遍历到什么就删除什么”仍不是全局原子操作。若要求删除符合某一时刻条件的完整集合,需要额外协调。

总结

回答主线是“读靠可见性、空桶 CAS、冲突桶加锁、扩容可协作”,最后强调应使用原子复合 API。

机制全景图

下面把「ConcurrentHashMap 如何保证线程安全?」从输入到结果压缩成一条可复述的主链路。面试时先用图建立全局坐标,再进入局部实现,能避免只背零散结论。

flowchart LR
    A["计算哈希定位桶"]
    A --> B["读取 volatile 桶节点"]
    B --> C["CAS 初始化或插入"]
    C --> D["桶冲突时局部加锁"]
    D --> E["协助扩容并发布结果"]

完整链路:从输入到结果

沿着「计算哈希定位桶 → 读取 volatile 桶节点 → CAS 初始化或插入 → 桶冲突时局部加锁 → 协助扩容并发布结果」观察输入、状态与输出,下面每个阶段都对应一个可以在源码、日志或系统表中验证的位置。

1. 计算哈希定位桶

并发映射仍先通过扰动哈希定位桶,读取路径尽量依靠 volatile 可见性而不获取全局锁。

2. 读取 volatile 桶节点

table 和节点关键字段的发布关系保证已完成写入对后续读可见,但复合业务操作仍可能需要原子 API。

3. CAS 初始化或插入

空桶插入优先 CAS,竞争失败后重试;这使不同桶写入可以并行。

4. 桶冲突时局部加锁

非空桶更新在桶首节点上同步,锁粒度是局部桶而不是整个 Map,树桶有独立并发控制。

5. 协助扩容并发布结果

扩容时线程可以领取迁移区间共同搬迁,ForwardingNode 引导其他线程访问新表或参与迁移。

源码与实现定位

入口 阅读重点
ConcurrentHashMap#putVal 空桶 CAS 与桶首 synchronized
ConcurrentHashMap#transfer ForwardingNode 与并行扩容

源码或系统表应按上表顺序追踪:先确认入口实际走到哪条路径,再用运行时数据验证,而不是仅凭类名或配置推测。

参数配置与可复现实验

counts.computeIfAbsent(key, k -> new LongAdder()).increment();

以 1、8、64 线程压测均匀键和单热键;比较 get+put、compute 与 LongAdder,记录吞吐和桶锁等待。

验证步骤与预期结果

1. 固定输入和基线

先在没有故障注入的环境执行上述配置,固定数据规模、并发度、运行时版本和预热时间。以「热键占比」为主基线,记录值应满足「应接近业务分布」;同时保存 热点桶锁等待、compute 执行时间,使后续变化能够回到同一时间轴比较。

2. 从实现入口确认路径

在「ConcurrentHashMap#putVal」确认请求确实进入「空桶 CAS 与桶首 synchronized」对应的实现,再沿「ConcurrentHashMap#transfer」观察「ForwardingNode 与并行扩容」。如果入口路径都未命中,就不应继续调整下游参数,而应先检查调用条件、版本或路由是否与假设一致。

3. 注入本文特有的失败模式

优先复现「get 后 put 的检查再执行竞态」,并把单一变量逐级放大,直到「热键占比」越过「单键超过 20% 写入」。随后再分别验证「compute 回调阻塞导致桶热点」和「键分布差让所有写集中到少数桶」,三类故障分开执行,避免多个变量同时变化而无法归因。

4. 执行止损和根因修复

第一轮只应用「复合更新改为 compute/merge/putIfAbsent」,确认它能控制影响范围;第二轮应用「回调中禁止远程调用」,验证核心链路恢复;最后落实「热点计数使用分段累加后汇总」,消除同类问题再次出现的条件。每一步都保留变更前后数据,不用“感觉变快了”替代测量。

5. 通过退出条件

实验只有同时满足三项才算通过:「热键占比」回到「应接近业务分布」、「compute 时长」回到「微秒级纯内存」、「扩容协助时间」回到「稳态接近 0」,并且业务结果差异为零。若性能恢复但结果不一致,仍应视为失败;若指标恢复后很快再次越线,则说明只完成了临时止损,没有消除根因。

量化基线

指标 样例基线/口径 风险线 结论
热键占比 应接近业务分布 单键超过 20% 写入 拆分计数或 LongAdder
compute 时长 微秒级纯内存 包含 I/O/阻塞 移出回调
扩容协助时间 稳态接近 0 请求线程大量 transfer 预估 initialCapacity

这些数值是实验口径或示例告警线,不是可复制到所有系统的固定答案;上线阈值应由本系统稳态、峰值和故障演练共同确定。

事故复盘:本地缓存计数偶发丢失

代码先 get 计数、加一再 put,多线程下两个更新读取同一旧值,ConcurrentHashMap 本身没有损坏但业务复合操作丢失。改用 compute 或 LongAdder 作为值,并确保映射函数短小且无外部阻塞后,更新才真正原子。

失败模式 首要证据 第一处置动作
get 后 put 的检查再执行竞态 热点桶锁等待 复合更新改为 compute/merge/putIfAbsent
compute 回调阻塞导致桶热点 compute 执行时间 回调中禁止远程调用
键分布差让所有写集中到少数桶 扩容协助耗时 热点计数使用分段累加后汇总

发布与回滚检查点

  • 发布前:确认「ConcurrentHashMap#putVal」对应实现和上述配置在目标版本仍然有效,并保存「热键占比」基线。
  • 灰度中:同时观察 热点桶锁等待、compute 执行时间、扩容协助耗时;任一指标越过表中风险线,就停止继续扩量。
  • 回滚时:先执行「复合更新改为 compute/merge/putIfAbsent」控制影响,再回退代码或参数;涉及持久状态时必须额外核对结果差异。
  • 发布后:至少覆盖一个完整峰值周期,确认「get 后 put 的检查再执行竞态」没有再次出现,才关闭变更观察窗口。

方案对比与选型

方案 更适合的场景 主要收益 代价与边界
ConcurrentHashMap 高并发通用键值映射 读路径轻、桶级并发 复合操作必须使用原子 API
同步包装 Map 低并发且需要整体互斥语义 实现简单 全局锁限制吞吐和遍历
不可变快照 配置等读极多、整批更新 读取无锁且视图一致 更新需复制,实时单键写不合适

选型至少带上 元素数量、读写比例、遍历方式、并发度和内存预算,并用上面的量化基线验证;未知数据应明确为待测假设。

设计边界与工程取舍

ConcurrentHashMap 保证单个方法的线程安全,不保证跨多个调用的业务事务;映射回调也不应递归修改同一键或执行慢 I/O。

工程落地遵循:先保证数据结构语义正确,再依据访问模式选择实现。回答时直接引用「ConcurrentHashMap#putVal」、配置实验和事故数据,比复述固定模板更有说服力。