← 返回文章

HashMap 和 ConcurrentHashMap

发布于 2026/10/19

1. 完整回答

HashMap 和 ConcurrentHashMap 都是 Java 中基于哈希表实现的键值对集合,主要区别是线程安全性和并发控制机制。

HashMap 适用于单线程环境,本身不保证线程安全;ConcurrentHashMap 专门用于多线程共享数据的场景,能够保证单次操作的线程安全,同时尽量减少锁竞争。

从底层结构来看,JDK 8 及之后的主流实现都采用数组 + 链表 + 红黑树。首先通过 Key 的哈希值定位数组中的桶,如果发生哈希冲突,就通过链表或者红黑树保存多个节点。正常情况下,查询和插入的平均时间复杂度都是 O(1),红黑树主要用于改善严重哈希冲突时的查询性能。

HashMap 的默认初始容量是 16,默认负载因子是 0.75,元素数量超过扩容阈值后,通常会扩容为原来的两倍。由于内部没有并发同步机制,多线程同时修改时可能出现数据丢失或者结构异常。

ConcurrentHashMap 在 JDK 8 中采用 CAS + synchronized + volatile 实现并发控制。空桶插入优先使用 CAS,发生哈希冲突时对对应的桶进行同步,读取操作通常不需要加锁,扩容时还支持多个线程协助数据迁移。

另外,ConcurrentHashMap 不允许 null 键和值,支持 putIfAbsent、compute 等原子操作,但多个独立操作组合起来仍然需要考虑竞态条件。

2. 核心原理与关键概念

2.1 HashMap 的底层原理

核心数据结构:数组 + 链表 + 红黑树。

put 操作流程:

计算 Key 的哈希值 → 定位数组下标 → 判断桶是否为空 → 插入或更新节点 → 必要时树化或扩容。

  1. 计算哈希值:通过 hashCode() 获取哈希值,并进行扰动处理,让高位信息参与索引计算。

  2. 定位桶位置:通过 (n - 1) & hash 计算数组下标,其中 n 是数组长度。

  3. 处理哈希冲突:如果桶为空,直接插入;如果已有节点,通过哈希值和 equals() 判断 Key 是否相同,相同则更新,否则继续查找或插入。

  4. 链表树化:当链表过长时会触发树化检查,树化阈值为 8,同时要求数组容量至少为 64;容量不足时优先扩容。

  5. 扩容:元素数量超过阈值后,通常将数组容量扩大一倍,并重新分配节点位置。

get 操作流程:

计算哈希值 → 定位桶 → 比较 Key → 遍历链表或搜索红黑树 → 返回对应的 Value。

哈希分布均匀时,查询效率接近 O(1);大量元素集中到同一个桶时,链表查询可能退化为 O(n),红黑树能够改善这种情况。

2.2 ConcurrentHashMap 的底层原理

JDK 8 之后,ConcurrentHashMap 的核心机制是 CAS + synchronized + volatile + 多线程协助扩容。

put 操作流程:

计算哈希值 → 定位桶 → 判断桶状态 → CAS 插入或加锁更新 → 更新元素计数 → 必要时参与扩容。

  1. 空桶插入:使用 CAS 尝试写入新节点,成功后无需获取互斥锁。

  2. 非空桶写入:通过 synchronized 对桶头节点进行同步,在桶内完成链表或红黑树的更新。

  3. 遇到扩容:如果发现对应桶已经迁移,线程可以协助完成剩余的数据迁移。

  4. 更新计数:通过基础计数和分散计数单元减少并发更新时的竞争。

get 操作流程:

计算哈希值 → 定位桶 → 读取节点 → 遍历链表或红黑树 → 返回结果。

读取通常无需加锁,主要依赖 volatile 和相应的原子访问机制保证节点发布与数据可见性。

扩容流程:

触发扩容 → 创建新数组 → 多线程分工迁移桶 → 设置迁移标记 → 完成新旧数组切换。

迁移标记节点(ForwardingNode)用于引导其他线程访问新数组,也让后续写入线程能够参与扩容。

2.3 关键概念

概念 含义与作用
哈希冲突 不同 Key 落入同一个桶,通过链表或红黑树解决
负载因子 控制元素密度与扩容时机,HashMap 默认是 0.75
CAS 比较并交换,通过原子操作减少简单写入的锁开销
synchronized 保证桶内多个节点修改操作的互斥性
volatile 提供必要的可见性和内存有序性保证,支持并发读取

3. 高频面试追问

3.1 为什么 HashMap 线程不安全?

HashMap 的内部数据修改没有并发同步保护,多个线程同时执行写操作时,可能出现数据覆盖、更新丢失以及扩容期间的结构异常。

例如,两个线程同时发现某个桶为空,都准备插入节点,最后一个线程的写入可能覆盖另一个线程的结果。

另外,HashMap 的扩容涉及数组替换和节点迁移,并发执行时可能出现数据丢失。

JDK 7 的 HashMap 曾存在并发扩容导致链表成环的典型问题。JDK 8 调整了链表迁移方式,但仍然没有提供线程安全保证。

因此,只要存在多个线程并发修改共享 HashMap 的情况,就需要额外同步,或者选择 ConcurrentHashMap。

3.2 HashMap 为什么使用 2 的幂作为数组容量?

主要是为了提高索引计算效率,并简化扩容后的节点迁移。

当数组长度 n 是 2 的幂时,可以使用 (n - 1) & hash 计算桶下标,效果相当于对数组长度取模。

此外,数组容量扩大两倍后,原来某个桶中的节点只会留在原下标,或者移动到“原下标 + 旧容量”的位置。

这样就能通过哈希值的特定位判断节点的新位置,减少重新定位的计算开销。

继续追问:hashCode 相同的两个 Key 一定相等吗?

不一定。哈希值相同只代表发生了哈希冲突,还需要通过 equals() 判断。

反过来,两个通过 equals() 判断相等的 Key,必须具有相同的 hashCode()。如果自定义对象违反这个约定,HashMap 可能无法正确查找对应元素。

3.3 HashMap 为什么引入红黑树?什么时候树化?

引入红黑树主要是为了降低严重哈希冲突带来的性能损耗。

在 JDK 8 之前,桶内主要使用链表保存冲突节点,如果大量元素落入同一个桶,查找时间复杂度可能退化为 O(n)。

JDK 8 引入红黑树后,对于能够有效排序的冲突节点,查询通常可以改善到 O(log n)。

树化涉及三个关键阈值:

  • 8:链表树化的触发阈值。

  • 64:允许树化的最小数组容量。

  • 6:树节点数量减少时,退化为链表所涉及的阈值。

之所以要求数组容量至少为 64,是因为容量较小时,哈希冲突可能来自数组空间不足,此时扩容通常比维护红黑树更合适。

3.4 HashMap 和 ConcurrentHashMap 的扩容有什么区别?

两者通常都会将数组容量扩大一倍,核心区别是扩容过程是否支持线程安全的协作。

HashMap 扩容时,会创建新数组,然后依次迁移旧数组中的节点。由于数组长度是 2 的幂,可以根据哈希值判断节点留在原位置,还是移动到偏移了旧容量的位置。

ConcurrentHashMap 则支持多个线程协助扩容。

当一个线程启动扩容后,其他线程如果发现迁移正在进行,可以参与迁移不同范围的桶。已经迁移完成的桶会设置特殊标记,帮助后续线程定位新数组。

这种设计能够分摊扩容工作,减少单个线程承担全部迁移任务带来的延迟,同时保证迁移期间的数据访问安全。

3.5 JDK 7 和 JDK 8 的 ConcurrentHashMap 有什么区别?

最大的区别是并发锁的粒度和底层组织方式发生了变化。

JDK 7 使用分段锁机制,底层通过 Segment 将数据划分成多个部分,每个 Segment 使用 ReentrantLock 控制写入。不同 Segment 可以并发修改,但同一个 Segment 内的写入需要竞争同一把锁。

JDK 8 移除了 Segment 结构,改用数组、链表和红黑树,并结合 CAS 与 synchronized 控制写入。

当桶为空时使用 CAS;当桶内存在节点时,主要锁住对应桶的头节点。

这样可以缩小锁的影响范围,使不同桶的更新更容易并发执行。

另外,JDK 8 之后的 concurrencyLevel 主要作为初始化容量的参考参数,不再对应固定数量的分段锁。

3.6 ConcurrentHashMap 的 get 为什么不需要加锁?

因为它通过内存可见性机制和安全的节点发布方式,保证读取线程能够正确访问已经发布的数据。

ConcurrentHashMap 中的节点值、后继节点等关键字段使用 volatile,并通过具有相应内存语义的方式访问桶节点。

写入线程完成节点发布后,读取线程能够按照内存可见性规则观察到已经完成的更新。

所以,get 通常只需要定位桶,再遍历链表或者红黑树,就可以返回结果,不必获取写入时使用的互斥锁。

但这里保证的是单次读取的并发语义。如果读取过程中其他线程正在修改数据,多次 get 之间仍然可能观察到不同状态。

3.7 为什么 ConcurrentHashMap 不允许 null?

主要是为了消除并发读取时的语义歧义。

在 HashMap 中,get(key) 返回 null,可能有两种情况:

  • Key 不存在。

  • Key 存在,但对应的 Value 是 null。

单线程下,可以通过 containsKey() 进一步判断。

但在并发环境中,调用 get() 和 containsKey() 之间,其他线程可能已经修改了映射关系,因此两次调用无法保证观察到同一个状态。

ConcurrentHashMap 禁止 null 键和值后,get(key) 返回 null 就可以明确表示当前没有对应映射。

这也方便其并发计算和批量操作使用 null 表示结果缺失。

3.8 ConcurrentHashMap 能保证所有操作都是原子性的吗?

ConcurrentHashMap 能保证规定的单次操作具有相应的线程安全和原子性语义,但多个独立操作组合起来,并不会自动形成一个原子操作。

例如,先通过 get 读取计数,再加一并 put 回去,两个线程可能读到相同的旧值,最终造成更新丢失。

对于这种场景,可以使用:

  • putIfAbsent():保证不存在时才插入。

  • compute():原子地根据旧值计算新值。

  • merge():原子地合并新旧值。

如果是高并发热点计数,还可以使用 ConcurrentHashMap<K, LongAdder>,减少频繁累加时的竞争。

继续追问:computeIfAbsent 适合执行耗时操作吗?

通常不适合。ConcurrentHashMap 的 computeIfAbsent() 会对对应键的计算与插入过程提供原子性保证,计算期间可能阻塞其他更新操作。

因此,计算函数应该尽量简短,并避免在函数内部修改同一个 Map。涉及远程调用、慢查询等耗时任务时,还需要专门设计并发加载机制。

3.9 HashMap 和 ConcurrentHashMap 的迭代器有什么区别?

HashMap 的迭代器采用 fail-fast(快速失败) 机制,ConcurrentHashMap 的迭代器采用弱一致性机制。

HashMap 在创建迭代器后,如果集合发生未经迭代器自身操作的结构性修改,通常会抛出 ConcurrentModificationException。

它主要通过修改次数检测结构变化,但这个异常只是尽力检测,并不保证每次并发修改都会被发现。

ConcurrentHashMap 允许遍历与修改同时进行,迭代器不会因为并发结构修改而抛出这个异常。

不过,它也不提供某个时刻的完整快照,遍历结果可能包含部分新修改的数据,也可能看不到部分并发更新。

因此,如果业务要求严格一致的完整快照,还需要额外的一致性控制。

3.10 实际项目中如何选择 HashMap 和 ConcurrentHashMap?

主要根据数据是否在线程之间共享,以及是否存在并发修改来选择。

如果数据只在单个方法或单个线程内部使用,HashMap 通常更简单,开销也更低。

如果多个线程共享用户状态、缓存映射或者任务执行状态,并且存在并发读写,更适合选择 ConcurrentHashMap。

不过,还需要注意两个边界。

第一,ConcurrentHashMap 的 size() 在并发修改期间反映的是瞬时计数,不适合直接作为要求严格一致性的业务判断依据。

第二,ConcurrentHashMap 只保证 Map 自身操作的并发安全。如果需要跨多个 Key 维护一致性,就需要额外同步或者重新设计数据更新方式。

继续追问:ConcurrentHashMap 中存放的对象也是线程安全的吗?

不一定。

ConcurrentHashMap 能安全地管理键值映射关系,但如果 Value 本身是一个可变对象,例如 ArrayList,多个线程同时修改这个对象时,仍然可能出现并发问题。

这种情况下,需要使用线程安全的 Value 类型,或者额外保护对象内部的修改操作。

4. 易混淆概念对比

4.1 HashMap 和 ConcurrentHashMap

对比维度 HashMap ConcurrentHashMap
线程安全 不保证 保证单次操作的线程安全
底层结构 数组 + 链表 + 红黑树 数组 + 链表 + 红黑树
并发控制 无内置同步 CAS + synchronized + volatile
null 键和值 允许 不允许
读取操作 无并发安全保证 通常无锁
扩容 普通节点迁移 支持多线程协助迁移
迭代器 快速失败 弱一致性
适用场景 局部数据、单线程处理 多线程共享映射、并发缓存

4.2 CAS、synchronized 和 volatile

机制 核心作用 ConcurrentHashMap 中的应用
CAS 通过原子比较更新数据 空桶插入、部分状态更新
synchronized 保证临界区互斥执行 非空桶的节点修改
volatile 保证可见性及相关有序性 支持安全发布和读取节点

三者相互配合,在保证并发安全的同时减少不必要的锁竞争。

4.3 putIfAbsent、computeIfAbsent 和 compute

方法 核心行为 适用场景
putIfAbsent Key 不存在时插入指定值 并发条件插入
computeIfAbsent Key 不存在时计算并插入值 懒加载、初始化
compute 根据当前值计算新映射 计数更新、状态变更

对于 ConcurrentHashMap,这些方法可以提供对应操作的原子性保证。其中,计算函数应保持简短,避免修改同一个 Map。

4.4 快速失败与弱一致性

对比维度 快速失败(fail-fast) 弱一致性
典型实现 HashMap ConcurrentHashMap
并发修改 可能抛出异常 允许并发遍历和修改
数据一致性 不支持无同步并发遍历 不保证完整快照
主要特点 尽早发现结构性修改 支持持续并发访问

5. 总结

HashMap 和 ConcurrentHashMap 都是基于哈希表的键值对集合,通过数组定位桶,再利用链表或红黑树解决哈希冲突,正常情况下查询效率接近 O(1)。HashMap 结构简单,适合单线程环境,但不保证并发安全。ConcurrentHashMap 在 JDK 8 之后通过 CAS、synchronized 和 volatile 配合实现线程安全,读取通常无需加锁,写入控制在桶级别,扩容时还支持多线程协作。两者最核心的区别是并发控制机制。另外,ConcurrentHashMap 的线程安全主要针对集合自身的操作,多个操作组合以及 Value 对象内部的修改,仍然需要根据业务保证一致性。