HashMap 和
ConcurrentHashMap 是 Java 中用于存储键值对(Key-Value)的哈希表实现,但前者是非线程安全的,后者是线程安全的,且两者的底层原理和设计目标有显著差异。以下从数据结构、核心机制、线程安全(仅 ConcurrentHashMap) 三个维度详细解析。
HashMap 是 Java 中最常用的哈希表实现,用于快速存储和查询键值对,核心目标是高效的增删改查,不保证线程安全。
JDK 1.8 及之后,HashMap 的底层结构为 “数组(哈希桶)+ 链表 + 红黑树” 的复合结构:
- 数组(哈希桶,
Node[] table):数组的每个元素称为 “桶”,用于存储键值对的首节点。数组长度(容量,capacity)默认是 16,且始终保持为 2 的幂次方(如 16、32、64...),这是为了通过位运算快速计算元素位置。
- 链表(
Node 节点):当多个键的哈希值映射到同一个桶时,会形成链表(解决哈希冲突)。每个 Node 包含 hash(键的哈希值)、key、value、next(下一个节点引用)。
- 红黑树(
TreeNode 节点):当链表长度超过阈值(默认 8),且数组容量 ≥ 64 时,链表会转为红黑树(一种自平衡二叉查找树)。红黑树的查询时间复杂度为 O (log n),远优于链表的 O (n),可优化大量哈希冲突时的查询效率。
HashMap 通过键(Key)的哈希值确定元素在数组中的位置,步骤如下:
- 计算键的哈希值:
int hash = hash(key),其中 hash(key) 是对 key.hashCode() 的二次哈希(通过高位异或低位,减少哈希冲突):
static final int hash(Object key) {
int h;
- 计算数组索引:
int index = (n - 1) & hash,其中 n 是数组容量(table.length)。由于 n 是 2 的幂次方,n-1 的二进制全为 1(如 16-1=15 → 1111),& 运算等价于取模(hash % n),但效率更高。
put(key, value) 用于插入键值对,步骤如下:
- 若数组
table 未初始化或长度为 0,先触发扩容(resize())。
- 计算
key 的 hash 和 index,检查索引 index 处的桶是否为空:
- 若为空,直接创建
Node 插入(table[index] = newNode(hash, key, value, null))。
- 若不为空(哈希冲突):
- 若桶的首节点与
key 相同(hash 相等且 key.equals() 为 true),直接替换 value。
- 若首节点是红黑树节点(
TreeNode),调用树的插入方法(putTreeVal)。
- 若首节点是链表节点,遍历链表:
- 若找到相同
key,替换 value;
- 若未找到,在链表尾部插入新节点,插入后若链表长度 ≥ 8,触发链表转红黑树(
treeifyBin)。
- 插入后若元素数量(
size)超过阈值(threshold = capacity * loadFactor,默认负载因子 loadFactor=0.75),触发扩容(resize())。
当元素数量超过阈值时,HashMap 会扩容(容量翻倍),目的是减少哈希冲突,步骤如下:
- 计算新容量:
newCap = oldCap << 1(原容量 × 2,保持 2 的幂次方)。
- 创建新数组(
newTable),容量为 newCap。
- 将原数组(
oldTable)中的元素迁移到新数组:
- 对于链表 / 红黑树中的每个节点,重新计算在新数组中的索引(因
newCap 是原容量的 2 倍,新索引要么是原索引,要么是 原索引 + oldCap,无需重新计算 hash,通过 hash & oldCap 判断高位是否为 1 即可)。
- 若原节点是链表,迁移时可能拆分链表为两个子链表(分别对应新索引的两个位置);若原节点是红黑树,迁移后可能退化为链表(若元素数量 < 6)。
- 替换
table 为 newTable,更新阈值(newThr = newCap * loadFactor)。
HashMap 未做任何同步处理,多线程环境下操作可能导致问题:
- 扩容死循环(JDK 1.7 及之前):多线程同时扩容时,链表迁移可能形成环形链表,导致后续查询时无限循环。
- 数据丢失:多线程同时
put 时,可能覆盖彼此的插入结果。
- 数据不一致:
size 计数未加锁,可能导致统计结果错误。
ConcurrentHashMap 是线程安全的哈希表实现,专为多线程环境设计,支持高并发的增删改查,同时尽量减少锁竞争带来的性能损耗。其设计在 JDK 1.7 和 1.8 有较大差异,这里以更常用的 JDK 1.8+ 为主讲解。
JDK 1.8 摒弃了 JDK 1.7 的 “分段锁(Segment)” 机制,底层结构与 HashMap 类似:“数组(哈希桶)+ 链表 + 红黑树”,但通过更细粒度的同步机制保证线程安全。
JDK 1.8 采用 “无锁 CAS 尝试 + synchronized 细粒度锁” 保证线程安全,避免了 JDK 1.7 分段锁的粗粒度竞争:
- CAS(Compare-And-Swap):对于未初始化的桶(
table[index] == null),通过 CAS 原子操作尝试插入新节点(casTabAt 方法),避免加锁。
- synchronized 锁:若 CAS 失败(桶已被其他线程占用,存在哈希冲突),则对桶的首节点(
table[index])加 synchronized 锁,仅锁定当前冲突的桶,其他桶仍可被并发访问,锁粒度更细。
put(key, value) 步骤与 HashMap 类似,但增加了同步处理:
- 计算
key 的 hash(与 HashMap 相同),若数组未初始化,先通过 CAS 初始化(initTable)。
- 定位索引
index,检查桶是否为空:
- 若为空,通过
casTabAt 方法(CAS)尝试插入新节点,成功则返回;失败(被其他线程抢先插入)则重试。
- 若不为空(存在哈希冲突):
- 若桶的首节点是
MOVED 状态(正在扩容),当前线程协助扩容(helpTransfer),避免扩容线程负载过重。
- 否则,对首节点加
synchronized 锁,遍历链表 / 红黑树:
- 若找到相同
key,替换 value(通过 cas 保证原子性)。
- 若未找到,插入新节点(链表尾部或红黑树),插入后检查是否需要转红黑树。
- 插入后更新
size(通过 addCount 方法,CAS 累加,超过阈值则触发扩容)。
ConcurrentHashMap 的扩容支持多线程协同,避免单线程扩容效率低:
- 当
size 超过阈值时,由某个线程发起扩容(transfer 方法),将数组容量翻倍。
- 扩容时,原数组(
oldTab)被分为多个段,每个线程负责迁移一段(通过 stride 控制,默认每个线程处理 16 个桶)。
- 其他线程执行
put/get 时,若发现正在扩容(桶的首节点为 MOVED),会协助迁移当前桶的元素,加速扩容过程。
- 迁移逻辑与
HashMap 类似(新索引 = 原索引 或 原索引 + oldCap),但通过 CAS 标记迁移状态,避免冲突。
get(key) 操作无需加锁,直接通过哈希定位桶,遍历链表 / 红黑树查找,原因是:
- 节点的
value 用 volatile 修饰,保证多线程间的可见性(修改后立即刷新到主内存,读取时从主内存加载)。
- 红黑树的结构修改(如插入、删除)通过
synchronized 锁保证原子性,读取时无需锁即可获取最新结构。
- HashMap 是单线程场景下的高效哈希表,底层为 “数组 + 链表 + 红黑树”,通过哈希散列和扩容减少冲突,非线程安全。
- ConcurrentHashMap 是多线程场景下的线程安全实现,JDK 1.8 用 “CAS + synchronized” 保证安全,锁粒度细,并发性能优异,底层结构与 HashMap 类似,但不支持 null 键值,迭代器为弱一致性。
实际开发中,单线程环境用 HashMap,多线程环境(如并发写入)用 ConcurrentHashMap。