从JDK源码学习HashMap
这篇文章记录一下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,则放到高位中,这样原本在一条链中的节点就能够分布到两条链上,也减少了搜索的开销
jdk1.7和1.8的Hashmap区别
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
简单了解一下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区别