一、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) 时:
-
先算 hash
-
定位桶下标
-
先比较桶头节点
-
如果没找到:
- 是链表就遍历链表
- 是红黑树就走树查找
时间复杂度:
- 理想情况:
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
- 允许
key为null - 允许
value为null - 非线程安全
- 适合单线程环境
ConcurrentHashMap
- 线程安全
- 不允许
key或value为null - 并发性能比
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 不树化?
优先扩容。因为很多冲突只是数组太小导致的,扩容后链表可能会变短,不一定需要红黑树。