跳到主要内容
约 9 分钟3 张卡

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.1 size 与业务限额​

并发更新期间,size 返回调用过程中观察到的映射数量。其他线程随后仍可插入,因此容量控制需要把检查与插入放进同一套同步规则。具体快照边界见 迭代一致性。

11.2 外部锁与多键操作​

11.3 Hashtable 与 ConcurrentHashMap​

11.4 单键读取与更新时间​

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 中构造新的不可变列表,再原子替换旧值。

13. 参考资料​