HashMap 的底层结构
在 Java 中,HashMap
是基于哈希表实现的键值对存储结构,其底层数据结构在不同版本的 JDK 中有所演变。
JDK 1.7 及之前:数组 + 链表
在 JDK 1.7 及更早版本中,HashMap
的底层采用数组和链表的组合方式。数组用于存储键值对的引用,链表用于解决哈希冲突。当多个键的哈希值映射到同一数组索引时,这些键值对会以链表的形式链接在该索引位置。
JDK 1.8 及之后:数组 + 链表 + 红黑树
从 JDK 1.8 开始,为了优化在大量哈希冲突情况下的性能,HashMap
引入了红黑树。当链表长度超过阈值(默认值为 8)且数组长度大于等于 64 时,链表会转换为红黑树,以提高查找效率。这种结构在哈希冲突严重时,能将最坏情况下的时间复杂度从 O(n) 降低到 O(log n)。
具体实现细节:
-
数组(table 数组):
HashMap
内部维护了一个Node<K,V>[]
类型的数组,称为表(table)。数组中的每个元素称为一个桶(bucket),用于存储键值对。 -
节点(Node): 每个桶中存储的是一个
Node
对象,Node
包含四个属性:键(key)、值(value)、哈希值(hash)和指向下一个节点的引用(next)。 -
哈希计算: 当向
HashMap
中插入一个键值对时,首先会对键的hashCode()
进行哈希计算,得到哈希值。然后通过(n - 1) & hash
计算出该键值对在数组中的索引位置(n
为数组长度)。 -
处理哈希冲突: 如果计算出的索引位置已有其他键值对存在(即发生哈希冲突),
HashMap
会将新的键值对以链表的形式链接在该索引位置的后面。如果链表长度超过阈值且数组长度足够大,链表会转换为红黑树。 -
扩容机制: 当
HashMap
中的键值对数量超过容量(数组长度)与负载因子(默认值为 0.75)的乘积时,HashMap
会进行扩容。扩容时,数组长度翻倍,并重新计算每个键值对在新数组中的位置。
负载因子为什么是 0.75:太低很多位置未被使用,浪费空间;太高红黑树和链表太长,查询效率低。
通过上述结构和机制,HashMap
实现了对键值对的高效存储和快速查找。
底层数据结构 | 本质 |
---|---|
数组的元素(桶) | 基于 Node 节点存储键值对 |
桶中的元素(链表/红黑树) | 链表长度大于 8 且数组长度大于 64 时为提高效率转换成红黑树;红黑树节点小于 6 时转换成链表 |
HashMap 是线程安全的吗?
HashMap
不是线程安全的。它在多线程环境下可能会导致数据不一致、死循环等问题,因此在并发场景下不能直接使用 HashMap
。
多线程下 HashMap 可能会出现的问题
在多线程环境中,如果多个线程同时对 HashMap
进行修改,可能会引发以下问题:
1. 数据丢失(覆盖)
- 多个线程同时执行
put()
操作,可能会覆盖彼此的值,导致数据丢失。 - 例如,线程 A
put(key1, value1)
,线程 Bput(key1, value2)
,最终的值可能是value1
或value2
,取决于哪个线程最后执行完成。
示例:最终值为7,3被覆盖
2. 竞态条件
HashMap
在put()
时可能会修改链表或红黑树结构,而这些操作不是原子性的,在并发修改时可能导致数据不一致。
3. 死循环(JDK 1.7 版本)
- 在 JDK 1.7 版本中,
HashMap
采用头插法进行扩容(rehash),多线程环境下可能导致链表形成循环引用,导致get()
操作时 CPU 100% 占用,进入死循环。
示例:
扩容前:
Node1 -> Node2 -> Node3 -> null
(原链表,Node1.next = Node2
)
扩容时(头插法):
- 线程 A 处理
Node1
:- 还没来得及修改
Node1.next
,即Node1.next
依旧指向Node2
。
- 还没来得及修改
- 线程 B 认为
Node1
已经处理完毕,开始使用头插法处理Node2
:- 但此刻
Node1.next
还是Node2
,B 仍然按照Node2.next = Node1
的逻辑执行。
- 但此刻
- 最终形成环形链表:
环形结构 产生,导致Node1 -> Node2 ^ | | v Node2 <- Node1
get()
查询时无限循环,CPU 100% 占用。
JDK 1.8 解决方案:
- JDK 1.8 改为尾插法,并引入了红黑树,降低了链表操作时的竞争风险,但
HashMap
仍然不是线程安全的。
4. 扩容时数据丢失
- put 和 get 并发时,可能导致 get 为 null。线程 1 执行 put 时,因为元素个数超出阈值而导致出现扩容,线程 2 此时执行 get,就有可能出现这个问题。
- 因为线程 1 执行完 table = newTab 之后,线程 2 中的 table 此时也发生了变化,此时去 get 的时候当然会 get 到 null 了,因为元素还没有转移。
HashMap 多线程会出现的问题
情况 | 问题 |
---|---|
扩容 | JDK 1.7 因使用头插法,扩容时可能出现环形链表导致死循环,由 JDK 8 使用尾插法从而修复这一问题。 |
put 覆盖 | 多个 put 并发,最后一个 put 进程会覆盖前面所有的键值对,导致部分 put 内容丢失。 |
put 和 get 并发 | 执行 put 时发现当前超过阈值需要扩容,此时 table = newtable 改变了引用,但因还未开始移动数据,此时 get 只能得到 null。 |
如何解决 HashMap 线程不安全问题?
1. 使用 ConcurrentHashMap
(推荐)
ConcurrentHashMap
是HashMap
的线程安全版本,底层采用分段锁(JDK 1.7) 或 CAS + 线程安全链表/红黑树(JDK 1.8),可以安全地支持多线程访问。
CAS 的原理
CAS 需要三个操作数:
1.V:要操作的变量的当前值(内存中的值)。
2.E(Expected):期望值,即我们认为 V 该有的值。
3.N(New):新的值,我们想要修改成的值。
🔸 逻辑流程:
1.比较:如果V == E
(当前值等于期望值),说明没有被别的线程修改,可以安全更新。
2.交换:把V
更新为N
,完成修改。
3.失败:如果V ≠ E
,说明在我们修改前,它已经被其他线程改了,这次修改失败,需要重新获取最新值,再次尝试。
2. 使用 Collections.synchronizedMap()
Map<String, String> map = Collections.synchronizedMap(new HashMap<>());
- 这种方式会对
HashMap
的所有方法加上synchronized
,但它的锁粒度较大,性能不如ConcurrentHashMap
。
3. 使用 Hashtable
(不推荐)
Hashtable
是HashMap
的早期线程安全版本,所有方法都加了synchronized
,但性能低,不推荐使用。
方法 | 本质 | 评价 |
---|---|---|
Hashtable | 在方法处添加 synchronized | 简单粗暴,不推荐 |
Collections. synchronizedMap (map) | 内部通过 synchronize 对象锁保证线程安全 | 简单易用,适用于低并发场景 |
CorrentHashMap | JDK 7 使用分段锁,JDK 8 使用 CAS 和 synchronized 关键字 | 推荐,适用于高并发场景 |