JDK8 HashMap底层结构解析

HashMap 底层结构是什么?

JDK 8 中 HashMap 的数据结构是数组+链表+红黑树

在知道了其底层的结构之后,还需要知道这每一个结构的作用以及他们之间的相互转换关系

所以接下来要去分析 数组,链表和红黑树之间是存储了哪些信息,以及相互之间是怎么进行转换的?

数组用来存储键值对 ,每个键值对可以通过索引直接拿到,索引是通过对键的 哈希值 进行进一步的 hash() 处理得到的

当多个键经过哈希处理后得到相同的索引时,需要通过链表 来解决哈希冲突------将具有相同索引的键值对通过链表存储起来。

不过,链表过长时,查询效率会比较低,于是当链表的长度超过 8 时(且数组的长度大于 64),链表就会转换为红黑树。红黑树的查询效率是 O(logn),比链表的 O(n) 要快。

怎么处理哈希冲突的问题?这些都是要答到!

上面提到了hash()方法,这里应该去做相对应的处理!以及既然提到了底层的结构里面有数组什么的,就需要考虑到需要进行扩容等机制!

hash() 方法的目标是尽量减少哈希冲突,保证元素能够均匀地分布在数组的每个位置上。【hash()的作用】

如果键的哈希值已经在数组中存在,其对应的值将被新值覆盖。【覆盖】

HashMap 的初始容量是 16,随着元素的不断添加,HashMap 就需要进行扩容,阈值是capacity * loadFactor,capacity 为容量,loadFactor 为负载因子,默认为 0.75。【扩容机制】

扩容后的数组大小是原来的 2 倍,然后把原来的元素重新计算哈希值,放到新的数组中。

HashMap 什么时候扩容?

首先需要答到的就是hashmap如果要进行扩容的话,会扩容到原来的多少倍?--->接着就是讲数组元素重新进行一个分配的问题!

对应的进行扩容的时候,我们需要考虑三种情况:

  1. 只有一个元素的时候,当前桶中只有一个元素,应该怎么做?

  2. 如果是红黑树,需要答到用到了spilt()方法来分裂树的节点,保证树的平衡

  3. 如果是链表,需要注意 通过旧键的哈希值与旧的数组大小取模来作为条件,判断当前的数组如何进行扩容操作!

扩容时,HashMap 会创建一个新的数组,其容量是原来的两倍。然后遍历旧哈希表中的元素,将其重新分配到新的哈希表中。

如果当前桶中只有一个元素,那么直接通过键的 哈希值 与数组大小取模锁定新的索引位置e.hash & (newCap - 1)

如果当前桶是红黑树,那么会调用 split() 方法分裂树节点,以保证树的平衡

如果当前桶是链表,会通过旧键的哈希值与旧的数组大小取模 (e.hash & oldCap) == 0 来作为判断条件,如果条件为真,元素保留在原索引的位置;否则元素移动到原索引 + 旧数组大小的位置。

为什么负载因子是 0.75?

之前我们有提到过hashmap的扩容机制,这里面提到了0.75!

负载因子是什么?负载因子过大或者过小都会有什么样的问题呢?

负载因子(load factor)是一个介于 0 和 1 之间的数值,用于衡量 哈希表 的填充程度。它表示哈希表中已存储的元素数量与哈希表容量之间的比例。

  • 负载因子过高(接近 1)会导致哈希冲突增加,影响查找、插入和删除操作的效率。

  • 负载因子过低(接近 0)会浪费内存,因为哈希表中有大量未使用的空间。

默认的负载因子是 0.75,这个值在时间和空间效率之间提供了一个良好的平衡。

为什么容量要是 2 的幂?

是为了快速定位元素在底层数组中的下标

HashMap 是通过 hash & (n-1) 来定位元素下标的,n 为数组的大小,也就是 HashMap 底层数组的容量。

数组长度-1 正好相当于一个"低位掩码"------掩码的低位最好全是 1,这样 & 运算才有意义,否则结果一定是 0

2 幂次方刚好是偶数,偶数-1 是奇数,奇数的二进制最后一位是 1,也就保证了 hash &(length-1) 的最后一位可能为 0,也可能为 1(取决于 hash 的值),这样可以保证哈希值的均匀分布。

HashMap put 流程是什么?

哈希寻址 → 处理哈希冲突(链表还是红黑树)→ 判断是否需要扩容 → 插入/覆盖节点。

第一步,进行哈希寻址的操作,通过 hash 方法进一步扰动哈希值,以减少哈希冲突。

第二步,进行第一次的数组扩容;并使用哈希值和数组长度进行取模运算,确定索引位置。

如果当前位置为空,直接将键值对插入该位置;否则判断当前位置的第一个节点是否与新节点的 key 相同,如果相同直接覆盖 value,如果不同,说明发生哈希冲突。

如果是链表,将新节点添加到链表的尾部;如果链表长度大于等于 8,则将链表转换为红黑树。

每次插入新元素后,检查是否需要扩容,如果当前元素个数大于阈值(capacity * loadFactor),则进行扩容,扩容后的数组大小是原来的 2 倍;并且重新计算每个节点的索引,进行数据重新分布。

只重写元素的 equals 方法没重写 hashCode,put 的时候会发生什么?

如果只重写 equals 方法,没有重写 hashCode 方法,那么会导致 equals 相等的两个对象,hashCode 不相等,这样的话,两个对象会被 put 到数组中不同的位置,导致 get 的时候,无法获取到正确的值。

HashMap 为什么线程不安全?

HashMap 不是线程安全的,主要有以下几个问题:

①、多线程下扩容会死循环。JDK7 中的 HashMap 使用的是头插法来处理链表,在多线程环境下扩容会出现环形链表,造成死循环。

不过,JDK 8 时通过尾插法修复了这个问题,扩容时会保持链表原来的顺序。

②、多线程在进行 put 元素的时候,可能会导致元素丢失。因为计算出来的位置可能会被其他线程覆盖掉,比如说一个线程 put 3 的时候,另外一个线程 put 了 7,就把 3 给弄丢了。

这里的问题就是在多线程环境下同时put的话,会有可能导致元素的丢失问题

比如你们都是指向同一个数组位置,你们put的位置是一样,如果这时候两个元素都要添加到这个位置上,就有可能导致其中一个元素还没有完成插入的时候,计算出来的位置就被其他线程覆盖掉了

③、put 和 get 并发时,可能导致 get 为 null。线程 1 执行 put 时,因为元素个数超出阈值而扩容,线程 2 此时执行 get,就有可能出现这个问题。

这个位置的话,主要强调的就是在 hashmap进行扩容的时候,如果同时发生了put+get操作,有可能导致get出来的值是一个空值null

++那怎么去解决hashmap的++ ++线程安全++ ++问题呢?++

在早期的 JDK 版本中,可以用 Hashtable 来保证线程安全。Hashtable 在方法上加了 synchronized 关键字

更优雅的解决方案是使用并发工具包下的 ConcurrentHashMap,使用了CAS+ synchronized 关键字来保证线程安全。

JDK 1.7 和 JDK 1.8 的 HashMap 有什么区别?

那就主要讲一下JDK8对于hashmap做了哪些优化吧

①、底层数据结构由数组 + 链表改成了数组 + 链表或红黑树的结构。

如果多个键映射到了同一个哈希值,链表会变得很长,在最坏的情况下,当所有的键都映射到同一个桶中时,性能会退化到 O(n),而红黑树的时间复杂度是 O(logn)。

②、链表 的插入方式由头插法改为了尾插法。头插法在扩容后容易改变原来链表的顺序。

③、扩容的时机由插入时判断改为插入后判断,这样可以避免在每次插入时都进行不必要的扩容检查,因为有可能插入后仍然不需要扩容。

④、哈希扰动算法也进行了优化。JDK 7 是通过多次移位和异或运算来实现的。JDK 8 让 hash 值的高 16 位和低 16 位进行了异或运算,让高位的信息也能参与到低位的计算中,这样可以极大程度上减少哈希碰撞。

相关推荐
Mahir082 天前
HashMap 底层原理深度解密:从数据结构到 JDK1.7/1.8 演进全解
java·后端·面试·hashmap
Misnearch6 天前
Java中创建Map的做法
java·hashmap
少司府7 天前
C++进阶:红黑树
开发语言·数据结构·c++·b树·二叉树·红黑树
啦啦啦啦啦zzzz8 天前
数据结构:红黑树理论
数据结构·c++·红黑树
Brilliantwxx16 天前
【C++】 深入理解红黑树:实现与原理全解
数据结构·c++·笔记·算法·青少年编程·红黑树
长谷深风11121 天前
ConcurrentHashMap线程安全机制解析【个人八股】
哈希算法·cas·线程安全·hashmap·多线程并发·分段锁·桶锁
深蓝轨迹22 天前
Java 集合框架超全解 · 底层源码|集合对比|HashMap 扩容原理
java·hashmap·集合框架·arraylist·linkedlist
不知名的忻24 天前
红黑树(简易版)
算法·红黑树
长谷深风1111 个月前
多线程并发实战:从原理到应用【个人八股】
java·并发编程·线程安全·java多线程·synchronized·锁升级