ConcurrentHashMap
ConcurrentHashMap 提供线程安全的键值操作,允许多个线程读取和更新共享映射。本文从桶数组和节点结构入手,解释读取、单键原子更新及计算回调。随后说明空值、协作扩容、热点键和可变值对象的使用边界。实现细节以 OpenJDK 21 为准,并补充 JDK 7 到 JDK 8 的变化。
1. 面试核心
先记住这几条,再读正文- 并发更新。 OpenJDK 21 的 ConcurrentHashMap 用 CAS 安装空桶节点,用桶级同步协调同一桶内的冲突更新。卡 · JDK 8 之后的结构
- 无桶锁读取。 ConcurrentHashMap 的
get通过 volatile 等同步机制读取已发布节点,无需获取桶互斥锁。 - 单键原子计算。
computeIfAbsent原子地完成单键的缺失检查与计算,containsKey后再put仍有竞态。卡 · 回调返回值与可变值 - null 的含义。 ConcurrentHashMap 拒绝 null 键和值,
get返回 null 表示当前缺失。卡 · null 键和值 - 映射与值对象。 ConcurrentHashMap 的原子更新保护键与值的映射关系,可变值对象内部的读写仍需独立同步。卡 · 回调返回值与可变值
2. 并发 Map 的需求
普通 HashMap 缺少并发读写保证,用同一把全局锁保护访问可以保证安全。但是,不相关的键也会在这把锁上排队。ConcurrentHashMap 通过更小范围的同步减少这类等待。
理解它的实现时,可以关注以下五项机制:
- 读取沿已发布的节点查找,无需获取桶互斥锁。
- 空桶插入通过 CAS 完成。CAS 即比较并交换,只有位置仍符合预期时才写入。
- 同一桶发生冲突更新时,在桶内协调,其他桶仍可更新。
- 扩容时,多个线程可以分担桶迁移工作。
- 迭代可以与更新同时进行,按弱一致规则观察元素。
这些机制保护共享映射的方法调用。热点键和耗时回调仍会造成等待,跨多个键的业务约束则需要更高层的同步。数据库写入和消息发送也要单独协调。
3. JDK 8 之后的结构
JDK 7 使用 Segment,把映射划成分别加锁的区段。JDK 8 改为桶数组、链表和树,OpenJDK 21 仍采用这个基本结构。桶是数组中的一个位置,同桶元素由链表或树组织。各部分的同步分工如下:
table字段、节点值和链接通过 volatile 等可见性机制发布,让读者看到正确构造和更新的节点。- 空桶插入通过 CAS 安装首节点,多个线程竞争时只有一个成功。
- 非空链表桶在首节点上使用
synchronized,通过对象监视器(monitor)串行协调更新。 - 树桶用 TreeBin 包装树结构,并通过内部状态协调查找与结构调整。
- 转发节点标记已经迁移的桶,引导后续操作进入新数组。
concurrencyLevel 在 OpenJDK 21 中用于估算初始容量。因此,面试中提到“默认 16 个 Segment”时,应明确那是 JDK 7 的历史实现。
4. 无锁读取
读取需要看到已经发布的节点,同时尽量减少与写线程的等待。get 先计算哈希并读取目标桶,再根据键沿链表或树查找。它通过 volatile 等同步机制观察节点值与链接,并利用稳定的键字段确认匹配。树正在调整结构时,查找可以退回链表路径,因此无需获取桶互斥锁。
V value = cache.get(key);
某个键的更新,与报告该更新值的非空读取之间存在 happens-before 关系。也就是说,写者发布该值前的写入,对读到它的线程可见。无互斥锁的读取仍依赖这些内存同步规则。
跨键读取的快照边界见 迭代一致性。
5. 复合操作的竞态
单个方法安全,还需要让完整的业务动作在并发下保持一致。下面先检查再写入,两次调用之间可以穿插其他线程的操作:
if (!users.containsKey(id)) {
users.put(id, loadUser(id));
}
两个线程都观察到缺失时,会分别执行加载,后写入的值还会覆盖先写入的值。短小的本地计算可以用 computeIfAbsent,把检查缺失和创建映射合成一个原子操作:
User user = users.computeIfAbsent(id, User::new);
上例只在键缺失时调用 User::new,已有值时直接返回它。根据条件更新的需求,还可以选择以下方法:
| 需求 | 方法 |
|---|---|
| 仅不存在时写入 | putIfAbsent |
| 仅值仍匹配时替换 | replace(key, old, next) |
| 仅值仍匹配时删除 | remove(key, value) |
| 按旧值计算新值 | compute、computeIfPresent |
| 不存在时计算 | computeIfAbsent |
| 合并已有与新增值 | merge |
这些方法的原子性范围是一个键对应的映射。数据库写入、另一个 Map 的修改和消息发送需要独立协调,才能让多个操作共同成功或处理部分失败。
6. compute 系列方法
compute 系列方法让调用方根据旧值计算新值,并由 Map 协调整个计算过程。下面在键缺失时创建计数器,再由计数器自己处理并发递增:
LongAdder counter = counters.computeIfAbsent(
key,
ignored -> new LongAdder()
);
counter.increment();
计算回调就是传给方法、用来生成值的函数。执行回调期间,相关更新会等待内部同步,因此应遵守以下回调约束:
- 计算要短小,减少其他线程的等待时间。
- 网络请求和数据库访问应放到专门的加载流程中,避免不可控的 I/O 阻塞。
- 回调应避免修改同一个 Map,包括递归修改当前键或修改其他映射。
- 失败时要处理已经发生的外部副作用,因为 Map 的异常处理无法回滚外部系统。
OpenJDK 21 在空桶执行 compute 计算时,会用 ReservationNode 作为临时占位节点并同步。已有桶则在相应节点上协调执行;computeIfAbsent 命中首节点的已有值时,还有直接返回路径。
computeIfAbsent 只负责按键创建映射。完整缓存还需要定义加载与淘汰策略:
- 加载超时后怎样结束等待。
- 已有值何时刷新。
- 加载失败或没有结果时是否缓存。
- 大量缺失查询怎样避免反复访问后端,也就是缓存穿透保护。
- 容量达到上限时淘汰哪些条目。
上例的 LongAdder 是支持并发递增的累加器,它独立保证计数更新安全。计数器映射被并发删除后,调用方手里的旧引用仍能递增。因此,计数与删除必须根据业务约定协调,才能避免增量写到已经脱离 Map 的计数器。
7. null 键和值
ConcurrentHashMap 用 null 作为“当前没有映射”的标记,因此 API 拒绝 null 键和值:
// map.put("key", null); // 抛出 NullPointerException
get(key) == null 可以直接说明当前缺少该键。如果允许 null 值,调用方还得用 containsKey 区分“没有映射”和“映射到了 null”。但两次调用间可以发生并发更新,所以无法形成稳定判断。null 也被并行归约用作空结果标记,归约就是把多个结果合并成一个结果。
需要缓存空业务结果时,可以保存枚举或显式状态对象。这样就能区分尚未加载、已经查询但没有值,以及加载失败。
8. 协作扩容
元素增多需要扩容时,旧表中的节点要迁往更大的数组。OpenJDK 21 把桶按区间划分,参与扩容的线程各自领取区间并迁移。完成迁移的旧桶放置 ForwardingNode,也就是指向新表的转发节点。
更新遇到转发节点时可以协助迁移,读取则跟随标记进入新表。因此,扩容期间 Map 仍可继续处理访问,迁移工作也由多个线程分担。扩容仍会消耗 CPU、内存带宽和新数组空间,已知规模时合理预估初始容量可以减少这些开销。
扩容期间的遍历仍采用弱一致语义,完整观察范围见 迭代一致性。
9. 热点键竞争
不同桶可以并发更新,同一个键的更新却必须保持顺序。下面的计数若所有请求都写一个键,竞争仍然集中:
counts.merge("all", 1L, Long::sum);
高频统计可以用 LongAdder,把并发递增分散到内部的多个计数位置,再读取总和。更大范围的竞争还可以按业务维度分片,或由各线程本地聚合后批量合并。也可以改变数据所有权,让一个写者集中处理更新。
分片会把一个值拆到多个位置,因此还要一起定义读取与维护规则:
- 读取时怎样找到各个分片。
- 各分片何时过期。
- 部分更新失败时怎样容错。
- 最终怎样汇总所有增量。
10. 回调返回值与可变值
回调的返回值决定映射是否存在,返回的对象是否线程安全则由对象自身决定。computeIfAbsent 的回调返回 null 时,本次计算保持键缺失。回调抛异常时也一样,所以后续调用仍可再次计算,外部副作用需要自行控制。
compute、computeIfPresent 或 merge 的回调返回 null 时,会删除现有映射。回调抛异常时,原映射关系保持不变。下面的代码则展示另一种边界:Map 原子地创建了 List,随后才向 List 追加元素。
var groups = new ConcurrentHashMap<String, List<String>>();
groups.computeIfAbsent("A", k -> new ArrayList<>()).add("x");
原子创建只保护 Map 中的映射,后面的 ArrayList.add 是另一次操作。多个线程拿到同一个列表后,会在追加时竞争。可以换用线程安全的值对象,或在 compute 内根据旧列表构造新的不可变列表,再替换映射。
11. 常见问题
11.2 外部锁与多键操作
外部锁只有在所有访问者都遵守时,才能保护多键操作。容器方法使用自己的内部同步,调用它们时会绕过调用方约定的锁。因此,多键更新应封装在统一入口,也可以改为整体替换不可变状态,或由单个写线程处理。
11.3 Hashtable 与 ConcurrentHashMap
Hashtable 的常用操作围绕实例锁串行,所以不同键也要等待同一把锁。ConcurrentHashMap 通过桶级协调缩小等待范围,并提供单键原子方法,其遍历采用弱一致语义。新代码需要并发 Map 时可优先考虑它,再核对业务所需的一致性。
11.4 单键读取与更新时间
针对单键,get 反映开始读取时最近完成的更新。读取与写入同时发生时,可以读到旧值或新值,取决于两者的实际顺序。连续读取多个键时,每次观察的时刻不同,因此完整业务状态仍需要一致性协议。
12 · 面试题卡片
12.1 JDK 8 之后 ConcurrentHashMap 怎样保证并发安全
出现公司:快手、阿里巴巴
考察重点
- 说明当前数组、链表或树结构与 JDK 7 Segment 的区别。
- 说明 volatile、CAS、桶级 synchronized 与协作扩容各解决什么问题。
- 说明容器安全与业务复合操作原子性的边界。
相关内容:JDK 8 之后的结构至复合操作的竞态、协作扩容。
参考回答
JDK 8 之后,ConcurrentHashMap 用可见性机制、CAS、桶级同步和协作扩容保证并发操作安全。
主结构由 JDK 7 的 Segment 改为桶数组、链表和树。OpenJDK 21 用 CAS 安装空桶节点,冲突更新在桶内同步,多个线程可以领取不同区间协助扩容。
get 通过 volatile 等同步机制读取已发布节点,无需获取桶互斥锁。但 containsKey 与随后 put 之间仍可穿插其他线程的操作。因此,单键复合更新应使用 putIfAbsent、compute、merge 或条件替换,多键约束则需更高层的同步。
12.2 ConcurrentHashMap 为什么不允许 null
出现公司:阿里巴巴、快手
考察重点
- 说明 null 怎样让并发
get明确表达映射缺失。 - 解释
containsKey与get之间发生更新时,为什么无法稳定区分两种状态。 - 说明怎样用显式状态替代业务 null。
相关内容:null 键和值。
参考回答
ConcurrentHashMap 用 null 明确表示读取时映射缺失。
若允许 null 值,就需额外调用 containsKey 区分状态。两次调用之间,其他线程可以更新映射。
null 也为并行归约提供明确的空结果标记。业务需要保存空结果时,可以使用枚举或包装对象,显式区分未加载、无结果与加载失败。
12.3 computeIfAbsent 返回 null 会缓存结果吗,返回的 List 是否线程安全?
出现公司:暂无已核验的公开来源(本站自测)
考察重点:说明回调返回 null 或抛异常时映射如何变化,以及返回的可变值为何需要独立同步。
相关内容:回调返回值与可变值。
参考回答
computeIfAbsent 的回调返回 null 时不会缓存映射,返回普通 ArrayList 时也不会让它自动变成线程安全。
回调返回 null 或抛异常后,该键仍缺失,所以后续调用可以再次计算。
成功创建 List 只保证映射的原子建立,随后调用 List.add 是另一次操作。多个线程共享列表时,需要线程安全的值或额外同步。也可以在 compute 中构造新的不可变列表,再原子替换旧值。