目录
1. 如何查看源码?
2. 查看源码时需要注意的几个小细节
3. HashMap 的无参构造
4. HashMap 的带参构造(只给一个初始容量)
5. HaahMap的put方法源码解读
6. HashMap的扩容解读
7. HashMap可以存空值吗?
8. 总结概括(非常重要)
在学习 HashMap 源码之前,我先来教各位如何在 IDEA 编辑器中查看Java源码,这里我就当各位都是小白,从零说起。
我们打开我们的 IDEA,如下图所示,是我编写的一个类,
当我们想要查看某个类的源码时,可以在键盘上点击 Ctrl 键 + N 键,此时,IDEA 编辑器页面上就会出现如下所示的一个小搜索框,我们就可以在搜索框中输入我们想要查看的类或者接口的源码,如下图中所示,我输入 String 搜索一下,这里就会出现一堆关于 String 的相关类或接口
当我们想查看时,直接点击对应自己想要查看的类或接口即可跳转至源码,我点击 String 跳转至 String 类的源码,如下图所示,
然后,我们就可以查看源码了;
这里还有一个小知识点,我们在源码类中,我们可以点击键盘上的 Ctrl 键 + F12 键,就会出现该源码类中所有的变量和方法,如下图中所示
各位学到了吗,以后自己在看源码时,也可以使用 Ctrl + N 与 Ctrl + F12 两个快捷组合键查看源码。
刚才我们说到了如何查看源码,这里我再说几个查看源码时需要注意的几个小细节,如下图中所示
我用红线标注出来的,在前面都有一个小圆圈里面还有字母m,这时英文单词 method 的缩写,说明这是该源码类中的一个方法;
往下翻,如下图
小 f 开头的代表是英文单词 field ,代表该类中的属性;
小 c 开头的则是class 的缩写,指此类是我们查看的源码类中的一个内部类;
还有以 小 i 开头的是该类中的一个接口,即 interface 如下所示
每一条方法的最后是该方法的返回值类型;
如下图所示,就是HashMap 的无参构造源码
可以看到源码中无参构造方法很简单,它仅仅赋值了一个loadFactor,翻译过来就"加载因子";如下我找到了该变量为 0.75f,这里我先简单解释,0.75就是HashMap 的扩容时机,也就是说,当我们创建的 HashMap 集合中存储的元素达到了最大容量的0.75时,就会触发扩容机制。
如下图,即为 HashMap 的带初始容量的构造器,这里调用了一个方法,我们点击 this 跟进查看
找到 this 方法如下
我们找到了真正的带参构造源码,这里我一点一点解释,首先这个方法传递了两个参数,一个 initialCapacity 是初始容量,loadFactory 是我们刚才我参构造中说到的扩容时机,也为0.75;
(1)可以看到首先做了一个判断,判断我们初始赋值的容量是否小于0,如果是直接抛出异常;
(2)再判断我们给的初始容量大于最大容量吗?这里我已经找到了最大容量如下图 1<< 30,可能有一些小伙伴不太明白 1 << 30 是什么意思,这里简单解释,1 << 30 就是 2^30(即2的30次方)这一表示的就是 1 乘以 2^30;
如果是 >> 表示除以 2 的多少次方,听明白了吧;
(3) 这里再次对 loadFactory 做判断,如果不动默认就是 0.75,不用多说什么;
(4) 根据 ininialCapacity 调用另一个方法计算出变量 threshold的值;
如下所示,即为HashMap的put方法源码,这里一共有5个参数,我简单说一下它们的含义
hash(key):计算出键的哈希值;
key:值;
value:键对应的要存储的值;
onlyIfAbsent:当遇到key一样时是否保留,这里默认为覆盖而不是保留;
最后一个参数暂时不用管,我们目前记住这四个参数一得以就可以了。
这里它又调用了putVal方法,我们点击查看
这里的 table 需要记住,它就是我们HashMap底层的那个数组的真实名称,接着往下看,putVal方法中我给了大量的注释,尽可能解释的详细一些
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
// 定义一个内部类 Node的数组,定义一个内部类 Node的对象定义 n,i两个变量
Node[] tab; Node p; int n, i;
// 将table 对象的值赋值给定义的Node数组对象,然后做判断
// 先判断 tab 等于空吗或者tab的长度为0吗,无参构造初始容量就为空,那么添加第一个元素时就会扩容
// 如果是,这里调用了 resize()方法,下面我展示到了
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 这里对应该存放数据的位置坐一次判空操作,看看是否已经存有元素了
if ((p = tab[i = (n - 1) & hash]) == null)
// 如果为空则说明该位置还尚未添加任何元素,就会执行这一行代码,
// tab[i] 即为添加的元素应该存放在HashMap集合中的位置
tab[i] = newNode(hash, key, value, null);
// 上述不执行说明要存入元素的位置不为空,进行下面的判断
else {
// 既然不为空,首先判断是否为链表,定义一个内部类Node的对象e,泛型对象 k
Node e; K k;
// 这里再次作判断,判断 key 是否已经存在
// 如果相等则把值直接进行替换
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 上方链表的判断未执行,则就是进入红黑树的判断了,判断是否是红黑树中的一个节点
else if (p instanceof TreeNode)
// 如果是,则调用putTreeVal方法添加该节点到红黑树中
e = ((TreeNode)p).putTreeVal(this, tab, hash, key, value);
else {
// 如果不是红黑树中的一个节点,那个就把它挂在链表的下面,用的是尾插法
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
// 这里会判断数组的长度是否超过了64并且链表的长度是否尝过了8
// 如果都满足条件会把链表转换为红黑树
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
我来简单描述一下,如下,假设我们定义了一个长度为16的HashMap,
下面在随机定义几个数据,地址分别为 0x0011,0x0022,0x0033,0x0044,0x0055,我们将它们依次存入HashMap;
(1)插入aaa时,取对象地址为0x0011的aaa的key,算出哈希值,假设应该存入2的位置,结合上述代码,在插入时会先做判断,判断2的位置是否为空,现在明显是为空的,直接存入,如下所示
(2)插入bbb时,取对象地址为0x0022的bbb的key,算出哈希值,假设应该存入6的位置,结合上述代码,在插入时会先做判断,判断6的位置是否为空,现在明显是为空的,直接存入,如下所示
(3)插入ccc时,取对象地址为0x0033的ccc的key,算出哈希值,假设应该存入2的位置,结合上述代码,在插入时会先做判断,判断2的位置是否为空,现在是不为空的,然后再使用equals方法判断已经存在的元素中是否有key与待存入的元素key相等,这里明显不相等,所以将ccc插入在aaa的下方,形成链表,之后还会判断长度是超过了8并且HashMap长度是否超过了64,若超过需要将链表转化为红黑树,这里没有,所以不转化,插入后结果如下图所示
(4)插入ddd时,取对象地址为0x0044的ddd的key,算出哈希值,假设也应该存入2的位置,结合上述代码,在插入时会先做判断,判断2的位置是否为空,现在是不为空的,然后再使用equals方法判断已经存在的元素中是否有key与待存入的元素key相等,这里明显不相等,所以将ddd插入在ccc的下方,形成链表,之后还会判断长度是超过了8并且HashMap长度是否超过了64,若超过需要将链表转化为红黑树,这里没有,所以不转化,如下图所示
(5)插入另一个ddd时,取对象地址为0x0055的ddd的key,算出哈希值,假设也应该存入2的位置,结合上述代码,在插入时会先做判断,判断2的位置是否为空,现在是不为空的,然后再使用equals方法判断已经存在的元素中是否有key与待存入的元素key相等,经过判断,发现已经存在了一个地址为0x0044的ddd,值为444,要新存入的0x0055的key也为ddd,但值为555,那么此时就会把0x0044的ddd所对应的值444覆盖为555,注意,这里不是把0x0044删除,而是把0x0044对应的值改为了新的555。之后还会判断长度是超过了8并且HashMap长度是否超过了64,若超过需要将链表转化为红黑树,这里没有,所以不转化,结果如下图所示
以上就是关于HashMap再添加元素时的流程,我这里只举了简单的例子,当元素多时,满足了转换成红黑树的条件,链表就转化为了红黑树,在这里就不再列举了,红黑树添加元素的过程可以参考我的另一篇文章,这里把链接放在这里了,有兴趣的可以观看,多谢各位的支持。
(1条消息) 数据结构——红黑树_程序猿ZhangSir的博客-CSDN博客https://blog.csdn.net/m0_70325779/article/details/131393598?spm=1001.2014.3001.5501
刚才的 putVal方法中就调用了 resize 方法,
// resize()方法的源码
final Node[] resize() {
// 这里先将内部定义好的table对象赋值给 oldTab
Node[] oldTab = table;
// 这里做判断oldTab为空吗,如果为空则赋值为0,如果不为空则获取oldTab的长度并赋值给oldCap
int oldCap = (oldTab == null) ? 0 : oldTab.length;
// 这里将原来带参构造中计算出的threshold赋值给oldThr
int oldThr = threshold;
// 定义newCap,newThr为0
int newCap, newThr = 0;
// 这里先做判断,将上面得到的数组容量做判断,判断是否大于0
if (oldCap > 0) {
// 如果大于0,判断是否大于或等于最大容量,若已经达到最大容量,则返回原数组
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 否则,左移一个2进制位,刚才说了,左移乘以2的1次方,就是扩容为原来的2倍
// 这里判断扩大为2倍后是否超过了最大容量
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
// 这里还要判断原来的容量是否大于等于默认初始容量,必须两个条件都满足才会扩容之原来的2倍
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
// 上述条件都不成立,这里会把默认初始容量赋值给newCap为16
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
// 这里newThr代表实际应该扩容时数组的长度为 16 * 0.75 = 12;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 这里对newThr做判断,若发生扩容,则newThr的值应该发生改变,改为新数组的0.75倍,即新扩容数据再次扩容时的长度
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[] newTab = (Node[])new Node[newCap];
table = newTab;
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
((TreeNode)e).split(this, newTab, j, oldCap);
else { // preserve order
Node loHead = null, loTail = null;
Node hiHead = null, hiTail = null;
Node next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
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);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
有些时候面试官可能会问一些刁钻的问题,就比如这个。
问你HashMap是否可以存空值?为什么能存空值?我之前有次面试就被问到了,这里先说结果,HashMap可以存空值;
原因:我们看源码可以得知,HashMap在调用putVal方法之前,参数列表中会先调用 hash 方法对键的哈希值做一次运算,如上图所示,我们可以看出在 hash 运算方法中,它做了一次判空操作,判断key == null吗?如果为空,后面将它赋值为0,即存入HashMap的0索引处。
通过上面对源码的解读,我们大致可以得出以下几个结论
(1)HashMap默认的初始容量为16;
(2)HashMap的最大容量为 2^30;
(3)HashMap底层的数据名称为 table;
(4)HashMap集合中每个元素成为一个节点,可能是链表节点,也可能是红黑树节点。若是链表节点,存放的数据包括该节点的哈希值,key值,value值,以及下一个结点的内存地址;如果是红黑树节点,会存放父结点的地址值,左子节点的地址值,右子节点的地址值,该节点的颜色,还有该节点的哈希值,该节点的Key值,该节点的value值。
(5)HashMap在JDK8之前,底层数据结构为数组 + 链表;在JDK8之后为数据+链表+红黑树,添加了红黑树这种数据结构,提高了查找的效率;
(6)HashMap在JDK8之前,添加元素用的是头插法,也就是当两个元素哈希值相同产生哈希碰撞时,新添加的元素会加在原来结点的头部;在JDK8之后,则是采用尾插法,插在了原来结点的尾部;
(7)HashMap转换为红黑树需要满足两个条件,一是HashMap的长度大于等于64,二是链表的长度超过8;
(8)HashMap的默认加载因子为0.75,即当HashMap集合中的元素超过最大容量的0.75倍时,就会出发扩容机制;
(9)HashMap扩容每次扩容为原来的2倍;
(9)因为HashMap扩容需要设计到数组的拷贝,链表的拷贝以及红黑树的拷贝。非常消耗性能,所以在开发过程中建议在定义时就选一个适当大小的容量作为初始容量,这样可以避免后期频繁增删导致扩容而带来的性能消耗,这种方法虽提高了程序的性能;但同时也会造成一些内存的浪费,尽管如此,但开发通常讲究牺牲空间换时间,所以还是建议在初始定义时就定义一个大小合适的容量;