HashMap面试题

HashMap的长度为什么是2的幂次方(1.8)

桶的计算方式

首先计算键值对的索引要满足俩个要求:不能越界,均匀分布

大家知道HashMap中,如果想存入数据,首先它需要根据key的 哈希值去定位落入那个桶中

而H % length(h根据key计算出来的哈希值)就能很好的满足这一点,但是取模运算较慢

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

先拿着key的哈希值,无符号右移16位 然后 ^ 异或上key的哈希值,得到一个值,再拿着这个值 & 上数组长度减一最后得出一个数,这个数就是得出的数脚标的位置,也就是存入桶的位置

由上面可以知道定位桶的最后需要做

原因

  1. HashMap的长度是2的次幂的话,可以让数据更散列更均匀的分布,更充分的利用数组的空间

假设数组长度是一个奇数,那参与最后的&运算的肯定就是偶数,那偶数的话,它的二进制的最后一位肯定永远是0,那说明一定是个偶数,

所以&完获取到的脚标永远是偶数位,那意味着奇数位的脚标永远都没值,有一半的空间是浪费的

  1. 这也是为什么hashmap每次扩容俩倍的原因

扩容后,元素新的位置,要么在角标原位,要么在原位+扩容长度的位置

hashMap1.8有什么改动

头插改尾插的原因

jdk1.7用的是头插法,1.8之后使用的是尾插法,那么为什么要这样做呢

比如现在是并发常见

  1. 线程启动T1,和T2都准备对Hashmap进行扩容操作,此时T1和T2指向的都是链表头节点的A,而T1和T2的下一个节点分别是T1.next,和T2.next,他们都指向节点B

  1. 第二步:开始扩容,这时候,假设线程T2的时间片用完,进入了休眠状态,而线程T1开始执行扩容操作,一直到线程T1扩容完成后,线程T2才被唤醒。
  2. 因为HashMap扩容采用的是头插法,线程T1执行之后,链表中的节点顺序发生了改变。但线程T2对于发生的一切还是不可知的,所以它指向的节点引用依然没变。如图所示,T2指向的是A节点,T2.ne
  3. xt指向的是B节点。
  4. 因为T1执行完扩容之后,B节点的下一个节点是A,而T2线程指向的首节点是A,第二个节点是B,这个顺序刚好和T1扩容之前的节点顺序是相反的。T1执行完之后的顺序是B到A,而T2的顺序是A到B,这样A节点和B节点就形成了死循环。

因为T1执行完扩容之后,B节点的下一个节点是A,而T2线程指向的首节点是A,第二个节点是B,这个顺序刚好和T1扩容之前的节点顺序是相反的。T1执行完之后的顺序是B到A,而T2的顺序是A到B,这样A节点和B节点就形成了死循环。

java8的rehash改动

在jdk1.7的时候,是将数组扩容为俩倍,然后将hashmap中所有的key重新进行hash寻址算法然后放入到扩容后的数组的新位置

但是jdk1.8之后,对rehash进行了优化,减少了对key重新进行hash求址的寻址算法过程

final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        int oldThr = threshold;
        int newCap, newThr = 0;
        if (oldCap > 0) {
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            //这个 newCap = oldCap << 1 就是扩容两倍的证据
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold
        }
        else if (oldThr > 0) // initial capacity was placed in threshold
            newCap = oldThr;
        else {               // zero initial threshold signifies using defaults
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        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"})
        //重新构建了一个新的数组,容量是上面计算出来的newCap
        //就是原来的两倍大小
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;
        //拿到老的数组,然后遍历每个数组的位置,对每个节点重新进行rehash
        if (oldTab != null) {
            for (int j = 0; j < oldCap; ++j) {
                Node<K,V> e;
                //当遍历到这个数组的位置有节点的时候,进入重新rehash
                if ((e = oldTab[j]) != null) {
                    oldTab[j] = null;
                    if (e.next == null)
                    //这个的意思很简单,就是这个节点只有一个
                    //也就是没有形成链表或者红黑树的时候
                    //此时处理就是重新进行hash寻址算法,找到在新数组的位置放上
                        newTab[e.hash & (newCap - 1)] = e;
                    else if (e instanceof TreeNode)
                    //就是这个已经是红黑树了,此时会进入红黑树rehash的过程
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    else { // preserve order
                    //这个就是链表的rehash的过程
                        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;
    }

其实rehash就是遍历数组的每个位置,判断节点的状态,是单个或者链表或者红黑树。接下来就每种情况进行讨论。

单个节点

其实重新进行hash寻址算法,找到对应数组的下标,放上就行了

链表

自习阅读源码后会发现,就是将之前的链表重新拆封为了俩个链表,一个链表rehash之后还是在当前位置的index,另一个链表rehash之后的位置变成了index+oldCap

至于为什么可以分为两个链表,这里说明一下。就是hash寻址算法对一个数组下标的所有节点,扩容后进行重新计算的时候,会发现计算出来的位置要么是在原来的index,要么实在原来的index + oldCap的位置,这是hash寻址的一个特点,所以基于这一个既定的结论,就去判断一下每个节点重新hash寻址之后是原来的位置还是index + oldCap的位置就行了(如何判断,就是源码图的第一个红框框出来的),判断是在原来的位置然后一个新的链表,在index + oldCap的位置也形成一个新的链表,这样计算完之后只要把新的两个链表挂在新的数组的 index 和 index + oldCap就行了(如何挂的,就是源码图的第二个红框框出来的)。这样就避免了对每个节点重新进行hash寻址算法,重新放到hash表中的过程,大大提高了效率,这也就是JDK1.8的HashMap扩容rehash算法优化。
红黑树

其实原理跟链表的差不多,就是链表拆成两个链表,红黑树这个拆成两个红黑树,分别挂到新的数组的位置上,只不过最后加个判断,就是判断这个红黑树是需要变成链表还是继续是红黑树。

所以在JDK1.8的rehash算法优化就是对原来的链表或者红黑树进行拆分成两部分,然后分别挂在原来数组的位置和 数组的位置 + oldCap的位置,这样做就避免了大量的节点进行重新hash寻址算法和重新放到hash表的过程,大大增加了扩容效率。

Last modification:October 31, 2022
如果觉得我的文章对你有用,请随意赞赏