长亭百川云 - 文章详情

从JDK源码学习HashMap - tr1ple

博客园 - tr1ple

38

2024-07-20

这篇文章记录一下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区别

相关推荐
关注或联系我们
添加百川云公众号,移动管理云安全产品
咨询热线:
4000-327-707
百川公众号
百川公众号
百川云客服
百川云客服

Copyright ©2024 北京长亭科技有限公司
icon
京ICP备 2024055124号-2