HashMap 的底层结构以及线程安全性

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)。
在这里插入图片描述

具体实现细节:

  1. 数组(table 数组): HashMap 内部维护了一个 Node<K,V>[] 类型的数组,称为表(table)。数组中的每个元素称为一个桶(bucket),用于存储键值对。

  2. 节点(Node): 每个桶中存储的是一个 Node 对象,Node 包含四个属性:键(key)、值(value)、哈希值(hash)和指向下一个节点的引用(next)。

  3. 哈希计算: 当向 HashMap 中插入一个键值对时,首先会对键的 hashCode() 进行哈希计算,得到哈希值。然后通过 (n - 1) & hash 计算出该键值对在数组中的索引位置(n 为数组长度)。

  4. 处理哈希冲突: 如果计算出的索引位置已有其他键值对存在(即发生哈希冲突),HashMap 会将新的键值对以链表的形式链接在该索引位置的后面。如果链表长度超过阈值且数组长度足够大,链表会转换为红黑树。

  5. 扩容机制:HashMap 中的键值对数量超过容量(数组长度)与负载因子(默认值为 0.75)的乘积时,HashMap 会进行扩容。扩容时,数组长度翻倍,并重新计算每个键值对在新数组中的位置。

负载因子为什么是 0.75:太低很多位置未被使用,浪费空间;太高红黑树和链表太长,查询效率低。

通过上述结构和机制,HashMap 实现了对键值对的高效存储和快速查找。

底层数据结构本质
数组的元素(桶)基于 Node 节点存储键值对
桶中的元素(链表/红黑树)链表长度大于 8 且数组长度大于 64 时为提高效率转换成红黑树;红黑树节点小于 6 时转换成链表

HashMap 是线程安全的吗?

HashMap 不是线程安全的。它在多线程环境下可能会导致数据不一致、死循环等问题,因此在并发场景下不能直接使用 HashMap


多线程下 HashMap 可能会出现的问题

在多线程环境中,如果多个线程同时对 HashMap 进行修改,可能会引发以下问题:

1. 数据丢失(覆盖)
  • 多个线程同时执行 put() 操作,可能会覆盖彼此的值,导致数据丢失。
  • 例如,线程 A put(key1, value1),线程 B put(key1, value2),最终的值可能是 value1value2,取决于哪个线程最后执行完成。
示例:最终值为7,3被覆盖

在这里插入图片描述

2. 竞态条件
  • HashMapput() 时可能会修改链表或红黑树结构,而这些操作不是原子性的,在并发修改时可能导致数据不一致。
3. 死循环(JDK 1.7 版本)
  • 在 JDK 1.7 版本中,HashMap 采用头插法进行扩容(rehash),多线程环境下可能导致链表形成循环引用,导致 get() 操作时 CPU 100% 占用,进入死循环。
    在这里插入图片描述
示例:

扩容前:

  • Node1 -> Node2 -> Node3 -> null(原链表,Node1.next = Node2

扩容时(头插法):

  1. 线程 A 处理 Node1
    • 还没来得及修改 Node1.next,即 Node1.next 依旧指向 Node2
  2. 线程 B 认为 Node1 已经处理完毕,开始使用头插法处理 Node2
    • 但此刻 Node1.next 还是 Node2,B 仍然按照 Node2.next = Node1 的逻辑执行。
  3. 最终形成环形链表
    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(推荐)
  • ConcurrentHashMapHashMap线程安全版本,底层采用分段锁(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(不推荐)
  • HashtableHashMap 的早期线程安全版本,所有方法都加了 synchronized,但性能低,不推荐使用。
    在这里插入图片描述
方法本质评价
Hashtable在方法处添加 synchronized简单粗暴,不推荐
Collections. synchronizedMap (map)内部通过 synchronize 对象锁保证线程安全简单易用,适用于低并发场景
CorrentHashMapJDK 7 使用分段锁,JDK 8 使用 CAS 和 synchronized 关键字推荐,适用于高并发场景
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值