HashMap部分源码解析(JDK1.8)

 

JDK1.8版本的HashMap源码

HashMap<K,V>继承自AbstractMap<K,V>,并实现了Map<K,V>, Cloneable, Serializable三个接口。

 

一些默认的静态常量

 

静态内部类Node的定义。Node实现了Map.Entry接口。可以看到Node就是链表的节点。

 

HashMap的域中定义了一个链表数组

 

HashMap自己的hash计算方法,可以看到当key==null的时候hashcode是0。

 

 

HashMap一共有4个构造器:

1、由外部传入初始容量和装填因子

 

2、外部传入初始容量,装填因子采用默认的0.75

 

3、什么都不传入

 

4、使用另一个Map来构造HashMap

 

 

下面先看get方法:

 

get调用了一个getNode方法

 

getNode的流程:

1、先判断链表数组是否为空,然后再判断对应位置的链表的头结点是否为空。如果其中一个为空的话直接返回null

 

2、检查头结点,如果头结点不为空的话返回头结点

 

3、判断头结点的next是否为空,不为空的话判断头结点是不是一个TreeNode,是的话按照TreeNode的方式去搜索结果,不是的话按照链表的方式去搜索结果。

 

getTreeNode的方法有点复杂,暂时先搁置具体过程研究。

 

 

下面看put方法:

 

put调用了putVal这个方法

putVal一共有5个参数,参数介绍如下

 

 

 

putVal的流程:

1、判断链表数组是否为空或者长度为0,是的话需要重新resize()一下。

 

关于resize()先把说明放这,作用是初始化或者翻倍链表数组的容量。

 

2、判断链表的头结点是不是为空,为空的话可以直接new一个节点出来,完成插入。同时返回null。

 

3、判断put的key。val是否和头结点p的key、val相同。相同的话直接将返回值e设置为p。最后会返回被覆盖的value值。

 

4、如果3的条件不成立,那么判断头结点p是否为TreeNode,是的话就调用putTreeVal来添加。

 

putTreeVal挺复杂的,具体流程先放一边搁置。

 

5、如果不是TreeNode,则按照链表的方式处理。

 

在遍历链表过程中如果遇到key相同的,那么依然会走到下面这一步。

 

如果遍历链表都没有key相同,那么直接尾部插入一个新节点。尾插法,之前一直记错了,把HashMap记成头插法了。

如果插入节点后长度超过7的话,那么会调用

 

将链表转换为红黑树。

6、最后如果新的key、value队插入的的话++modCount,同时size加1.如果size超过了threshold,就会扩容。

 

 

下面我们来看resize()方法的流程。resize()方法有扩容兼初始化的作用。

1、

 

2、如果oldCap为0,oldThr > 0的话,那么会执行以下这一步,把oldThr赋值给newCap。

 

为什么是把oldThr赋值给newCap?

回头去看,oldThr的值是threshold,那么threshold的值是谁呢?

再回头去翻。回到构造函数。如果threshold没有被动过的话那么按照Java的规范应该默认初始化为0。而这里的值不为0,那么自然得找动过threshold的构造函数。

于是发现了这两个构造函数有给threshold赋值。

 

继续看tableSizeFor这个方法。

 

这个方法的作用是当在实例化HashMap实例时,如果给定了initialCapacity,由于HashMap的capacity都是2的幂,因此这个方法用于找到大于等于initialCapacity的最小的2的幂(initialCapacity如果就是2的幂,则返回的还是这个数)。

所以最后构造器里的这句this.threshold = tableSizeFor(initialCapacity)在未执行resize()之前其实代表的是capacity的数值。也就是说下图的代码的作用是在我使用了带initialCapacity参数的构造器的时候,第一次使用resize()时(比如第一次put的时候,put就会调用resize()初始化链表数组)给Node<K,V>[] tab初始化capacity参数

 

3、与2相对的是下图代码就是在使用不指定initialCapacity参数的构造器构造HashMap时,初始化capacity和threshold。

 

4、如果进入了2步骤中的判断的话,那么需要给newThr赋值。

 

5、将newThr赋值给变量threshold。

 

6、根据newCap建立一个新的链表数组。

 

7、如果oldTab不为空,遍历oldTab。并将原来的oldTab[j]赋值为空

 

8、如果原oldTab处的链表只有一个节点的话,那么直接放入newTab对应的位置即可。

 

9、否则如果e是TreeNode,那么调用split拆分。

 

关于split,搁置。

 

10、这一块比较难理解。

如果e.next != null,e也不是TreeNode,那么进行链表复制。

方法比较特殊:它并没有重新计算元素在数组中的位置,而是采用了原始位置加原数组长度的方法计算得到位置。

 

结合网上有人写的分析:

 

中间的do while循环

 

11、resize()末尾返回newTab

 

 

贴一个网上说的JDK1.8和JDK1.7的区别

下面我们讲解下JDK1.8做了哪些优化。经过观测可以发现,我们使用的是2次幂的扩展(指长度扩为原来2倍),所以,

经过rehash之后,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。对应的就是下方的resize的注释。

 

 

看下图可以明白这句话的意思,n为table的长度,图(a)表示扩容前的key1和key2两种key确定索引位置的示例,图(b)表示扩容后key1和key2两种key确定索引位置的示例,其中hash1是key1对应的哈希值(也就是根据key1算出来的hashcode值)与高位与运算的结果。

 

元素在重新计算hash之后,因为n变为2倍,那么n-1的mask范围在高位多1bit(红色),因此新的index就会发生这样的变化:

因此,我们在扩充HashMap的时候,不需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”。这个设计确实非常的巧妙,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。这一块就是JDK1.8新增的优化点。有一点注意区别,JDK1.7中rehash的时候,旧链表迁移新链表的时候,如果在新表的数组索引位置相同,则链表元素会倒置,但是从上图可以看出,JDK1.8不会倒置。

 

再看一下remove()方法

 

remove()调用了removeNode()方法

 

 

removeNode()流程如下:

1、先获得头结点,然后检查头结点是不是要删除的节点。如果是的话那么给node赋值p。

 

2、否则如果p是TreeNode就按照TreeNode的方法得到待删除的node,如果p是链表的话就遍历链表得到待删除的node。

 

3、看待删除的Node是否为null。不为null的话就使用对应的方法将其移除。

 

4、如果没有删除节点,就返回null。

 

 

 

 

posted @ 2018-04-15 22:28  随意什么名字  阅读(198)  评论(0编辑  收藏  举报