开始
HashMap是在开发工作中经常使用的集合类之一,熟悉其源码应该是基本要求。这篇文章对jdk1.8版本中的HashMap的一些常用方法的源码进行个记录。ps:这篇文章没有对其中的树化进行深究,比如提供的TreeNode内部类的结构和在扩容、Hash碰撞的时候的静态方法,之后有时间再研究下。
源码分析
1.1定义的变量
常量
1 |
|
- DEFAULT_INITIAL_CAPACITY:默认的初始化容量,必须是2的幂,这个值为16。
- MAXIMUM_CAPACITY:最大的容量,是1左移30位,相当于2的30次幂。
- DEFAULT_LOAD_FACTOR:默认的负载因子,值是0.75f。
- TREEIFY_THRESHOLD:树化的阈值,在整个数组的一个槽内,发生碰撞的链表如果超出整个阈值,就会转换为红黑树,在下面的代码也会分析到。
- UNTREEIFY_THRESHOLD:树变为链表的阈值。
- MIN_TREEIFY_CAPACITY:树化对应的最小容量阈值,上面的TREEIFY_THRESHOLD常量不是树化的唯一条件,HashMap在树化的方法中判断了当前的容量和这个值的比较,没有达到这个值,会先进行resize扩容操作。
变量字段
1 |
|
- table:HashMap中的Node数组,Node是其中的内部类。
- entrySet:键值对的缓存。
- size:key-value对的数量。
- modCount:修改次数。比如在内部实现的foreach方法,可以看到在循环中应用传入的action之外,还做了对modCount的校验,这里有点cas的感觉,有预期值和当前值,如果不一致,就抛出
ConcurrentModificationException
这个异常,也被称为是HashMap的fast-fail
机制。 - threshold:扩容的阈值。构造方法中直接通过tableSizeFor(initialCapacity)进行赋值,因为构造方法中tab数组并没有初始化,后来在put方法中初始化tab数组的时候重新对threshold进行了计算。
- loadFactor:负载因子。这里吐槽下经常看到面试问为啥是0.75,没get到问这个的意义在哪,设计jdk的人经过很多实验设置的值,并解释了符合泊松分布啥的,这种事了解下不就可以了吗,在工作中知道能自己设置这个值,不就可以了吗= =
Node内部类
1 | /** |
可以看到其中只定义了简单的值:hash值、key、value、还有next节点。
1.2 构造方法
来看看HashMap的构造方法:
1 | public HashMap(int initialCapacity, float loadFactor) { |
可以看到提供了四个重载的构造方法,有:
- 设置初始化容量和负载因子的。
- 无参数。
- 设置初始化容量的。
- 参数是一个map的
可以看到在构造方法中并没初始化数组(除了传入一个map初始化),而是赋值了负载因子和通过tableSizeFor
方法赋值了容量的阈值。
tableSizeFor方法
来看看这个tableSizeFor
方法:
1 |
|
这里将传入的容量参数-1,然后无符号右移1为左或操作,重复无符号右移2位、4位…,最后做了和最大容量值的比较。看注释知道这里返回的容量都是2的幂次方,这与下面定位数组中的位置有关,下面会做出解释。
1.3 put方法
1 | public V put(K key, V value) { |
hash方法
1 | static final int hash(Object key) { |
hash方法将key的hashCode和其无符号右移16位的值进行了异或操作,这个操作 也是扰动函数,为了降低哈希码的冲突。右位移16位,正好是32bit的一半,高半区和低半区做异或,就是为了混合原始哈希码的高位和低位,以此来加大低位的随机性。而且混合后的低位掺杂了高位的部分特征,这样高位的信息也被变相保留下来。也就是保证考虑到高低Bit位都参与到Hash的计算中。
这里看到我们已经做了相当于两次hash操作:
(1)取key的hashCode方法进行计算。
(2)hashCode无符号右移16位,与hashCode进行了异或。
一个key计算hash函数得到的值就是Node类中hash变量的值。
在后面定位元素在数组中的位置用了元素长度(这个上面提到了是2的次幂)-1去和hash值做了与操作:tab[i = (n - 1) & hash]
。这也相当于一个key传入HashMap定位到数组中的位置至少经历了3次hash操作。
在后边的方法代码中可以经常看到这行代码来定位数组中的位置,其实这里因为是2的次幂,所以做与操作相当于模除来定位数组中的位置。(参考这篇博客:HashMap中的hash算法)
putVal方法
1 | /** |
可以看到putVal方法流程是:
如果当前tab数组没有初始化,先调用resize()方法去初始化数组,数组长度赋给n。
通过tab[(n-1) & hash] 寻找数组位置,
如果当前数组位置对象为null,则new一个Node放到当前数组的位置,走++modCount、判断并且resize扩容、返回null。
把当前数组下标位置的元素赋给p变量,如果p不为null(说明通过该key对应的hash值映射到的数组下标处的元素不为空),那么就看是否存在key相同的元素(映射到同一下标不代表key相同):
- 如果满足判断条件:
(k = p.key) == key || (key != null && key.equals(k)))
这个判断条件,则p赋值给e,代表exist就是数组tab上的第一个元素。 - 没有满足条件看p是否为TreeNode的一个实例,这里如果p是TreeNode,说明该hashMap已经树化,此时调用TreeNode的putTreeVal方法去往树中添加当前的key,value。并把这个方法的返回值赋给e。(这个方法先不深究,这里注意可能返回null)。
- 如果也不是TreeNode的一个实例,则沿当前位置像遍历链表:一个for循环,维护了计数变量bitCount。
- 将p.next赋值给e,如果这个e为null,则new一个node对象放在p.next的位置上,并且判断当前的bitCount是否大于等于树化的阈值8-1=7,注意bitCount是从0开始的,如果达到,要调用treeIfBin方法进行树化操作,并跳出循环。(调用树化的方法并不一定会树化,因为内部还判断了数组长度,如果未达到数组长度的树化阈值,会先去调用resize方法扩容)
- 如果p.next不为null,那么再次判断条件
(k = e.key) == key || (key != null && key.equals(k)))`,即判断p.next的key是否和要put的key相等,如果相等,则跳出循环。
- 如果都不满足(p.next不为null且与键值与key不相等),则将e赋给p,进行下次循环。
- 判断e这个变量,即当前map中存在与当前要put的key相等的键值对,如果oldValue为null或者onlyIfAbsent是false,则覆盖oldValue并直接返回oldValue。(注意这步不会修改modCount)
- 如果满足判断条件:
modCount++、size++,这里判断了是否需要去扩容,如果超过了负载因子和长度的乘积这个阈值,则调用resize()方法进行扩容。返回null。
resize()方法
可以在putVal方法的源码中看到resize()方法在多个地方被调用,来看看这个方法的源码:
1 | /** |
从注释上可以看到,这个方法不仅仅承担着两倍扩容的作用,也负责初始化table数组的作用。
resize方法的流程:
一、初始化变量阶段
- 当前tab数组赋给oldTab,当前tab数组长度赋给oldCap,当前threshold赋给oldThr。初始化newCap、newThr为0
- 判断如果oldCap>0,即当前数组长度大于0
- 如果oldCap大于等于最大的Cap值,那么设置threshold为Integer.MAX_VALUE,直接返回oldTab。
- 赋值newCap为oldCap的2倍,如果newCap小于最大容量并且oldCap大于等于默认初始化容量,则直接将当前的threshold扩大2倍。
- oldCap为0,如果oldThr>0,则直接将newCap设置为oldThr容量。(这里说明下,如果当前数组长度为0,但是阈值已经初始化了,那么直接将oldThr赋给newCap变量。这里场景即运行了构造函数但是没有进行put操作,那时的threshold值会为tableSizeFor(initCap)方法的结果)
- 如果oldCap和oldThr都为0,则初始化newCap为默认大小,newThr为默认Cap大小 * 默认负载因子。
- 如果上述流程中newThr为0,那么根据newCap和当前的负载因子去计算threshold值赋给newThr。
二、数据迁移阶段
如果oldTab不为null,那么需要进行数据到扩容之后的数组的映射。
- 循环老数组
- 如果当前下标元素old[j]不为null,则进行下面的操作:
- oldTab[j]元素赋给变量e,oldTab[j]赋值null。
- 如果e.next为null,则说明直接将当前元素迁移到新的坐标即可。这里定位新的坐标是
e.hash & (newCap - 1)
即用hash值和newCap-1去做与操作,1.8中的这个操作很妙的是newCap绝对是2的次幂,具体的可以参考上面提到的扰动函数的位置的说明。 - 如果e.next不为null并且当前e是一个TreeNode,那么调用TreeNode的split方法去处理数组。这里红黑树的方法不去深究。这个操作中其实也有可能去做树的退化操作。因为和1.7一样,扩容原来在一条链上的元素,在扩容之后可能不在一条链上,这里也一样,如果链的长度达到了树退化的阈值,那么这里的要做树退化为链表的操作。
- 如果e.next不为null并且是一个链表结构,那么这里会将e所在的链表元素重新计算新的下标值,映射到新的数组上去,刚才也提到了链表可能会被拆分,因为容量扩大,可能要移动到[oldCap +当前索引值]处。(可以看到注释说明了保证了链的顺序,这里弃用了1.7中的头插法避免出现环)
- 继续循环oldCap
- 最后返回newTab
- 如果当前下标元素old[j]不为null,则进行下面的操作:
1.4 get方法
我们来看看也是经常使用的HashMap的get方法:
1 | /** |
看注释有一点是:如果get方法返回了null,那么不一定是这个map中不包含当前传入的key,也有可能是在map中key映射了null值value,并可以通过containsKey方法去区分这两种情况。
可以看到get方法是将hash值传入getNode方法的,下面看看getNode方法的源码:
1 |
|
getNode的流程比较简单,注释上写的流程这里不去做过多解释。
1.4 remove方法
来看看remove方法的代码:
1 | /** |
这里注意返回null可以代表map中没有这个映射,或map中这个key映射的value是null。
同样这里也是把hashCode计算好之后传入一个removeNode方法中:
1 | final Node<K,V> removeNode(int hash, Object key, Object value, |
可以看到删除时的操作和getNode方法大同小异,只不过在找到对应的key对应的node节点之后,去做了对node节点的删除逻辑而已。
彩蛋
上面有提到在1.7中resize时采用的头插法来转移之前数组上的链表,下面这个图是在1.7中扩容的简单的说明:
图片来源: https://blog.csdn.net/pange1991/article/details/82347284
而这个在并发的场景下可能形成环,耗子叔的博客也介绍了很详细: https://coolshell.cn/articles/9606.html
总结
本文简单介绍了HashMap中的成员变量、构造函数、改查删方法的源码,还对里面一些hash取值、定位数组下标的方法做了简单介绍,其中对1.8之后的红黑树没有深究,之后可能在后面的拾遗过程中介绍红黑树的用法。这里知道在链表长度过大时,红黑树能提高对应的效率即可。
关于HashMap的结构和容量总的来说:
- HashMap 底层数据结构在JDK1.7之前是由数组+链表组成的,1.8之后又加入了红黑树;链表长度小于8的时候,发生Hash冲突后会增加链表的长度,当链表长度大于8的时候,会先判读数组的容量,如果容量小于64会先扩容(原因是数组容量越小,越容易发生碰撞,因此当容量过小的时候,首先要考虑的是扩容),如果容量大于64,则会将链表转化成红黑树以提升效率。
- hashMap 的容量是2的n次幂,无论在初始化的时候传入的初始容量是多少,最终都会转化成2的n次幂,这样做的原因是为了在取模运算的时候可以使用&运算符,而不是%取余,可以极大的提上效率,同时也降低hash碰撞的概率。
参考文章
https://juejin.im/post/5c8f461c5188252da90125ba