目录
一、分析HashMap的数据结构
1.使用数组存储,加快访问速度
2.数组中的链表,解决hash冲突
3.使用红黑树优化链表,防止大量hash冲突
二、HashMap主要源码解读
三、总结
在看源码之前,了解一下它的数据结构和运行过程,才能更快更加有效率的读懂源码。
HashMap实际存储的是一个数组。
transient Node[] table;
使用数组存储的好处有很多,最大的特点就是数组的访问速度相当快。
每当添加/获取元素时,HashMap会根据key值的hashCode,计算出当前key对应的键值对对象(Node.class)存放在数组的哪个索引位置上。
这个数组存储类型的是一个引用类型,在HashMap的内部类中,可以看一下它的源码(精简版)。
static class Node implements Map.Entry {
//存储Key的hash码
final int hash;
//存储key对象
final K key;
//存储value对象
V value;
Node next;
Node(int hash, K key, V value, Node next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
}
成员变量中除了保存着K,V,hash之外,还有一个Node变量,也就是他自己。这种组成方式,也是一种数据结构,叫做链。
链表
虽然数组的访问速度相当快,但是他的大小是固定不变的,而且每次插入元素和删除元素,其他的元素也要跟着一起发生变化,这对于经常插入或者删除元素的应用场景来说简直就是灾难。
因此也就有了另一种存储方式,叫做链,意思就是说,通过存储指针的方式,将下一个元素的地址引用,保存在上一个元素中。
根据其功能设计,又分为双向(需要保存上一个元素和下一个元素的引用地址)和单向链(仅需要保存下个元素的引用地址)。
虽然链表的存储不连续,而且大小也不是一样的,但是只要根据引用一级一级向下查找,就能够找到你想要的任何元素。
关于链和数组的区别和优缺点在此就不多赘述了,大家在看ArrayList和LinkedList的区别的时候就基本上都看过了。
由上可知HashMap它的底层存储结构为:存储着链表的数组。
通过数组和链表的双重结构,我们的hashMap已经可以说是很完善了,
但是如果发生大量的hash碰撞,就会导致一条甚至多条链过长的情况,这个时候由于链表的特性,会导致其获取元素的效率降低。
此时,使用树状存储结构将有效的解决该问题。
那么知道了HashMap的数据结构,可以进一步开始读取源码。
ps:不想看源码就直接跳到文章末尾看总结吧。
HashMap代码的核心主要就是存储这一块了,在知道了HashMap的存储结构之后,我们大概就能猜到它的代码的流程了,这里说一下put()方法的流程(不包含HashMap的一些优化策略):
现在再来看源码吧,put()方法直接调用了putVal()方法
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
hash码的运算,进行了位运算,右移了16位(这里的意义是什么?求大佬指出)
问题已经解决,为什么要存在hash方法而不是直接使用hashCode()
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
接着直接开始就是添加元素了,代码比较长,我们到后面一步一步拆解
1.计算得出该键值对应该存放的索引位置
HashMap第一次进行初始化是在第一次添加元素时,默认的数组长度时1<<4=16,默认的负载因子是0.75,也就是说,存放的元素超过数组的0.75就会进行重新扩容2倍(也就是new一个新的数组,长度=oldLength*2);
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
//局部变量tab = 成员变量table
Node[] tab;
//局部变量p存储table[i] i是该键值对对应的索引
Node p;
//局部变量n存储数组table的length
int n, i;
//如果数组table=null,或者table.length=0,那么调用resize()方法,重新创建数组table并重置数组大小
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//i = (n - 1) & hash:计算出该键值对对应的数组索引
//如果(p = tab[i]) == null ,说明没有存储节点,直接new一个Node存进去
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else{
//省略若干行代码
}
//省略若干行代码
}
2.当发生hash碰撞时,如何处理
如果发生hash碰撞,首先判断key值是否相等,如果相等则覆盖旧值,如果不等则将该键值对添加到链表末尾。
在添加元素之后,需要判断链表的长度(1.8版本之后),如果hashMap的长度大于等于8,则会将链表转换成红黑树存储(树状存储结构在查找和删除上花费的时间更少)。
//省略上面的代码
{
//e存储的是最后一次从链表中获取的元素索引
Node e; K k;
//当碰撞的k的hash码以及equals方法相同时,或者两个k是同一个对象时
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
//因为k相同,所以最后一次获取的链就是p了,直接赋值给e
e = p;
//此处涉及到了HashMap的优化(当链表达到一定长度时,改用红黑树存储),暂时跳过
else if (p instanceof TreeNode)
e = ((TreeNode)p).putTreeVal(this, tab, hash, key, value);
//这里就是哈希碰撞的车祸现场了
else {
//循环遍历链表,binCount保存的是链表的长度
for (int binCount = 0; ; ++binCount) {
//当该链的下一链为null时
if ((e = p.next) == null) {
//构造该键值对的node,并且将引用地址保存在链表的末尾
p.next = newNode(hash, key, value, null);
//此处是链表的优化,暂时跳过
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
//注意,当成功添加该键值对到链表末尾之后,此时跳出循环,e=null
break;
}
//同上,这里也是判断K值重复操作
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//有上面的代码可以知道,当成功添加之后,e=null值,
//如果k值相同,e对应的就是那个k相同的node索引
if (e != null) { // existing mapping for key
V oldValue = e.value;
//因为onlyIfAbsent是写死的false,所以此处一定会将旧的value值替换成新键值对的value值
if (!onlyIfAbsent || oldValue == null)
e.value = value;
//默认配置是啥也没干,该方法是为了HashMap的子类实现的
afterNodeAccess(e);
//返回旧的value值
return oldValue;
}
}
//省略代码
3.插入成功后,判断是否需要扩容
//下面三个都是成员变量
++modCount;
//当Map的大小足够大时,此时需要扩容
if (++size > threshold)
resize();
//默认啥也没干,该方法是为了HashMap的子类实现的
afterNodeInsertion(evict);
return null;
看到这里呢,整个HashMap的核心基本上也就看完了。
总结一下就是: