基本原理
HashMap的数据结构是由数组和链表(或者树)组成。当进行元素的增删改查时,首先要定位到元素所在桶的位置,也就是table数组的下标,之后再从链表或树中定位该元素。定位下标使用 (n - 1) & hash 算法,其中n为数组长度,hash为key的哈希值
hashmap是通过一个数组Node<K,V>[] table(该变量是一个类变量)存储所有的数据,每个元素存放元素的下标为putVal()函数中对应的局部变量p,其计算方法为:
p = tab[i = (n - 1) & hash]
其中tab即为table,n为table的长度,hash为key的哈希值
存放数据的数组中每项的数据结构是Node或者TreeNode.当数组某项对应的长度小于8时,默认使用单向链表来存储元素,当数组某项对应的长度大于等于8时,此时会将链表转化为红黑树.同理,当TreeNode中包括的元素数量较小时,此时会将红黑树转化为链表形式存储.
此处为什么需要这么做?
主要是基于查询的效率考虑.链表结构查询元素的时间复杂度为O(n),随着链表长度的增大,查询时间也会递增.而红黑树的时间复杂度为O(logn),红黑树虽然占用空间更大,此处相当于以空间换时间.
HashMap中几个特殊值说明:
- initialCapacity HashMap初始容量, 默认为16
- loadFactor 负载因子,默认0.75
- threshold 键值对数量的最大值,超过这个值,则需要扩容,该值在进行扩容的时候会改变为原先值的两倍
插入流程
首先计算key的哈希值, 然后调用putVal()函数。
下图是putval()函数的源码, 就其中的关键几步进行说明
-
当存储数据的数组为空,则调用
resize()
函数进行数组的初始化操作。该处代码表明HashMap中数组的初始化并不是在new的时候,而是在第一次put的时候。 -
使用
(n - 1) & hash
计算数组下标,若当前项不存在,则调用newNode()
函数在数组下标处新建一个节点。 -
当以上条件都不满足, 说明该处索引处已经存在节点,则需要判断该节点处的key和待存入数据的key是否相等。3、4、5是对应不同类型的。其中3是直接判断当前节点, 4则是树节点(不需要判断), 5是链表。 判断相等的条件如下(之间是且的关系):
- 将要存入数据key的哈希值和数组下标处节点的哈希值相等
- 将要存入数据的key和数组下标处节点的key相等(此处相等是内存地址相等或者equals比较相等都可以)
-
该代码表明该处数组下标处的节点是树节点, 所以此时调用
putTreeVal()
函数插入树节点。 -
当3和4都不满足时,则表明该处是一个链表,则进行链表的遍历。
-
若一直能遍历到链表尾部,则在链表尾部新建一个节点储存当前待存入数据。然后判断是否要将链表转化为红黑树, 若是则调用
treeifyBin()
函数进行链表的树化。 -
若链表中某个节点的key与待存入数据的key相等(与第3步的判断条件一样),则退出遍历。
-
判断之前是否已经存在key值相同的节点,若是此时根据onlyIfAbsent参数来决定是否将之前节点的值更新为本次要插入的值(实际上map默认的put操作该值为true,表明是覆盖插入)
-
判断是否要进行扩容操作, 若是则调用
resize()
函数进行扩容。注意此处的++resize操作可能导致线程不安全。
扩容
resize()函数。其用途用来进行扩容或者初始化数组。
下面是resize函数关键几步分析:
基本流程:
- 当扩容之前的数组长度大于最大值时,直接返回未扩容之前数组(也就是不进行扩容),此处表明当hashmap容量大于最大值时(Integer.MAX_VALUE),就无法继续插入新值
- 当元素个数大于阈值(默认初始容量16 * 负载因子0.75 = 12)时进行扩容,将新数组长度和阈值都扩大为原先2倍
- 能进入该分支表明是第一次初始化,设置数组容量大小(指定值或者默认值)和阈值大小
- 重新初始化一个新的数组,数组长度是原先的2倍
- 开始进行重哈希,也就是将原数组项的数据重新放入新的数组项里面,此处需要遍历原数组
- 此处表明当前数组项只有一个节点(肯定是链表节点),则直接将该项值插入到新数组项中
- 此处表明当前数组项是一个红黑树,进行红黑树的重新赋值
- 当以上两种情况都不符合,肯定是链表节点,则进行链表的重哈希,需要遍历链表
链表节点的重哈希 有必要说明一下,此处设计的巧妙,具体描述可见文章:https://yikun.github.io/2015/04/01/Java-HashMap%E5%B7%A5%E4%BD%9C%E5%8E%9F%E7%90%86%E5%8F%8A%E5%AE%9E%E7%8E%B0/
JDK中解释resize函数的说明大意如下:
当进行扩容时,因为我们每次扩容容量都是之前的2倍,任意元素在新数组中要么是原先的位置,要么是原位置移动2次幂的位置。
其具体原理是利用0与0或者1进行&运算结果都是0,这样在进行重hash的时候,不需要重新计算Hash值,只需要判断原先的Hash值新增的那一位是0还是1,若为0,则原先位置,若为1,则索引变为“oldCap+原索引”。
这地方有几点需要注意:
- 判断索引是否改变用的是 hash & oldCap,此处与计算索引值**(n - 1) & hash**需要区分开来
链表转化红黑树
treeifyBin()函数.在链表长度超过8的时候,使用该函数将链表转化为红黑树
其主要思路如下:
- 当整个数组桶的长度小于64时,此时并不会进行树化操作,只是进行扩容。注意,在进行扩容的时候,链表的长度有可能会变短
- 将链表中的节点转化为树节点TreeNode,形成一个新的链表
- 将新链表节点赋值给给定的数组项
- 调用TreeNode的treefify()方法将该处的链表转化为红黑树,该函数具体步骤如下:
- 插入树节点元素,具体可以参考二叉搜索树的插入操作。在进行插入的时候,比较key的hash值来决定插入的方向。
- 插入完成之后开始调整红黑树,使其符合红黑树的特性
查找
get函数的内部实现主要是getNode()函数,主要分析该函数
-
根据(n - 1) & hash计算得到该key所在的桶,若桶不存在或者数组table为空,则直接返回null.
-
当桶存在时,首先判断该桶上第一个节点是否就是要找的节点(大部分情况下都是),若是直接返回该节点值
-
若不是,判断节点类型,若是红黑树,则遍历红黑树查找,若是链表,则遍历链表查找