初尝HashMap源码

写在最前

一晃眼,参加工作也已经快两年的时间了,之前也尝试过读过JDK的源码,但是都不是系统性的,而且,读过却没有记录,转眼就忘了,于是今天尝试将部分重要的JDK的源码再理解一遍,并且记录一下吧。

基础环境

	本次阅读的源码是基于JDK1.8的,并用上了idea的部分插件。

一、整体概述

HashMap继承了AbstractMap,并且实现了Map、Cloneable和Serializable等接口,因此HashMap是可克隆、可序列化的。HashMap类与Hashtable大致相当,只是它是不同步的,并且允许key和value为null

1.0 基础概念(挺重要,能帮助理解结构)

HashMap里的元素是存放在桶里的,数据结构是个数组:Node<K,V>[] table
由于hash值是会重复的,所以必然会产生hash碰撞,HashMap则采用了链表和红黑树的方法去解决这个问题。
桶是根据元素的hash值和桶的长度n做 &运算((n-1)&hash) 去标识的,这样元素会泊松分布的分布到各个桶内。(exp(-0.5) * pow(0.5, k) / factorial(k))

&计算是在二进制时,两个位的数据都为1时,才返回1;而n-1可以保证前几位都为0,后几位都为1,能确保返回的值的大小被n-1的后几位来确定(确保都在0-(n-1)内)。

那数组内多条数据怎么存呢?
有很多的方法,HashMap选择了在数据少的时候,以链表的数据结构存储,在数据多的时候,以红黑树的数据结构存储。
每个桶是相对独立的,所以数组table内可能同时存在数据结构为链表和数据结构为红黑树的元素。

1.1、影响性能的因素

Hash函数在桶(bucket)中适当的分散了元素,这个操作为基本操作(get和put)提供了恒定的时间性能(时间复杂度为O(1))。
集合视图上的迭代所需的时间与HashMap实例的“容量”(bucket的数量)加上其大小(键值映射的数量)成比例。因此,如果使用的时候,迭代的操作多,那么不要将初始容量设置得太高(或者扩容阈值太低),这一点非常重要。
影响性能的主要的两个参数:初始容量和扩容阈值。capacity是哈希表中的桶(bucket)的数量,初始容量就是创建哈希表时的容量。
扩容阈值:决定何时增加HashMap容量以维护O(1)的get()和put()操作复杂度的度量。
扩容:扩容(resize)是一个非常耗时的操作,耗时的点在于:当要进行扩容时,需要将原数组中的数据重新计算其在新数组中的位置,然后放进去。

Q:那么我初始化的时候把初始容量设置的很大不就好了?
A:如果初始化的容量很大,但是数据很少,就会造成数据存储的非常分散,这样会占用大量的内存空间。

1.2 相关默认值

1.2.1 未指定的初始容量(16)

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
在设置初始容量时,应考虑map中的预期条目数及其荷载系数,以尽量减少重新计算hash值操作的次数。

1.2.2 最大容量(2的30次方)

static final int MAXIMUM_CAPACITY = 1 << 30;

1.2.3 默认的扩容阈值(0.75)

static final float DEFAULT_LOAD_FACTOR = 0.75f;
在默认容量的情况下,数据量达到16 * 0.75 = 12,就会扩容到16<<1 = 32。
默认的扩容阈值在时间和空间成本之间做了很好的折中,较高的扩容阈值会增加查找的开销。
Q:为什么为0.75而不是其他的数值呢?
A:在扩容阈值(加载因子)为0.75的情况下,节点出现在频率在Hash桶(表)中遵循参数平均为0.5的泊松分布。超过0.8,查表时的CPU缓存不命中(cache missing)按照指数曲线上升。总之,选择0.75作为默认的加载因子,完全是时间和空间成本上寻求的一种折衷选择。

1.2.4 树形化阈值(8)

static final int TREEIFY_THRESHOLD = 8;
使用树(而不是列表)的桶的计数阈值。将元素添加到至少具有这么多节点的桶时,存储单元将转换为树。该值必须大于2,且至少应为 TREEIFY_THRESHOLD - 1,以符合树形移除中关于在收缩时转换回普通桶的假设。

1.2.5 链表还原阀值(6)

static final int UNTREEIFY_THRESHOLD = 6;
桶量小于该阈值,则会从红黑树转换为单链表结构。

1.2.6 最小树形化容量阈值(64)

static final int MIN_TREEIFY_CAPACITY = 64;
当哈希表中的容量大于该值时,才允许树形化链表(即将链表转换成红黑树)

二、底层结构

2.1 数据结构

在第一次使用时初始化,并根据需要调整大小。分配时,数组长度总是2的幂次。(在某些操作中,我们也允许长度为零,以允许当前不需要的引导机制。)
HashMap的数据就是存放在这个数组中。数组中的元素是由Node组成的。TreeNode继承了Node。
所以HashMap的基本数据结构是 数组+链表(或红黑树)

PS: 链表和红黑树可能同时存在于不同的桶中哦~换句话说,数组中可能同时存在数据结构为链表的数据和数据结构为红黑树的数据

在这里插入图片描述

2.1.1 链表结构

在map还处于链表结构的时候,里面的结构有hash值、键、值、下个节点等相关属性。并为value设置了get、set方法,重写了equals方法。
equals方法的计算是:
1.判断要比较的数据是否和当前对象是同一个对象,是则返回true。
2.判断传入的对象是否实现了Map.Entry,没有实现,则返回true。
3.判断传入的key和当前对象的key、传入的value和当前对象的value是否都相等,相等则返回true。
hashcode的计算是key和value各自的hash值做异或得到的。
在这里插入图片描述

2.1.2 树形结构(红黑树)

相比与链表结构,多指向了父节点(parent)、左节点(left)、右节点(right)、上一节点(prev)等属性,并且多了一个boolean类型的red属性,用来标识当前节点是红色节点还是黑色节点。
在这里插入图片描述

2.2 初始化

HashMap有多个构造方法:
方法一:public HashMap(int initialCapacity, float loadFactor)
可以在初始化的时候指定初始容量(initialCapacity),扩容阈值(loadFactor)
方法二:public HashMap(int initialCapacity)
会调用方法一,指定初始容量为用户传入的容量,扩容阈值为默认值
方法三:public HashMap()
以默认值的方式加载
方法四:public HashMap(Map<? extends K, ? extends V> m)
传入一个继成了Map的对象,将扩容阈值加载为默认值,而后会调用 putMapEntries(Map<? extends K, ? extends V> m, boolean evict) 方法。
putMapEntries方法的具体实现如下图:

  1. 先取传入的map的长度,判断长度是否大于0
  2. 再判断当前hashMap的数组中是否已经初始化。
    2.1.未初始化则计算出一个float类型的负载因子ft,计算的方法是传入map的长度除以默认(已指定)负载因子,再加1(为了向上取整)。
    2.1.1.再判断ft是否大于最大容量,计算出初始容量,大于则设置为最大值,不然就用ft取整作为计算的容量t。
    2.1.2.再判断t是否大于threshold,大于则把容量通过tableSizeFor()方法计算出来,并赋值给threshold。
    2.2.初始化了,就判断传入map的长度是否大于当前map的threshold
    2.2.1如果是,必须要resize()来重新计算大小。
  3. 在计算完初始的相关属性后,循环的把数据从源map中拷贝到当前的map中,在赋值过程中,还会再次调用resize()来计算大小。

在这里插入图片描述

2.3 Map中元素的操作(链表结构)

2.3.1 读取get(key)

调用get方法时,会调用getNode来返回Node节点,getNode方法需要传入hash,于是get方法计算了key的hash值,hash()方法计算hash值时,先取出key的hashcode,再与该hashcode的高16位做异或。

在这里插入图片描述

在getNode方法,才是真正的取出节点数据的操作。
1.首先需要同时满足以下三个条件:数组不为空、数组长度>0、通过hash计算出该元素在数组中存放位置的索引,而且该索引处数据不为空null。不满足则返回null。
2.判断该数组索引位置处第一个是否为我们要找的元素。需要满足hash值和key都相同,第一个就是,那么直接返回第一个节点。
3.判断当前节点的数据结构,如果已经是红黑树类型,则调用TreeNode的getTreeNode方法查找节点。
4.如果是其他(其实就是链表),那么遍历该链表,循环判断下一个节点的hash、key是否和传入的hash、key相同,相同则返回该节点。
5.结束循环的条件是没有下一节点了,那么就返回null,表示没找到。
在这里插入图片描述

2.3.2 存储put(key,value)

put方法一样需要计算key的hash值,来确定将数据存放到哪个桶内。而后调用了putval方法。
在这里插入图片描述
putval方法才是真正的存入节点内容。
先看后两个需要传入的参数

  • 1.onlyIfAbsent,如果为true,不改变现有值。
  • 2.evict,如果为false,则是创建过程。

所以这里传入的是onlyIfAbsent=false,evict=true,说明在put时,遇到重复的key,会改变原有的value。接下来看看这个方法里都做了什么:
1.如果table数组为空的或者table的长度为0,那么调用resize()进行数组扩容。
2.取出该索引处的节点,如果为空,则表明没有发生hash冲突,直接新建一个node。
3.如果不为空,则表明发生了hash冲突。判断第一个节点key的hash值和key是否与传入的key的hash和key值相等,相等则说明应该存到这个桶里,先进行保存。
4.如果第一个节点和传入的不同,则判断当前的数据结构是否为红黑树,为红黑树,则调用TreeNode的putTreeVal方法去存储。
5.不然就是为链表结构,以链表的方式去存储。
6.接下来就是对该桶做循环,判断桶内的下一节点是否为空,为空则说明到达末尾,新建一个节点插入即可。而后判断链表的长度是否大于树形化阈值,若大于,则改用红黑树的方式存储。
7.而后判断该节点的值是否已经存在了,存在则更新该值并返回。返回前有一个afterNodeAccess方法,该方法没有实现逻辑,假如继承并重写HashMap,可以自己编写该方法里的内容。
8.加入元素后,HashMap的修改次数+1,HashMap的长度也+1,假如此时HashMap的长度大于threshold,则调用resize()来重新计算大小。
9.后面还有一个afterNodeInsertion方法,该方法没有实现逻辑,假如继承并重写HashMap,可以自己编写该方法里的内容。
在这里插入图片描述

2.3.3 移除remove(key)

移除节点,要传入需要删除的节点的key,同样需要计算hash值来确定在哪个桶内,而后调用removeNode方法。(remove还含有一个传入key、value两个参数的方法,这个方法需要key和value都匹配才进行删除)

在这里插入图片描述
调用removeNode才会去具体的删除,removeNode需要额外传入两个值:

  • 1.matchValue:为true时还需要传入key对应的value,并且在传入的value和已存的value相等时,才会真的删除
  • 2.movable: 如果为false,则在移除时不会移动其他节点

调用只含有key的remove方法时,传入的是matchValue=false;movable=true。所以在删除节点时,不需要传入value,并且删除完成后会自动将root节点设为该桶的第一个节点。接下来看看这个方法具体都干了什么:
1.如果节点数组tab不为空、数组长度n大于0、根据hash定位到的节点对象p(该节点为树的根节点或链表的首节点)不为空,那么可以开始查找,不然直接返回null。
2.接下来判断第一个节点的hash值、key和传入的hash值、key是否相等,是的话就说明该桶的第一个就是要删除的节点,标记为node。
3.上一步不相等,那么首先判断是否有下一节点,没有也会返回null
4.存在下一节点,那么先判断该桶的数据是链表存放还是红黑树存放,如果是红黑树结构的,就调用TreeNode.getTreeNode方法去查找出对应的节点,并记录node。
5.是链表结构,就遍历该桶内的所有数据,每一次遍历,都用p节点来标记该节点e,然后把该节点e指向e的子节点,有key和hash值都和传入的相等就记录下该node,跳出循环,到最后一节点都没有找到匹配的,那么同样会跳出循环,并返回null
6.找到了相应的节点,同样还需要判断桶的类型,如果是红黑树结构,就调用TreeNode.removeTreeNode方法去做真正的删除。
7.假如是链表结构,那么判断该节点e是否为首节点,是的话就把桶指向该节点e的子节点。
8.不是首节点,此时p节点是该节点e的父节点,要删除该节点e,就把p节点的子节点,设置为该节点e的子节点,就完成了删除。
9.最后标记HashMap的修改次数+1,HashMap的元素个数-1。
10.后面还有一个afterNodeRemoval方法,该方法没有实现逻辑,假如继承并重写HashMap,可以自己编写该方法里的内容。

在这里插入图片描述

2.3.4 清除clear()

调用该方法,会把数组中所有数据清除,会把HashMap的size重新置为0,并把每个桶里的根节点置为null,之前数据占用的内存,则会被jvm自动gc掉。
在这里插入图片描述

2.3.5 替换replace(key,value)

replace有两个方法,一个是传入key、value直接就可进行替换的;另一个还需要传入oldValue,当oldValue与map中现有value匹配时,才会进行删除。
1.基础的替换操作,首先调用了getNode去获取出node,而后再替换value,并返回用户oldValue。
2.传入了oldValue的replace方法,则会返回boolean格式的数据,来标识是否替换成功。
3.在返回前有个afterNodeAccess方法,该方法没有实现逻辑,假如继承并重写HashMap,可以自己编写该方法里的内容。
在这里插入图片描述

2.4 树形化与取消树形化

2.4.1 树形化

存储的时候,当链表节点中元素的长度大于阈值8时,将会调用treeifyBin方法。
treeifyBin方法,会先判断桶的数量,当桶的数量小于最小树形化阈值64时,不会树形化,而是调用resieze方法进行扩容。而当数组table的当前链表节点的长度大于 TREEIFY_THRESHOLD - 1,hashMap中的总元素个数大于64时,才会将所有链表节点,转换为红黑树类型。转换的方法:

  1. 通过hash求出桶的位置。
  2. 将每个节点包装成TreeNode
  3. 将所有TreeNode连接在一起,此时只是链表结构
  4. 遍历完该链表的所有节点后,调用treeify方法

在这里插入图片描述

  1. 遍历传入的链表
  2. 为下一节点、左节点、右节点、根结点(如果根节点为空)赋值
  3. 取出当前节点的key和hash值
  4. 将root节点赋值给p,自顶向下的循环节点,比较当前项与当前树中访问节点hash值判断加入项的路径,dir=-1为左子树,dir=1为右子树
  5. 将p赋值给xp,p的左(右)子树为空,则当前节点的父节点=p,如果xp的hash值大于x的hash值,将x添加在xp的左边,否则,添加在xp的右边。
  6. 添加节点后,程序通过balanceInsertion方法,计算红黑树的红黑结构,维持平衡
  7. 最后调用moveRootToFront方法,确保其root节点是该桶的第一个节点

在这里插入图片描述

2.4.2 树形还原

当桶内的节点数量小于UNTREEIFY_THRESHOLD(默认6)时或TreeNode的根节点、左节点、右节点有任意一个为空时,会从红黑树的数据结构,转换回链表类型的数据结构,以使HashMap的性能达到最优。还原的过程较为简单,具体如下:
1.定义两个链表,一个为要返回的链表hd,另一个为零时链表tl,遍历该桶的所有节点,取出该桶的根节点并将其指向q,q不为空则继续循环,执行结束将q的子节点指向q。
2.调用replacementNode,新实例化出一个Node,Node的hash、key、value分别由本节点的对应值赋值,并将其指向p。
3.假如当前为第一次执行,那么将hd指向链表的首节点。
4.如果不是第一次执行,则将tl的子节点(此时也是尾结点)保存为p。
5.将该节点tl指向p,即作为尾结点。
6.最后返回转换后的链表的头结点。
在这里插入图片描述

2.5 HashMap的内部类TreeNode

TreeNode的基本数据结构在上面介绍了,主要含有根节点、左节点、右节点、父节点、节点颜色等基本属性。

2.5.1 moveRootToFront()

这个方法会重新整理桶内的节点,使根节点在桶内确保为第一节点
在这里插入图片描述

2.5.2 插入putTreeVal()

这个方法就是上面调用的TreeNode.putTreeVal方法的具体实现了。红黑树就是通过这个方法来插入数据的。
1.首先先找出根节点,因为当前传入的可能并不是根节点。
2.从根节点开始遍历,通过hash来确定数据在红黑树中具体的存放位置。
3.如果当前的元素的hash值大于传入的key的hash值,那么要添加的元素应该放置在当前节点的左侧。
4.如果当前的元素的hash值小于传入的key的hash值,那么要添加的元素应该放置在当前节点的右侧。
5.如果当前节点的键对象和指定key对象相同,那么返回当前节点。
6.如果当前节点的hash值等于传入的key的hash值,但是equals不等;
7.假如key的类具有可比较大小的方法,那么判断是否已经对比过当前节点的左右子节点,没有比较过,就递归遍历进行对比,看看是否能够得到与传入的key的对象equals的节点,跳出循环的条件是找到相等的节点,并返回该节点。
8.如果还是没有键的equals相等的节点,那么通过之前标识的方向,确定要保存的节点应该放在左节点还是右节点,然后新生成一个树节点,将左或右节点指向当前节点,同时将链表中的next节点也同样指向到这个新的树节点,这个新的树节点的父节点、前节点均设置为当前的树节点。
9.而后判断原来节点的next节点是否为空,不为空,则将原来的next节点的前节点指向到新的树节点
10.最后重新计算树的平衡,将新的根节点放到该桶的首节点处。
在这里插入图片描述

2.5.3 移除removeTreeNode()

这个方法就是上面调用的TreeNode.removeTreeNode方法的具体实现了。红黑树就是通过这个方法来移除数据的。
1.还是会重新判断当前map是否为空或者长度为0,是则直接返回。
2.计算出桶的下标,将首节点标识为first,将下一节点标识为succ,将父节点标识为prev。
3.如果前一节点为空,那么将该桶的下一节点标识为该桶的首节点。
4.如果前一节点不为空,则将前一节点跳过本节点指向下一节点。
5.如果后一句的不为空,则将后一节点的前一节点指向本节点的前一节点。
6.如果首节点为空,直接返回。
7.该根节点存在父节点,说明该节点不是根节点,调用root()方法将root确保为根节点。
8.如果根节点为空或它的左右节点有一个为空,那么说明该红黑树类型的节点,已经过小了,则通过untreeify方法,将其转换回链表结构。
9.由于是红黑树,所以接下来就要遍历当前节点的子节点,计算出可以替代当前节点子节点,然后通过红黑树的删除方法,重新组织后序节点。
10.最后重新计算红黑树的平衡,使该结构重新满足红黑树的数据结构。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

2.5.4 split()

该方法会在HashMap进行扩容时会调用到,如果现在太小,则会调用untreeify方法将红黑树转换回链表结构。
在这里插入图片描述

三、总结

通过源码的阅读确能较好的让我们掌握jdk中的相关对象。
1.在jdk1.8中,当某个桶的链表长度大于TREEIFY_THRESHOLD(默认8)且HashMap的数组(桶)的长度大于MIN_TREEIFY_CAPACITY(默认64)时,就会转换为红黑树的数据结构就行存储。当某个桶的红黑树节点长度小于6时,就会转换为链表结构存储。这是HashMap为了兼顾时间和空间两方面权衡的结果。
2.当桶的数量达到DEFAULT_LOAD_FACTOR(默认0.75)* DEFAULT_INITIAL_CAPACITY (默认1 << 4即16)时,便会以2的幂次进行扩容。
3.在进行put、computeIfAbsent、compute等操作时,会调用resize方法重新进行大小的计算,这是非常耗时的。
4.当插入新元素时,会放在链表的尾部(jdk1.7是放在头部,并且只有数组+单链表结构)。
5.HashMap是线程不安全的,要想线程安全可以使用HashTable或ConcurrentHashMap ,当然这会牺牲性能,HashTable在操作时会锁住整个HashTable,ConcurrentHashMap则进行了优化,采用了分段锁,在读操作时不加锁,同时HashEntry的value是volatile的,所以也能保证读到最新的值。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值