这篇文章记录一下hashmap的学习过程,文章并没有涉及hashmap整个源码,只学习一些重要部分,如有表述错误还请在评论区指出~
Hashmap采用key算hash映射到具体的value,因此查找效率为o(1),为防止hash冲突,在数组的基础上加入链表、红黑树,为无序非线程安全的存储结构
jdk1.8之前采用以下方式存储数据:
左边实际上就是一个数组,右边则是key值相同的元素放到同一个链表中(图片侵删)
但是这种数组加单链表也存在问题,即单链表长度过长时,搜索值将耗费时间复杂度为o(n),因此jdk1.8中提出数组+链表+红黑树的方法
该类是实现map接口的,并且也支持序列化、支持浅拷贝
第一种可以自己指定容量大小与负载因子,那么此时阈值已经确定,使用tableSizefor来找到大于等于指定容量的最小2的次方数作为阈值,其中输入的值先-1,保证返回的值要大于等于输入值
第二种可以仅指定容量,使用默认的负载因子,此时也会初始化阈值
第三种使用默认的容量16以及默认的负载因子0.75
第四种是由map来创建一个hashmap,使用默认的负载因子,以及能够将map放进hashmap的容量创建(不常用)
默认容量1左移4位位16,这里容量大小必须为2的次方,很有讲究 ,后面解释原因
最大容量为2的30次方
默认的负载因子0.75,和扩容相关,主要表示当前hashmap的填充度
node表,真正存储元素的表,为2的次方,其为hashmap的一个内部类
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; //key的hash值
final K key;
V value;
Node<K,V> next; //存储下一个节点的地址
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
<key,value>元素的个数,包括数组中的和链表中的元素
put方法,放入键值对:
首先将放入的键计算hash,然后调用putVal方法
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>\[\] tab; Node<K,V> p; int n, i;
//如果当前hash表为空,即还没有放入任何元素,则进行扩容操作,相当于初始化
if ((tab = table) == null || (n = tab.length) == 0)
n \= (tab = resize()).length;
//根据当前key的hash算出当前元素应该放到hash表中的下标,如果改位置为null,则放入
if ((p = tab\[i = (n - 1) & hash\]) == null)
tab\[i\] \= newNode(hash, key, value, null);
else {
Node<K,V> e; K k; //否则发生hash冲突,并且如果当前位置元素的hash和要放入元素的hash相同并且当前元素的key和要放入的key一样,则暂时保存当前冲突的node节点
if (p.hash == hash &&
((k \= p.key) == key || (key != null && key.equals(k))))
e \= p;
//若仅仅键的hash一样,但是key并不一样则首先判断是否是红黑树节点,如果是的话则将当前的键放进红黑树中,更新当前的hash表的冲突节点
else if (p instanceof TreeNode)
e \= ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//否则当前节点为链表 else {
//遍历链表(因为我们之前已经知道每个node节点都存储了下一个节点的地址,所以P.next变量即代表相对于当前node的下一个node,那么遍历到一个链表的尾部放入新的节点即可)
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next \= newNode(hash, key, value, null);
if (binCount >= TREEIFY\_THRESHOLD - 1) // -1 for 1st //放入后判断,如果当前hash表的长度>=7,则将当前hash位置处转为红黑树表示从而替换链表表示
treeifyBin(tab, hash);
break;
}
//如果遍历过程中发现链表中存在相同的key则break退出
if (e.hash == hash &&
((k \= e.key) == key || (key != null && key.equals(k))))
break;
p \= e; //否则更新p节点为e,从而实现循环遍历链表
}
}
//如果保存冲突节点的e变量不为null,则取冲突的值,根据onlyIfAbsent没有设置或者当前value为null,都将
if (e != null) { // existing mapping for key
V oldValue = e.value; //取到冲突节点的value
if (!onlyIfAbsent || oldValue == null)
e.value \= value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount; //hashmap修改次数,防止多线程冲突的
if (++size > threshold) //判断当前node节点的多少有没有到扩容的阈值
resize();
afterNodeInsertion(evict);
return null;
}
所以整个put的流程为:
①.首先根据要放入的key计算hash,然后根据hash获取table中的放入位置,如果当前table为空,则进行初始化
②.判断放入位置是否为空,为空则直接放入,否则判断是否为红黑树节点,不是则为链表,则遍历链表查找是否存在相同的key,没找到则放入链表尾部并判断是否需要转为红黑树(TREEIFY_THRESHOLD)
③.若查找链表找到相同key则替换,放入后要判断node节点数是否超过threshold,判断是否需要resize
resize方法,扩充当前容量:
final Node<K,V>\[\] resize() {
Node<K,V>\[\] oldTab = table; //保存旧的hash表
int oldCap = (oldTab == null) ? 0 : oldTab.length; //判断hash表的长度,若是第一次初始化则为0
int oldThr = threshold; //取旧的阈值
int newCap, newThr = 0; //定义新的长度和阈值
if (oldCap > 0) { //如果之前长度大于零
if (oldCap >= MAXIMUM\_CAPACITY) { //如果之前的长度大于等于2的30次
threshold \= Integer.MAX\_VALUE; //则将node节点阈值设置为2的31次-1
return oldTab; //返回旧的hash表,不再扩容
}
//否则满足扩容条件,进行扩容
else if ((newCap = oldCap << 1) < MAXIMUM\_CAPACITY && //如果旧的容量扩大一倍小于2的30次并且旧的容量大于默认的初始化容量大小16,阈值也变为原来的2倍
oldCap \>= DEFAULT\_INITIAL\_CAPACITY)
newThr \= oldThr << 1; // double threshold 则容量扩大一倍
}
//如果旧的容量为0,但是旧的阈值大于零,则可能是初始化hashmap时指定了容量,则直接将新的容量设置为旧的阈值
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
//对于没有设置初始容量的情况
else { // zero initial threshold signifies using defaults //如果是第一次初始化,则设置容量为16,阈值为16\*0.75=12,即hashmap可以放12个node节点
newCap = DEFAULT\_INITIAL\_CAPACITY;
newThr \= (int)(DEFAULT\_LOAD\_FACTOR \* DEFAULT\_INITIAL\_CAPACITY);
}
//如果新的阈值为0,则进行修正,令新的阈值为新的hash表容量长\*负载因子
if (newThr == 0) {
float ft = (float)newCap \* loadFactor;
newThr \= (newCap < MAXIMUM\_CAPACITY && ft < (float)MAXIMUM\_CAPACITY ?
(int)ft : Integer.MAX\_VALUE);
}
//设置完新的容量和新的阈值后,则开始进项node节点元素转移
threshold \= newThr; //先将新生成的阈值赋值给成员变量threshold
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>\[\] newTab = (Node<K,V>\[\])new Node\[newCap\]; //然后声明一个新的节点数组,容量即为扩充后的大小
table \= newTab; //替换成员标量table为新表
if (oldTab != null) { //遍历旧的容量大小,取其每个node节点
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab\[j\]) != null) { //如果该节点不为null
oldTab\[j\] \= null; //则让旧表的该位置为null,进行垃圾回收
if (e.next == null) //如果当前遍历的节点下一个为null,说明为尾节点(单个node节点,无链表,无红黑树)
newTab\[e.hash & (newCap - 1)\] = e; //则直接将该节点放到新的hash表中
//如果下一个节点不为null,则判断当前节点是否是红黑树节点,若是,则将新标的该节点转为红黑树节点 else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
//否则为单链表节点,则遍历当前链中的节点决定要放入新hash表的位置
else { // preserve order
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> 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;
}
这里设计很妙,原来的容量为2的次方,则只有1位为1,原来的下标是容量-1,则新增的一位bit,决定了节点hash新增的一位为1还是为0,来决定其存放位置,其也为随机的,从而均匀地将节点放到新的hash表中,新增一位为0则放到低位中,即索引值不变,新增一位为1,则放到高位中,这样原本在一条链中的节点就能够分布到两条链上,也减少了搜索的开销
1.jdk1.7中发生hash冲突新节点采用头插法,1.8采用的为尾插法
2.1.7采用数组+链表,1.8采用的是数组+链表+红黑树
3.1.7在插入数据之前扩容,而1.8插入数据成功之后扩容
1.在算key的hash时将key的hashcode和与hashcode的高16位做异或降低hash冲突概率
2.HashMap 的 bucket (数组)大小一定是2的n次方,便于后面等效取模以及resize时定节点分布(low或者high)
3.HashMap 在 put 的元素数量大于 Capacity * LoadFactor(默认16 * 0.75)=12 之后会进行扩容,负载因子大于0.75则会减小空间开销,
4.影响hashmap性能的两个参数就是负载因子和初始容量,扩容影响性能,因此最好能提前根据负载因此估算hashmap大小,扩容实际上是将当前node节点放入一个新的node数组
5.tab[i = (n - 1) & hash] 实际上用与运算代替取模操作,性能更好,n即为容量大小,n为2的次方,则n-1则其二进制位为全1,从而代替模运算,e.hash & oldCap 用与运算决定hash增加的一位为0或者为1
关于负载因子设置:
负载因子的大小决定了HashMap的数据密度。
负载因子越大密度越大,发生碰撞的几率越高,数组中的链表越容易长,造成查询或插入时的比较次数增多,性能会下降。
负载因子越小,就越容易触发扩容,数据密度也越小,意味着发生碰撞的几率越小,数组中的链表也就越短,查询和插入时比较的次数也越小,性能会更高。但是会浪费一定的内容空间。而且经常扩容也会影响性能,建议初始化预设大一点的空间。
按照其他语言的参考及研究经验,会考虑将负载因子设置为0.7~0.75,此时平均检索长度接近于常数
简单了解一下linkedhashmap,它直接继承自hashmap,大部分方法与hashmap相同,不过结构有所变化
1.由hashmap的单链表变为双向链表(有头指针和尾指针)
2.在accessOrder为true的情况下(默认为false,即默认不按访问顺序排序),put,get方法取完的节点放到链表尾部,按照Lru(最近最长时间未使用的方法进行排列,也便于删除最老的节点),保证遍历顺序和插入顺序一致(后插入的一定在后面遍历到,先插入的先遍历)
3.LinkedHashMap.Entry继承至HashMap的静态内部类HashMap.Node
头节点和尾节点的说明,以及accessOrder实际上就定义了从hashmap的get方法拿到要寻找的节点后,是否要放到尾部
如果要研究linkedhashmap的具体三个回调函数,hashmap中留给子类linkedhashmap去实现的
https://zhuanlan.zhihu.com/p/72296421
https://juejin.im/post/5aa47ef2f265da23a0492cc8#heading-4
https://blog.csdn.net/zxt0601/article/details/77429150
https://tech.meituan.com/2016/06/24/java-hashmap.html
https://blog.csdn.net/wangyi1225/article/details/99705173
https://blog.csdn.net/qq_36520235/article/details/82417949 1.7和1.8区别
https://blog.csdn.net/justloveyou_/article/details/52464440 == equals hashcode区别