评论

收藏

[Java] HashMap底层实现原理详解

编程语言 编程语言 发布于:2021-08-16 18:18 | 阅读数:478 | 评论:0

一、快速入门
示例:有一定基础的小伙伴们可以选择性的跳过该步骤
HashMap是Java程序员使用频率最高的用于映射键值对(key和value)处理的数据类型。随着JDK版本的跟新,JDK1.8对HashMap底层的实现进行了优化,列入引入红黑树的数据结构和扩容的优化等。本文结合JDK1.7和JDK1.8的区别,深入探讨HashMap的数据结构实现和功能原理。
Java为数据结构中的映射定义了一个接口java.uti.Map,此接口主要有四个常用的实现类,分别是HashMap,LinkedHashMap,Hashtable,TreeMap,IdentityHashMap。本篇文章主要讲解HashMap以及底层实现原理。

1.HashMap的常用方法
//  Hashmap存值:----------------------------------》 .put("key","value"); ----------》无返回值。
//
//  Hashmap取值:----------------------------------》 .get("key");-------------------》 返回Value的类型。
//
//  Hashmap判断map是否为空:-----------------------》 .isEmpty(); -------------------》返回boolean类型。
//
//  Hashmap判断map中是否存在这个key:--------------》.containsKey("key");------------》返回boolean类型。
//
//  Hashmap判断map中是否含有value:----------------》.containsValue("value");-------》返回boolean类型。
//
//  Hashmap删除这个key值下的value:----------------》.remove("key");-----------------》返回Value的类型。
//
//  Hashmap显示所有的value值:---------------------》.values(); --------------------》返回Value的类型。
//
//  Hashmap显示map里的值得数量:-------------------》.size(); ----------------------》返回int类型
//
//  HashMap显示当前已存的key:---------------------》 .keySet();-------------------》返回Key的类型数组。
//
//  Hashmap显示所有的key和value:-----------------》.entrySet());------------------》返回Key=Value类型数组。
//
//  Hashmap添加另一个同一类型的map:--------------》.putAll(map); -----------------》(参数为另一个同一类型的map)无返回值。
//
//  Hashmap删除这个key和value:------------------》.remove("key", "value");-------》(如果该key值下面对应的是该value值则删除)返回boolean类型。
//
//  Hashmap替换这个key对应的value值(JDK8新增):---》.replace("key","value");-------》返回被替换掉的Value值的类型。
//
//  克隆Hashmap:-------------------------------》.clone(); ---------------------》返回object类型。
//
//  清空Hashmap:-------------------------------》.clear(); ---------------------》无返回值。

2.HashMap的几个重要知识点
  • HashMap是无序且不安全的数据结构。
  • HashMap 是以key–value对的形式存储的,key值是唯一的(可以为null),一个key只能对应着一个value,但是value是可以重复的。
  • HashMap 如果再次添加相同的key值,它会覆盖key值所对应的内容,这也是与HashSet不同的一点,Set通过add添加相同的对象,不会再添加到Set中去。
  • HashMap 提供了get方法,通过key值取对应的value值,但是HashSet只能通过迭代器Iterator来遍历数据,找对象。

二、JDK7与JDK8的HashMap区别
既然讲HashMap,那就不得不说一下JDK7与JDK8(及jdk8以后)的HashMap有什么区别:
  • jdk8中添加了红黑树,当链表长度大于等于8的时候链表会变成红黑树
  • 链表新节点插入链表的顺序不同(jdk7是插入头结点,jdk8因为要把链表变为红 黑树所以采用插入尾节点)
  • hash算法简化 ( jdk8 )
  • resize的逻辑修改(jdk7会出现死循环,jdk8不会)

三、HashMap的容量与扩容机制

1.HashMap的默认负载因子
/**
  * The load factor used when none specified in constructor.
  */
 static final float DEFAULT_LOAD_FACTOR = 0.75f;
 /**
  *默认的负载因子是0.75f,也就是75% 负载因子的作用就是计算扩容阈值用,比如说使用
  *无参构造方法创建的HashMap 对象,他初始长度默认是16 阈值 = 当前长度 * 0.75 就
  *能算出阈值,当当前长度大于等于阈值的时候HashMap就会进行自动扩容
  */
面试的时候,面试官经常会问道一个问题:为什么HashMap的默认负载因子是0.75,而不是0.5或者是整数1呢?
答案有两种:
  • 阈值(threshold) = 负载因子(loadFactor) x 容量(capacity) 根据HashMap的扩容机制,他会保证容量(capacity)的值永远都是2的幂 为了保证负载因子x容量的结果是一个整数,这个值是0.75(4/3)比较合理,因为这个数和任何2的次幂乘积结果都是整数。
  • 理论上来讲,负载因子越大,导致哈希冲突的概率也就越大,负载因子越小,费的空间也就越大,这是一个无法避免的利弊关系,所以通过一个简单的数学推理,可以测算出这个数值在0.75左右是比较合理的

2.HashMap的扩容机制
写数据之后会可能触发扩容,HashMap结构内,我记得有一个记录当前数据量的字段,这个数据量字段到达扩容阈值的话,它就会触发扩容的操作
阈值(threshold) = 负载因子(loadFactor) x 容量(capacity)
当HashMap中table数组(也称为桶)长度 >= 阈值(threshold) 就会自动进行扩容。
扩容的规则是这样的,因为table数组长度必须是2的次方数,扩容其实每次都是按照上一次tableSize位运算得到的就是做一次左移1位运算,
假设当前tableSize是16的话 16转为二进制再向左移一位就得到了32 即 16 << 1 == 32 即扩容后的容量,也就是说扩容后的容量是当前
容量的两倍,但记住HashMap的扩容是采用当前容量向左位移一位(newtableSize = tableSize << 1),得到的扩容后容量,而不是当前容量x2
问题又来了,为什么计算扩容后容量要采用位移运算呢,怎么不直接乘以2呢?
这个问题就比较简单了,因为cpu毕竟它不支持乘法运算,所有的乘法运算它最终都是再指令层面转化为了加法实现的,这样效率很低,如果用位运算的话对cpu来说就非常的简洁高效。

3.HashMap中散列表数组初始长度
/**
 * The default initial capacity - MUST be a power of two.
 */
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
 
/**
 * HashMap中散列表数组初始长度为 16 (1 << 4)
 * 创建HashMap的时候可以设置初始化容量和设置负载因子,
 * 但HashMap会自动优化设置的初始化容量参数,确保初始化
 * 容量始终为2的幂
 */
老问题又来了,为啥HashMap中初始化大小为什么是16呢?
首先我们看hashMap的源码可知当新put一个数据时会进行计算位于table数组(也称为桶)中的下标:
int index =key.hashCode()&(length-1);
hahmap每次扩容都是以 2的整数次幂进行扩容
因为是将二进制进行按位于,(16-1) 是 1111,末位是1,这样也能保证计算后的index既可以是奇数也可以是偶数,并且只要传进来的key足够分散,均匀那么按位于的时候获得的index就会减少重复,这样也就减少了hash的碰撞以及hashMap的查询效率。
那么到了这里你也许会问? 那么就然16可以,是不是只要是2的整数次幂就可以呢?
答案是肯定的。那为什么不是8,4呢? 因为是8或者4的话很容易导致map扩容影响性能,如果分配的太大的话又会浪费资源,所以就使用16作为初始大小。

四、HashMap的结构
JDK7与JDK8及以后的HashMap结构与存储原理有所不同:
Jdk1.7:数组 + 链表 ( 当数组下标相同,则会在该下标下使用链表)
Jdk1.8:数组 + 链表 + 红黑树 (预值为8 如果链表长度 >=8则会把链表变成红黑树 )
Jdk1.7中链表新元素添加到链表的头结点,先加到链表的头节点,再移到数组下标位置
Jdk1.8中链表新元素添加到链表的尾结点
(数组通过下标索引查询,所以查询效率非常高,链表只能挨个遍历,效率非常低。jdk1.8及以
上版本引入了红黑树,当链表的长度大于或等于8的时候则会把链表变成红黑树,以提高查询效率)

五、HashMap存储原理与存储流程

1.HashMap存储原理
  • 获取到传过来的key,调用hash算法获取到hash值
  • 获取到hash值之后调用indexFor方法,通过获取到的hash值以及数组的长度算
  • 出数组的下标 (把哈希值和数组容量转换为二进,再在数组容量范围内与哈希值
  • 进行一次与运算,同为1则1,不然则为0,得出数组的下标值,这样可以保证计算出的数组下标不会大于当前数组容量)
  • 把传过来的key和value存到该数组下标当中。
  • 如该数组下标下以及有值了,则使用链表,jdk7是把新增元素添加到头部节点 jdk8则添加到尾部节点。

2.HashMap存储流程
前面寻址算法都是一样的,根据key的hashcode经过高低位异或之后的值,再按位与 &(table.lingth - 1),得到一个数组下标,然后根据这个数组下标内的状况,状况不同,然后情况也不同,大概分为了4种状态:
( 1.)第一种就是数组下标下内容为空:
这种情况没什么好说的,为空据直接占有这个slot槽位就好了,然后把当前.put方法传进来的key和value包装成一个node对象,放到这个slot中就好了。
( 2.)第二种情况就是数组下标下内容不为空,但它引用的node还没有链化:
这种情况下先要对比一下这个node对象的key与当前put对象的key是否完全.相等,如果完全相等的情况下,就行进行replace操作,把之前的槽位中node.下的value替换成新的value就可以了,否则的话这个put操作就是一个正儿.八经的hash冲突,这种情况在slot槽位后面追加一个node就可以了,用尾插法 ( 前面讲过,jdk7是把新增元素添加到头部节点,而jdk8则添加到尾部节点)。
( 3.)第三种就是该数组下标下内容已经被链化了:
这种情况和第二种情况处理很相似,首先也是迭代查找node,看看链表上中元素的key,与当前传过来的key是否完全一致,如果完全一致的话还是repleace操作,用put过来的新value替换掉之前node中的value,否则的话就是一致迭代到链表尾节点也没有匹配到完全一致的node,就和之前的一样,把put进来数据包装成node追加到链表的尾部,再检查一下当前链表的长度,有没有达到树化阈值,如果达到了阈值就调用一个树化方法,树化操作都是在这个方法里完成的。
( 4.)第四种情况就是冲突很严重的情况下,这个链表已经转化成红黑树了:
红黑树就比较复杂 要将清楚这个红黑树还得从TreeNode说起 TreeNode继承了Node结构,在Node基础上加了几个字段,分别是指向父节点parent字段,指向左子节点left字段,指向右子节点right字段,还有一个表示颜色的red字段,这就是TreeNode的基本结构,然后红黑树的插入操作,首先找到一个合适的插入点,就是找到插入节点的父节点,然后红黑树它又满足二叉树的所有特性,所以找这个父节点的操作和二叉树排序是完全一致的,然后说一下这个二叉树排序,其实就是二分查找算法映射出来的结构,就是一个倒立的二叉树,然后每个节点都可以有自己的子节点,本且左节点小于但前节点,右节点大于当前节点,然后每次向下查找一层就能那个排除掉一半的数据,查找效率非常的高效,当查找的过程中也是分情况的。
首先第一种情况就是一直向下探测,直到查询到左子树或者右子树位null,说明整个树中,并没有发现node链表中的key与当前put key一致的TreeNode,那此时探测节点就是插入父节点的所在了,然后就是判断插入节点的hash值和父节点的hash值大小决定插入到父节点的左子树还是右子树。当然插入会打破平衡,还需要一个红黑树的平衡算法保持平衡。
其次第二种情况就是根节点在向下探测过程中发现TreeNode中key与当前put的key完全一致,然后就也是一次repleace操作,替换value。

六、jdk8中HashMap为什么要引入红黑树?
其实主要就是为了解决jdk1.8以前hash冲突所导致的链化严重的问题,因为链表结构的查询效率是非常低的,他不像数组,能通过索引快速找到想要的值,链表只能挨个遍历,当hash冲突非常严重的时候,链表过长的情况下,就会严重影响查询性能,本身散列列表最理想的查询效率为O(1),当时链化后链化特别严重,他就会导致查询退化为O(n)为了解决这个问题所以jdk8中的HashMap添加了红黑树来解决这个问题,当链表长度>=8的时候链表就会变成红黑树,红黑树其实就是一颗特殊的二叉排序树嘛,这个时间复杂…反正就是要比列表强很多

七、扩容后的新table数组,那老数组中的这个数据怎么迁移呢
迁移其实就是挨个桶位推进迁移,就是一个桶位一个桶位的处理,主要还是看当前处理桶位的数据状态把,这里也是分了大概四种状态:
这四种的迁移规则都不太一样
(1.)第一种就是数组下标下内容为空:
这种情况下就没什么可说的,不用做什么处理。
( 2.)第二种情况就是数组下标下内容不为空,但它引用的node还没有链化:
当slot它不为空,但它引用的node还没有链化的时候,说明这个槽位它没有发生过hash冲突,直接迁移就好了,根据新表的tableSize计算出他在新表的位置,然后存放进去就好了。
( 3.)第三种就是slot内储存了一个链化的node:
当node中next字段它不为空,说明槽位发生过hash冲突,这个时候需要把当前槽位中保存的这个链表拆分成两个链表,分别是高位链和低位链
(4.)第四种就是该槽位储存了一个红黑树的根节点TreeNode对象:
这个就很复杂了,本文章暂时不做过多的介绍(博主还没整明白 =_=! )
到此这篇关于HashMap底层实现原理详解的文章就介绍到这了,更多相关HashMap底层实现原理内容请搜索服务器之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持CodeAE代码之家
原文链接:https://blog.csdn.net/weixin_49822811/article/details/113804402

关注下面的标签,发现更多相似文章