HashMap 底层原理是什么?为什么线程不安全?

7 阅读5分钟

一、HashMap 底层原理是什么

1. 核心数据结构

JDK 8 里的 HashMap 底层是:

数组 + 链表 + 红黑树

你可以把它理解成一个“桶数组”:

  • 最外层是一个 Node<K,V>[] table

  • 数组的每一个位置,叫一个 桶(bucket)

  • 不同 key 经过 hash 计算后,会落到不同桶里

  • 如果多个 key 落到同一个桶里,就发生 哈希冲突

  • 冲突后:

    • 元素少时,用 链表 挂起来
    • 链表太长时,转成 红黑树,提高查询效率

2. put 过程

执行 map.put(key, value) 时,大致过程:

第一步:计算 key 的 hash

不是直接用 key.hashCode(),而是会做一次扰动运算:

(h = key.hashCode()) ^ (h >>> 16)

目的:
让高位也参与运算,减少哈希冲突。


第二步:计算桶下标

通过下面方式找到数组位置:

(n - 1) & hash

这里 n 是数组长度。

为什么不用 %
因为位运算比取模更快。

同时这也要求数组长度通常是 2 的幂次方


第三步:判断桶里有没有元素

  • 如果该位置为空,直接放进去

  • 如果不为空,说明冲突了,继续处理:

    • 如果桶中第一个节点的 key 相同,则覆盖 value
    • 如果是链表,则遍历链表查找是否已有相同 key
    • 如果是红黑树,则按树方式查找和插入

第四步:是否需要树化

JDK 8 中,如果链表长度超过 8,并且数组长度至少达到 64,链表会转成红黑树。

原因:
链表太长会导致查询退化成 O(n),转红黑树后可以到 O(log n)。


第五步:是否需要扩容

当元素个数超过阈值时触发扩容。

阈值公式:

threshold = capacity * loadFactor

默认:

  • 初始容量:16
  • 负载因子:0.75

所以默认第一次扩容阈值是:

16 * 0.75 = 12

超过 12 个元素就会扩容。


3. resize 扩容原理

扩容时一般变成原来的 2 倍

比如:

  • 16 -> 32
  • 32 -> 64

扩容后,原有元素需要重新分配位置。

JDK 8 优化点:
元素在扩容后的位置只有两种可能:

  • 原位置不变
  • 原位置 + oldCap

这样减少了重新计算成本。


4. get 过程

执行 map.get(key) 时:

  1. 先算 hash

  2. 定位桶下标

  3. 先比较桶头节点

  4. 如果没找到:

    • 是链表就遍历链表
    • 是红黑树就走树查找

时间复杂度:

  • 理想情况:O(1)
  • 冲突严重链表时:O(n)
  • 树化后:O(log n)

二、为什么 HashMap 线程不安全

HashMap 本身没有任何同步控制,多个线程同时操作时,会出现数据竞争。

线程不安全主要体现在下面几类问题。


1. put 时可能导致数据覆盖

假设两个线程同时往同一个桶插入数据:

  • 线程 A 读到桶头是 node1
  • 线程 B 也读到桶头是 node1
  • A 插入 node2
  • B 又插入 node3

可能导致其中一个线程的修改被覆盖,出现 数据丢失


2. resize 扩容时可能导致元素丢失或结构异常

多个线程同时触发扩容时,问题更明显。

因为扩容过程包含:

  • 创建新数组
  • 遍历旧数组
  • 迁移元素到新数组
  • 替换引用

这些步骤不是原子操作。

如果多个线程同时扩容,可能出现:

  • 元素丢失
  • 元素重复
  • 链表结构混乱

3. JDK 7 中甚至可能形成死循环

这是面试里很经典的问题。

原因

JDK 7 的 HashMap 在扩容迁移时,采用 头插法 转移链表节点。

在多线程下,链表迁移时如果线程交叉执行,可能会把链表指针改乱,形成 环形链表

后果:

  • get() 时遍历链表可能进入死循环
  • CPU 飙高

注意

  • JDK 7:有环形链表死循环风险
  • JDK 8:迁移方式做了优化,基本避免了这个经典死循环问题
  • JDK 8 依然线程不安全,仍然可能数据丢失、覆盖、状态不一致

4. size 可能不准确

多个线程同时 put/remove 时,size 的更新不是线程安全的。

所以并发情况下:

  • 实际元素个数和 size() 返回值可能不一致

三、为什么说它本质上不安全

因为 HashMap 的关键操作都不是原子性的,比如:

  • 计算位置
  • 判断桶是否为空
  • 插入节点
  • 修改 next 指针
  • 扩容迁移
  • 更新 size

这些步骤在单线程下没问题,但多线程并发执行时,会互相打断,导致状态不一致。


四、和 ConcurrentHashMap 的区别

如果面试官继续追问,通常会问这个。

HashMap

  • 允许 keynull
  • 允许 valuenull
  • 非线程安全
  • 适合单线程环境

ConcurrentHashMap

  • 线程安全
  • 不允许 keyvaluenull
  • 并发性能比 Hashtable 更好
  • 适合多线程环境

五、简洁版回答

HashMap 底层在 JDK 8 中是数组 + 链表 + 红黑树。put 时先对 key 做 hash 扰动运算,再通过 (n - 1) & hash 定位桶下标。如果桶为空就直接插入,如果发生哈希冲突,就挂到链表上;当链表长度超过 8 且数组长度达到 64 时,会转成红黑树。HashMap 在元素个数超过 capacity * loadFactor 时会扩容,默认负载因子是 0.75。

HashMap 线程不安全,原因是它没有加锁,并发 put、resize 时会出现数据覆盖、元素丢失、size 不准确等问题。JDK 7 中扩容采用头插法,在多线程下还可能形成环形链表,导致死循环;JDK 8 虽然优化了迁移方式,但本质上仍然不是线程安全的。


六、面试官可能继续深挖的问题

1)为什么容量要是 2 的幂?

因为这样 (n - 1) & hash 等价于取模,效率更高,而且分布更均匀。

2)为什么负载因子默认 0.75?

这是时间和空间的折中:

  • 太小:扩容频繁,浪费空间
  • 太大:冲突增多,查询变慢

3)为什么链表长度超过 8 才树化?

因为链表较短时,遍历成本不高,红黑树维护成本反而更大;超过 8 后,树化更划算。

4)为什么数组长度小于 64 不树化?

优先扩容。因为很多冲突只是数组太小导致的,扩容后链表可能会变短,不一定需要红黑树。