Java Collection框架(六)HashMap源码浅析

 Java Collection框架 HashMap 源码解读 与 实现原理浅析

Java Collection框架(六)HashMap源码浅析

2018拍摄于京都天龙寺

微信公众号

Java Collection框架(六)HashMap源码浅析

 

Java Collection框架(六)HashMap源码浅析

王皓的GitHub:https://github.com/TenaciousDWang

 

Java Collection框架(六)HashMap源码浅析

 

今天来看平时最常用的一个容器HashMap,版本为JDK8.

 

数据结构

 

JDK8 之后的HashMap底层在解决哈希冲突的时候,除了使用数组加上单链表外,还加入了红黑树结构,因为当处理如果 hash 值冲突较多的情况下,链表的长度就会越来越长,时间复杂度达到 O(n)。

 

所以JDK7之后,在满足条件的时候,就会在添加元素的同时将原来的单链表转化为红黑树,查询的时间复杂度是 O(logn) ,可以更高效的对 HashMap 中的元素进行操作。后面会单独拿出一个篇幅来复习红黑树。

 

Java Collection框架(六)HashMap源码浅析

 

HashMap的数据结构如上图(哈希桶包含哈希槽,这里为了好区分自己画图分开了)。我们先来看几个概念。

 

table数组,用来存储所有节点数据的数组,数组中table[i]的位置对应一个哈希槽,底层数组的长度总是 2^n。当向HashMap集合中放入键值对时,对key进行hash运算找到一个地址对应一个哈希槽,这个元素存到哈希槽对应的哈希桶中,但是hash计算无法避免哈希冲突,多个元素落入同一个哈希槽中时,就会在哈希桶中形成链表,链表的头部节点放在了哈希槽中。

 

哈希桶中的所有元素的总和就是HashMap的size,遍历HashMap元素,首先横向遍历所有哈希槽(table[0]~table[length-1]),如果哈希槽中有元素,就遍历桶里所有元素。

 

JDK7之后,对HashMap进行了改造,当哈希桶中的元素超过8个且table数组的长度大于或等于64时,会将桶中的链表转换为红黑树,来优化和提高性能。桶中元素小于6时,会由红黑树转为链表形式。

 

大概了解HashMap的底层数据结构后,我们开始看看HashMap的源码,首先看几个重要的成员变量。

 

重要的成员变量

 

Java Collection框架(六)HashMap源码浅析

 

DEFAULT_INITIAL_CAPACITY初始化容量,1<<4,这里为了提高效率,HashMap里的运算使用位移运算,向左移动4位,2^4=16,16代表了初始化table数组的长度,也就是哈希桶的数量。

 

MAXIMUM_CAPACITY最大容量,2^30,桶的最大数量。

 

DEFAULT_LOAD_FACTOR负载因子,0.75f,在扩容前,允许容量自动增加。由于扩容会增加原来哈希桶数量的两倍,有可能会造成浪费,所以设置一个负载因子,假设要存储12个元素,此时桶的数量会自动增加到12/7.5=16,16表示当前默认容量大小,这时并不会触发扩容,当存入第13个元素时,13>16*0.75,次时触发扩容条件,容量变为32。当集合元素数量>当前容量*0.75时触发扩容。

 

Java Collection框架(六)HashMap源码浅析

 

TREEIFY_THRESHOLD树化阈值,哈希桶中元素数量大于8时,进行树化(桶数量大于等于64时)。

 

UNTREEIFY_THRESHOLD反树化阈值,哈希桶中元素数量小于6时,由红黑树结构变为链表结构。

 

MIN_TREEIFY_CAPACITY最小树化容量,桶数量大于等于64时触发树化条件,再根据TREEIFY_THRESHOLD树化阈值判断桶中数据是否需要树化。

 

构造方法

 

Java Collection框架(六)HashMap源码浅析

 

第一个方法可以自定义初始化容量与负载因子,第二个方法只自定义初始化容量使用默认负载因子,第三个为默认构造方法,只赋值负载因子,第四个方法为根据指定Map构造一个新的HashMap,使用默认负载因子。

 

初始化容量方法调用了static final int tableSizeFor(int cap)。

 

Java Collection框架(六)HashMap源码浅析

 

这个算法看的一脸懵逼。。。实际上这个方法是在找大于等于cap且最小2的幂。

 

假设cap为9,int n =8;8的二进制位为1000,8向右移位1个单位,二进制变为0100,相当于8/2=4,1000对0100做位或运算得1100

 

接下来1100向右移动2个单位得0011,做位或运算得1111

 

接下来1111向右移动4个单位得1111,做位或运算得1111

 

接下来1111向右移动8个单位得1111,做位或运算得1111

 

接下来1111向右移动16个单位得1111,做位或运算得1111

 

1111为十进制15,最后n+1为15+1得16,所以得到大于或等于9的最小2次幂数16。

 

这里印证了table的大小总是2^n的,初始化容量总是根据设定容量去找大于等于它的最小二次幂数。

 

基础哈希节点Node<K,V>

 

接下来我们看一下HashMap里面用于存储的基础单位Node类,这是一个HashMap中的静态内部类,JDK8后加入了红黑树机制,本篇先不涉及到,与红黑树一起来说。

 

Java Collection框架(六)HashMap源码浅析

 

可以看到Node<K,V> implements Map.Entry<K,V>,构造器参数有四个,分别对应其成员变量。

 

final int hash; 为元素key值经过hash计算得出的结果,一会我们会看HashMap类中的hash方法。

 

final K key; 与 V value; 这个是元素原始key值与value。

 

Node<K,V> next; 用来记录下一个节点,链表用,与我们之前LinkedList里面的Node内部类已经完全不一样了。

 

Node<K,V>节点覆写了Object的hashCode与equals和toString方法。

 

getKey(),setValue(),getValue()为操作元素的方法。

 

hash函数

 

用于计算元素key值的哈希槽地址,即确定table数组中哈希槽的位置。

 

Java Collection框架(六)HashMap源码浅析

 

key为null时返回0,key不为null时,首先调用Object的hashCode方法计算哈希值返回,Object的hashCode方法调用的本地的native方法会根据内存地址计算返回一个整数值,整数值>>>16无符号右移16位后与整数值本身做异或运算得到hash值。

 

只进行一次位运算,一次异或运算,获得key的hash值。

 

public V put(K key, V value)

 

Node<k,v>节点与hash函数看完后,我们来看HashMap如何put一个元素。

 

Java Collection框架(六)HashMap源码浅析

 

实际上调用了putVal(hash(key), key, value, false, true);方法。

 

Java Collection框架(六)HashMap源码浅析

 

首先判断是否是第一次添加元素,如果是,则先使用resize方法进行扩容。

 

i = (n - 1) & hash用于计算哈希槽位置即table数组的指针位置,这里用数组长度-1后做与刚才得到的hsah整数值&位与运算得到table数组指针位置。

 

如果当前数组指针元素为空,那么直接创建Node节点放入该哈希槽。

 

 

 

如果不为空,则表示发生了哈希碰撞,首先判断key值是否相同,如果相同则直接覆盖Node节点。

 

如果key值不相同则先判断节点P是否为红黑树节点,如果是则需要先创建新节点为红黑树节点TreeNode进行插入操作,如果不是跳过。

 

然后,开始遍历p节点所在哈希槽对应哈希桶内的链表,到链表的底部,创建一个新的节点放到p节点的next变量中,即链表尾部,break跳出循环,如果链表中有相同的key值节点则直接替换。

 

最后如果需要替换节点即e节点不为空记录了旧的节点,返回旧节点的value值。

 

Java Collection框架(六)HashMap源码浅析

 

收尾,记录modCount操作次数+1,size+1如果size大于阈值即当前容量*0.75时触发resize方法进行扩容。

 

其他put元素的方法原理基本相同,感兴趣大家可以自行查看源码。接下来我们来看HashMap中重要的扩容方法resize。

 

final Node<K,V>[] resize()

 

Node<K,V>[] oldTab = table;将当前table赋值给oldTab。

 

int oldCap = (oldTab == null) ? 0 : oldTab.length;旧容量

int oldThr = threshold;将全局阈值赋值给oldThr

int newCap, newThr = 0;初始化扩容新容器的容量及阈值。

 

当oldCap大于0,先判断是否大于等于最大容量2^30,如果大于等于则无需扩容了,直接返回老table,扩容阈值赋值Integer.MAX_VALUE。如果没有大于最大容量且oldCap大于默认容量,则用位运算向左移1位,即*2,oldThr*2赋值给newThr新的扩容阈值。

 

当oldThr>0时,表示阈值计算过,则直接将oldThr旧扩容阈值赋值给newCap新容器的初始化容量。

 

都不是则全部赋上默认值。

newCap=DEFAULT_INITIAL_CAPACITY;

newThr=(int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);

 

如果newThr==0,则重新计算扩容阈值赋值给newThr。最后将newThr赋值给HashMap容器的全局阈值。threshold = newThr;

 

上述为扩容第一步,即确定新容器的容量及新容器的阈值。接下来为第二部赋值旧容器中的元素到新的容器中。

 

Java Collection框架(六)HashMap源码浅析

 

根据新newCap容量创建newTable数组,讲newTable赋值给HashMap的全局table变量。

 

如果oldTab为null则不用复制,直接返回新的空的table数组。如果存在,就开始遍历oldTab数组,开始复制元素到全局table中。

 

先e = oldTab[j]赋值,oldTab[j]不为null,就先清空oldTab中j位置的元素释放资源,e.next == null表示当前哈希槽只有一个元素,不是链表形态,直接重新计算e节点的hash槽地址,直接存入,newTab[e.hash & (newCap - 1)] = e;

 

如果为e类型为TreeNode,则需要使用红黑树节点用方法去确定节点在新容器中的位置。

 

Java Collection框架(六)HashMap源码浅析

 

                        Node<K,V> loHead = null, loTail = null;

                        Node<K,V> hiHead = null, hiTail = null;

 

链表状态下,由于对原来的容器进行了双倍扩容,为了table中元素在哈希槽分布均匀,效率更高,原来的元素可能存到原来table数组中的哈希槽中,源码中定义为low位,loHead定义低位头节点,loTail定义低位尾节点,原来的元素也可能存到扩容后的哈希槽中,源码中定义为high位,hiHead 定义为高位头节点,hiTail 定义为高位尾节点,next定义原oldCap中节点的next节点。

 

开始do while 循环直到链表的尾部,每次对链表中的节点元素进行e.hash & oldCap运算,获取元素的hash值与oldCap原来容器的做位与运算。等于0时放入低槽位,反之放入高槽位。

 

新的hash值根据扩容的table大小-1重新计算,只需要看看比原来的hash值新增的那个bit是1还是0,是0的话索引没变放到底槽位,是1的话索引变成原索引加上oldCap来放入高槽位。

 

              //将低位链表存放在原哈希槽

              if (loTail != null) {

                  loTail.next = null;

                  newTab[j] = loHead;

              }

              //将高位链表存放在新哈希槽

              if (hiTail != null) {

                  hiTail.next = null;

                  newTab[j + oldCap] = hiHead;

              }

 

到此,第二部将oldTab中的所有元素移动到了newTab中,扩容结束。

 

public V get(Object key)

 

接下来看一下get方法。

 

Java Collection框架(六)HashMap源码浅析

 

如果获取节点为null则返回value为null,这里调用了getNode方法。

 

Java Collection框架(六)HashMap源码浅析

 

首先使用hash函数计算key值,tab[(n - 1) & hash]哈希槽桶内元素就一个直接返回。

 

如果哈希桶内为红黑树则调用专用getTreeNode方法从树中找到对应元素返回。

 

如果是链表则遍历链表找到后返回。

 

public V remove(Object key)

 

接下来看一下HashMap删除元素的方法。

 

Java Collection框架(六)HashMap源码浅析

 

调用removeNode方法删除节点。

 

Java Collection框架(六)HashMap源码浅析

 

首先判断table数组是否为空,数组长度是否大于0 ,对应的哈希槽上是否有元素。

 

Node<K,V> node = null, e; K k; V v; 造一个空的元素用来盛放要删除的元素,如果哈希槽中只有一个元素则将该元素直接赋值给node。

 

如果为红黑树则使用getTreeNode找到元素赋值给node。

 

如果为链表则遍历链表找到元素赋值给node。

 

最后开始删除元素,红黑树使用removeTreeNode方法,如果哈希桶中只有一个元素则直接使用node.next赋值给tab[index],链表则将p.next = node.next。

 

操作次数+1,afterNodeRemoval方法在HashMap中没有实现。

 

最后返回被删除的node。

 

其他方法原理都是基于这一套数据结构来进行操作,所以这里不再赘述,感兴趣的朋友可以自行查看源码。

 

HashMap的数据结构和源码比ArrayList或LinkedList复杂了很多,下一篇我们将继续来看本篇中提到的红黑树。