以下源码分析基于JDK8的实现
希望你在阅读时,并不是第一次了解HashMap的源码
哈希表
在分析HashMap之前我们需要先了解什么是Hash表。
Hash表也称为散列表,也有直接译作哈希表,Hash表是⼀种根据关键字值(key - value)⽽直接进⾏访问的数据结构。也就是说它通过把关键码值映射到表中的⼀个位置来访问记录,以此来加快查找的速度。在链表、数组等数据结构中,查找某个关键字,通常要遍历整个数据结构,也就是O(N)的时间级(很好理解,因为我们不知道查找的元素在什么位置,所以需要从头开始查找),但是对于哈希表来说,只是O(1)的时间级(因为我们在存入元素的时候经过一定规则的计算,然后存放到了对应的位置,我们在取元素的时候把要找的元素经过同样的计算就可以知道它存放在什么位置,进而直接获取到)。
如果上面的解释的你没有看懂,请结合下图理解:
以链表 LinkedList为例。如果我们要查找这两个集合中的某个元素,通常是通过遍历整个集合,需要O(N)的时间级。
如果是哈希表,它是通过把关键码值映射到表中⼀个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表,只需要O(1)的时间级。
存放在哈希表中的数据是key-value 键值对,⽐如存放哈希表的数据为:
{Key1-Value1,Key2-Value2,Key3-Value3,Key4-Value4,Key5-Value5,Key6-Value6}
如果我们想查找是否存在键值对 Key3-Value3,⾸先通过 Key3 经过散列函数,得到值 k3,然后通过 k3 和散列表对应的值找到是 Value3。
提到Hash表就不得不提一个名词Hash冲突
了
什么是Hash冲突呢?
Hash冲突指的是一个多个key经过散列函数计算得到了相同的k值,此时为了解决Hash冲突的问题,一个普遍的做法就是通过拉链法解决的,如下图把经过hash计算后相同值得value链到一起:
哈希表有个重要的设计就是:保证散列函数能够尽量均分插入的元素,减少hash冲突的产生
其实也很好理解,因为本来通过哈希表可以以O(1)
的时间复杂度获取到对应的数据,但是如果散列函数设计的不合理,hash冲突频繁发生可能会导致某一个位置上的元素链接的很长,这时候,如果要查找的元素在这条很长的链上,那么时间复杂度就会退化,变得不理想了
什么是 HashMap?
HashMap 是⼀个利⽤哈希表原理来存储元素的集合。遇到冲突时,HashMap 是采⽤的链地址法来解决,在 JDK1.7 中,HashMap 是由 数组+链表构成的。但是在 JDK1.8 中,HashMap是由 数组+链表+红⿊树构成,新增了红⿊树作为底层数据结构,结构变得复杂了,但是效率也变的更⾼效。下⾯我们来具体介绍在 JDK1.8 中 HashMap 是如何实现的。
首先看一下JDK8中的数组+链表+红黑树的实现
HashMap的继承结构
蓝色实线表示继承、绿色虚线表示实现
⾸先该类实现了⼀个 Map 接⼝,该接⼝定义了⼀组键值对映射通⽤的操作。储存⼀组成对的键-值对象,提供key(键)到value(值)的映射,Map中的key不要求有序,不允许重复。value同样不要求有序,但可以重复。但是我们发现该接⼝⽅法有很多,我们设计某个键值对的集合有时候并不像实现那么多⽅法,那该怎么办?
JDK 还为我们提供了⼀个抽象类 AbstractMap ,该抽象类继承 Map 接⼝,所以如果我们不想实现所有的 Map 接⼝⽅法,就可以选择继承抽象类 AbstractMap 。
但是我们发现 HashMap 类即继承了 AbstractMap 接⼝,也实现了 Map 接⼝,这样做难道不是多此⼀举?
据 java 集合框架的创始⼈Josh Bloch描述,这样的写法是⼀个失误。在java集合框架中,类似这样的写法很多,最开始写java集合框架的时候,他认为这样写,在某些地⽅可能是有价值的,直到他意识到错了。显然的,JDK的维护者,后来不认为这个⼩⼩的失误值得去修改,所以就这样存在下来了。
HashMap 集合还实现了 Cloneable 接⼝以及 Serializable 接⼝,分别⽤来进⾏对象克隆以及将对象进⾏序列化。
字段属性
这里只介绍核心的属性
// 散列表table数组的默认长度为16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
// 散列表table数组的最⼤长度,如果通过带参构造指定的最⼤容量超过此数,默认还是使⽤此数
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认的负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 当table上元素的节点的数量大于当前这个值时会转换成成红黑树(注意并不是说只要大于8就会转换)
static final int TREEIFY_THRESHOLD = 8;
// 当table上的节点数量小于这个值时红黑树会退化为链表
static final int UNTREEIFY_THRESHOLD = 6;
// 当table数组的容量⼤于这个值时,table元素对应的链表才能进⾏树形化 ,否则链表元素太多时会扩容,⽽不是树形化
static final int MIN_TREEIFY_CAPACITY = 64;
// hash表的数组,需要注意的是,这个数组的长度总是2的幂
transient Node<K,V>[] table;
// 保存缓存的entrySet()
transient Set<Map.Entry<K,V>> entrySet;
// 集合中存储键值对的数量
transient int size;
// 记录hash结构修改次数,插入一个元素、删除一个元素都算修改结构,但是如果key一致去覆盖之前的值,由于结构未发生改变所以modCount不增加
transient int modCount;
// 数组扩容的阈值(容量*加载因⼦)。capacity * loadfactor,当你的hash表中的元素超过阈值时,会触发扩容
int threshold;
// 负载因子
final float loadFactor;
重点介绍几个字段:
Node<K,V>[] table
:我们说 HashMap 是由数组+链表+红⿊树组成,这⾥的数组就是 table 字段。后⾯使用无参构造对其进⾏初始化⻓度默认是 DEFAULT_INITIAL_CAPACITY= 16。⽽且 JDK 声明数组的⻓度总是 2的n次⽅。
为什么一定要这样设计,请看这篇博客size
:集合中存放key-value 的实时对数loadFactor
:装载因⼦,是⽤来衡量 HashMap 满的程度,计算HashMap的实时装载因⼦的⽅法为:size/capacity,⽽不是占⽤桶的数量去除以capacity。capacity 是桶的数量,也就是 table 的⻓度length。
这个值不建议我们自己手动设置,因为这个只是经过多方面考虑后一个权衡的结果。除⾮在时间和空间⽐较特殊的情况下,如果内存空间很多⽽⼜对时间效率要求很⾼,可以降低负载因⼦loadFactor 的值;相反,如果内存空间紧张⽽对时间效率要求不⾼,可以增加负载因⼦ loadFactor 的值,这个值可以⼤于1。threshold
:计算公式:capacity * loadFactor。这个值是当前hash表中元素个数的最大值。过这个数⽬就重新resize(扩容),扩容后的 HashMap 容量是之前容量的两倍
构造函数
无参构造
public HashMap() {
// 设置负载因子为默认的0.75
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
指定初始容量的构造
public HashMap(int initialCapacity) {
// 调用了两个参数的有参构造,分别传入了指定的初始容量和默认的负载因子
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap(int initialCapacity, float loadFactor) {
// 指定的初始化容量小于0,抛出异常
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
// 指定的初始化容量大于table能接受的最大值,设置为table能接受的最大值
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
// 指定的负载因子,如果小于等于0,或者不是一个数字比如0/0,就会抛出异常
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
//--------------------上面就是做了一些校验----------------------------
this.loadFactor = loadFactor;
// 通过tableSizeFor方法计算一个扩容的阈值,这个阈值一定是2的n次方,在添加第一个元素的时候进入扩容方法后会将这个阈值作为数组初始化的容量
this.threshold = tableSizeFor(initialCapacity);
}
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
解释这个tableSizeFor方法:
两个问题
问题1
这个tableSizeFor方法的作用是什么呢?
返回一个大于等于cap的最小的2的次方数
解释这个算法:
举个例子:
传入进来的容量cap=10
那么此时n就是9,n = 10 - 1 => 9
n |= n >>> 1:
1001 | 0100 => 1101
n |= n >>> 2:
1101 | 0011 => 1111
n |= n >>> 4:
1111 | 0000 => 1111
...
省略
n |= n >>> 8
n |= n >>> 16
因为结果已经为111不变了
为什么刚刚的这个算法能将任意一个二进制数转换为全部为1的情况呢?
分析:
- 一个二进制数的首位肯定是1,无符号右移1位之后,将右移之后的结果跟原来的数进行或运算,那么无论原来的二进制数左数第二个数是不是1,因为是或运算,所以左边两位一定都是1(因为或运算,只要有一个true就是true,也就是只要有一个1就是1)
此时二进制数的最左边两位就都是1了,就像这样1001 | 0100 => 1101
- 然后在将第一步得到结果进行无符号右移两位,再跟第一步的结果结果进行或运算,那么此时无论如何,左数四位就都是1了
此时二进制数的左数4位就都是1了,就像这样1101 | 0011 => 1111
- 然后在第二步的结果无符号右移4位,再跟第二步的结果进行或运算,就可以得到从左边数的八位的1了
- 然后在重复,将第三步的结果无符号右移动8位,再跟第三步的结果进行或运算,就可以得到从左边数的16位的1了
- 然后再重复,将第四步的结果无符号右移16位,再跟第四步的结果进行或运算,就可以得到32位的1了
- 所以最终无论如何得到的都是全部的1
- 返回出去的结果就是刚刚的所有的1加1
例如1111 + 1 = 10000
,所以一定是2的次方数
问题2
什么要-1呢?
答:因为如果不减1那么得到的大小不是大于当前cap的最小的2次方数了,具体自己可以带入一个数试一试
确定哈希桶数组索引位置
哈希桶数组就是我们的table
前⾯我们讲解哈希表的时候,我们知道是⽤散列函数来确定索引的位置。散列函数设计的越好,使得元素分布的越均匀。HashMap 是数组+链表+红⿊树的组合,我们希望在有限个数组位置时,尽量每个位置的元素只有⼀个,那么当我们⽤散列函数求得索引位置的时候,我们能⻢上知道对应位置的元素是不是我们想要的,⽽不是要进⾏链表的遍历或者红⿊树的遍历,这会⼤⼤优化我们的查询效率。我们看 HashMap 中的哈希算法:
static final int hash(Object key) {
int h;
/**
* 1、当key为null的时候,它的hash值就是0
* 2、对key的hashCode值进行扰动处理,作为key的hash值
*/
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
// ------------------------------------------------------
i = (table.length - 1) & hash;//这⼀步是在后⾯添加元素putVal()⽅法中进⾏位置的确定,这个i就是要插入的哈希桶数组索引的位置
计算一个元素在hash桶数组索引中的位置主要分为三步:
- 取 hashCode 值: key.hashCode()
- ⾼位参与运算:h>>>16
- 取模运算:(n-1) & hash
为什么要使用h>>>16
方式进行扰动处理呢?
在table刚刚创建的时候,table的len是很小的,比如16、32、64。
而这些长度在计算key的下标的时候通过(len - 1) & hash
计算。
此时就会导致高16位中可能使用不到
同时也可能出现一种情况就是这个hash值的低16位可能0比较多,如果仅仅跟低16位进行与运算的话可能导致频繁发生hash冲突 因此需要对hash值进行扰动,让低16位也具有高16位的特征。
举个例子吧
key1的hashCode:
1100 0010 1001 1010 0000 0000 0000 0000
key2的hashCode:
1100 0110 1001 1010 0000 0000 0000 0000
key3的hashCode:
1100 1010 1001 1010 0000 0000 0000 0000
此时数组的长度为8:
那么长度减1就是7
0000 0000 0000 0000 0000 0000 0000 0111
key1 & (len-1)
1100 0010 1001 1010 0000 0000 0000 0000
&
0000 0000 0000 0000 0000 0000 0000 0111
||
\/
0000 0000 0000 0000 0000 0000 0000 0000
key2 & (len-1)
1100 0110 1001 1010 0000 0000 0000 0000
&
0000 0000 0000 0000 0000 0000 0000 0111
||
\/
0000 0000 0000 0000 0000 0000 0000 0000
key3 & (len-1)
1100 1010 1001 1010 0000 0000 0000 0000
&
0000 0000 0000 0000 0000 0000 0000 0111
||
\/
0000 0000 0000 0000 0000 0000 0000 0000
可以看到这种情况没有进行扰动,导致key1、key2、key3发生了hash冲突都存当到了下标为0的位置
为了让数组元素分布均匀,我们⾸先想到的是把获得的 hash码对数组⻓度取模运算(hash%length),但是计算机都是⼆进制进⾏操作,取模运算相对开销还是很⼤的,那该如何优化呢?
HashMap 使⽤的⽅法很巧妙,它通过 hash & (table.length -1)来得到该对象的保存位,前⾯说过HashMap 底层数组的⻓度总是2的n次⽅,这是HashMap在速度上的优化。当 length 总是2的n次⽅时,hash & (length-1)运算等价于对 length 取模,也就是 hash%length,但是&⽐%具有更⾼的效率。⽐如 n % 32 = n & (32 -1)
这也解释了为什么要保证数组的⻓度总是2的n次⽅。
再详细的分析见我的这篇博客
添加元素
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
// tab用于引用当前的hashMap的table散列表
Node<K,V>[] tab;
// p表示当前散列表的某一个下标的元素
Node<K,V> p;
// n表示散列表数组的长度
// i表示路由寻址的下标结果
int n, i;
// 为什么不在new的时候就直接申请了table数组占用的内存呢?
// 因为我们可能仅仅是把hashMap给new出来了,但是暂时不使用,如果在new的时候就创建了数组,那么会浪费内存
// 所以设计为了一种延迟初始化的模式
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 使用路由算法计算key应在存放的下标位置(n - 1) & hash
// 如果这个位置没有元素存放,那么直接创建一个节点,然后存放到这个位置
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
// 说明发生hash冲突了,此时i下标的位置可能是一个链表也可能是一个红黑树
else {
// e不为null,表示找到了一个跟当前要插入的key-value一致的key的元素
Node<K,V> e; K k;
// 注意p就是发生hash冲突的位置的桶位置的node元素
// 表示桶中的该元素,与当前我要插入的key完全一致,表示后续需要进行替换操作
// (当p的hash值跟当前要插入的元素的hash值相同 并且 p的key(键)跟当前要插入的key相同)
// 或者
// (当前要插入的元素key不为null并且当前要插入的key.equals(k)为ture)
// 把p赋值给e
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 如果说当前桶位置的元素跟我要插入的元素,虽然发生了hash冲突,但是key并不相同
// 此时就需要判断该桶位置的node是否是一个红黑树
// 如果是红黑树,那么执行红黑树的插入
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 剩下的情况就是链表的情况了,如果链表中有符合“相同的key”的条件的元素时,就进行替换
// 反之没有“相同的key”那么就将添加的元素,添加的链表末尾
else {
for (int binCount = 0; ; ++binCount) {
// 当p.next=null表示当前p已经是位于链表的末尾了,那么就将当前的这个元素插入到p的next
// e表示当前元素的下一个
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 判断是否需要进行树化
// 为什么是TREEIFY_THRESHOLD - 1,因为计数器binCount是从0开始的,当binCount为7的时候
// 其实就代表当前链表有8个元素了
// 需要注意的是在添加完元素之后没有立即对binCount进行加一,也就是意味着
// 实际上当链表的元素达到9个的时候才会进行树化,此时的binCount为8
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// 条件成立说明 找到了相同key的node元素,需要进行替换操作
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
// 指针后移
p = e;
}
}
// e不为null表示找到了
if (e != null) { // existing mapping for key
// 获取旧的值
V oldValue = e.value;
// 因为参数设置了onlyIfAbsent为false,所以表示如果遇到了key相同的情况会去覆盖它
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
// 返回旧的值
return oldValue;
}
}
// 哈希表结构改变的次数加1
++modCount;
// 判断添加完元素之后是否需要扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
- 判断键值对数组 table 是否为空或为null,否则执⾏resize()进⾏扩容;
- 根据键值key计算hash值得到插⼊的数组索引i,如果table[i]==null,直接新建节点添加,转向6,如果table[i]不为空,转向3
- 判断table[i]的⾸个元素是否和key⼀样,如果相同直接覆盖value,否则转向4,这⾥的相同指经过扰动计算后的hash值或者equals
- 判断table[i] 是否为treeNode,即table[i] 是否是红⿊树,如果是红⿊树,则直接在树中插⼊键值对,否则转向5
- 遍历table[i],判断链表⻓度是否⼤于8,⼤于8的话把链表转换为红⿊树,在红⿊树中执⾏插⼊操作,否则进⾏链表的插⼊操作;遍历过程中若发现key已经存在直接覆盖value即可
- 插⼊成功后,判断实际存在的键值对数量size是否超过了最⼤容量threshold,如果超过,进⾏扩容。
- 如果新插⼊的key不存在,则返回null,如果新插⼊的key存在,则返回原key对应的value值(注意新插⼊的value会覆盖原value值)
注意这两行代码:
void afterNodeAccess(Node<K,V> p) { }
void afterNodeInsertion(boolean evict) { }
这都是⼀个空的⽅法实现,我们在这⾥可以不⽤管,但是在后⾯介绍 LinkedHashMap 会⽤到,LinkedHashMap 是继承的 HashMap,并且重写了该⽅法。
put方法流程图
扩容方法
为什么需要扩容?
因为如果不扩容,那么table数组长度不变,随着添加的元素越来越多,那么hash冲突也会越来越多,导致链化严重,导致hash表的时间复杂度不再是O(1)了
final Node<K,V>[] resize() {
// 引用扩容之前的hash表table
Node<K,V>[] oldTab = table;
// 获取扩容之前的hash表table的长度
// 刚刚new出hashMap没有放入过元素的话,table就是null,这个oldCap得到的结果就会是0
// 反之只要有长度就会获取到table的长度
int oldCap = (oldTab == null) ? 0 : oldTab.length;
// 表示扩容之前的扩容阈值阈值
int oldThr = threshold;
// newCap表示扩容之后table的大小
// newThr表示扩容之后新的阈值大小
int newCap, newThr = 0;
//--------------------------这一段的内容就是在计算newCap和newThr的值---------------------------------
// 条件如果成立,说明table之前初始化过了,就是一次正常扩容
if (oldCap > 0) {
// 如果你旧的table的长度已经是大于等于成员变量中定义的最大table容量了,那么将扩容阈值设置为MAX_VALUE
// 然后返回旧的table长度,因为已经不能更大了
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// oldCap << 1,等于oldCap*2
// 如果旧的table容量的2倍小于最大table能够达到的容量
// 并且旧的容量大于等于16
// 这种情况下将旧的阈值大小成2后作为新的阈值
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
// 新的阈值为旧的阈值*2
newThr = oldThr << 1; // double threshold
}
// !!!下边的else if和else都是表示的是oldCap为0的情况,也就是table没有进行过初始化的情况
// else if(oldThr > 0)
// 如果条件成立,会将旧的扩容阈值作为新的容量大小,
// 这个地方对应的是HashMap的有参构造初始化,指定了初始化table的容量
// 在HashMap的有参构造中将,初始化容量赋值给了扩容阈值
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
// 调用new HashMap()的时候oldCap为0,oldThr也是0
else { // zero initial threshold signifies using defaults
// 将初始化的容量设置为16
newCap = DEFAULT_INITIAL_CAPACITY;
// 将扩容阈值设置为12
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// newThr为零时,通过newCap和loadFactor计算出一个newThr
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
// 更新扩容阈值的大小
threshold = newThr;
//------------------------------------------------------------------------------------
@SuppressWarnings({"rawtypes","unchecked"})
// 创建出一个扩容后的数组
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
// 条件成立说明不调用无参构造的初始化调用的扩容
if (oldTab != null) {
// 一个桶位一个桶位的处理
for (int j = 0; j < oldCap; ++j) {
// e表示当前遍历的桶位置的node节点
Node<K,V> e;
// 条件成立说明该桶位中有数据
if ((e = oldTab[j]) != null) {
// 将引用置空,方便jvm回收
oldTab[j] = null;
// 条件满足说明当前桶位置的node节点是单个元素
if (e.next == null)
// 重新计算新table中的位置并赋值
newTab[e.hash & (newCap - 1)] = e;
// 条件满足说明当前桶位的node节点是个红黑树
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
// 当前桶位置是个链表
else { // preserve order
// 低位链表:存放在扩容之后的数组的下标位置与当前数组的下标位置一致
Node<K,V> loHead = null, loTail = null;
// 高位链表:存放在扩容之后的数组的下标位置为 当前数组下标位置 + 扩容之前数组的长度
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
// next表示当前旧桶上的第一个node元素的下一个元素
next = e.next;
// 举个例子说明:
// 假设原先的数组长度是16,那么oldCap的二进制就是1 0000
// hash值只会有两种类型,分别是:
// ... 1 1111
// 或者
// ... 0 1111
// 因为我们不需要管前面是什么,只有最后五位才会影响结果
// 那为什么用hash值与旧的table长度呢?
// 因为如果(e.hash & oldCap) = 0说明
// hash值只可能是这样子的: ... 0 1111
// 这就说明了当(e.hash & oldCap) == 0条件成立这个hash值对应的key应该存放在低位的位置
// 在模拟一下,用这个hash值与32-1
// ... 0 1111
// &
// 1 1111
// ||
// \/
// 0 1111 可以看到得到的结果还是原来的低位下标
// 由衷感叹,设计的真是巧妙!!!
if ((e.hash & oldCap) == 0) {
// 说明当前是低位
// 如果低位的尾元素为null,说明低位链表还没添加元素
if (loTail == null)
// 将当前节点作为低位链表的头结点
loHead = e;
// 说明低位链表有节点了,将当前的节点添加到尾节点后面就行
else
loTail.next = e;
// 移动尾节点指针,后移
loTail = e;
}
// 说明当前是元素应该插入到高位链表上
else {
// 如果高位节点的尾节点为空,说明高位节点还没有元素,直接将当前节点作为高位节点的头结点即可
if (hiTail == null)
hiHead = e;
else
// 说明高位有节点了,将当前的元素插入到尾节点后面
hiTail.next = e;
// 移动高位尾节点指针
hiTail = e;
}
// 移动旧桶的指针,继续移动下一个元素到高位链表或者低位链表上
} while ((e = next) != null);
// 如果低位链表不为null,说明有东西,添加到新桶的对应位置就行
if (loTail != null) {
// 置为null 方便jvm进行gc
loTail.next = null;
newTab[j] = loHead;
}
// 如果高位链表不为null,说明有节点,将这个链表添加到信桶对应的位置就行
if (hiTail != null) {
// 置为null 方便jvm进行gc
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
// 返回扩容后的table
return newTab;
}
该⽅法分为两部分,⾸先是计算新桶数组的容量 newCap 和新阈值newThr,然后将原集合的元素重新映射到新集合中。
get方法
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object key) {
// 引用当前hashMap的散列表
// first:桶位中的头元素
// e:临时node元素
// n:table数组长度
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
// 判断table是不是没有初始化,如果没有初始化则返回null
if ((tab = table) != null && (n = tab.length) > 0 &&
// 如果条件成立说明对应的桶位上没有数据,返回null,反之对应的桶位上有数据
(first = tab[(n - 1) & hash]) != null) {
// 第一种情况:定位出来的铜元素就是要找的
// 如果对应下标的桶位的头元素的就是要查的数据那么直接返回当前节点
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
// 如果对应下标的桶位的元素的下一个不为null,说明当前位置不止一个元素,可能是红黑树也可能是链表
if ((e = first.next) != null) {
// 如果对应下标的头结点是一个红黑树,那么调用红黑树的获取方法
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
// 如果要找的key和当前遍历的node的key相同那么就返回当前节点
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
// 指针后移 遍历链表的步骤
} while ((e = e.next) != null);
}
}
return null;
}
remove方法
public V remove(Object key) {
Node<K,V> e;
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
// tab:表示引用当前hashMap中的散列表
// p:当前node元素
// n:表示散列表数组长度
// index:表示寻址结果
Node<K,V>[] tab; Node<K,V> p; int n, index;
// 看hashMap中的散列表是不是初始化过了,如果没有初始化过那么肯定也是删除不了的,返回null
// 经过hash值计算当前要删除的key的table对应的下标没有元素,那么也说明肯定是没有要删除的元素的,返回null
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
// 此时p指向了某一个桶位,并且该位置上是有元素的
// node表示查找到的结果
// e表示当前Node的下一个元素
Node<K,V> node = null, e; K k; V v;
// 第一种情况:当前桶位就是你要删除的节点
// 如果p节点跟要删除的元素的hash值相同并且key相同胡总和调用equals相同,那么当前就是要删除的节点
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
node = p;
// 判断当前节点的下一个是不是null,如果为null表示没有要删除的节点了,如果有就继续向后找
else if ((e = p.next) != null) {
// 第二种情况
// 当前桶位上的节点是红黑树
if (p instanceof TreeNode)
// 从红黑树上获取要删除key的节点
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
else {
// 第三种情况
// 说明当前是个链表
do {
// 如果当前是要删除的节点,那么就将当前的节点赋值给node然后退出循环
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
p = e;
// 迭代遍历链表
} while ((e = e.next) != null);
}
}
// node不为null并且,对比传进来的value和查找到的node节点的value是否一致
if (node != null &&
// matchValue如果位true
// 表示的是不仅需要保证查找到的node的key跟要删除的key相同 同时 保证查找到的node的value跟要删除的value相同
// matchValue如果为false表示只按照传递进来的key删除
// 可以看到如果matchValue为false那么(!matchValue || (v = node.value) == value ||
// (value != null && value.equals(v)))
// 整个都不需要走了
// 如果为ture的话 还需要继续判断后面(v = node.value) == value和(value != null && value.equals(v))
// 都是判断value值是不是相等的,这个相等的依据就是内存地址 或者equals方法的结果
(!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
// node是红黑树
if (node instanceof TreeNode)
// 调用红黑树的删除方法
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
// 如果要删除的节点就是要删除的元素的key经过hash计算后的下标为的桶位上第一个元素时
// 则将该元素的下一个元素防止捅位中
else if (node == p)
tab[index] = node.next;
// 桶位上的元素是链表
else
// 删除链表上节点的操作
p.next = node.next;
++modCount;
--size;
afterNodeRemoval(node);
// 返回删除了的结果
return node;
}
}
return null;
}