HashMap源码,你可能忽视的几个问题

75 阅读6分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第1天,点击查看活动详情

HashMap源码,大家已经研究透了,它的主要流程想必大家都非常清楚了。今天就聊聊大家可能忽视的几个问题。

一 、初始化容量大小的计算

HashMap扩容是一个很消耗资源的操作(遍历所有的数据,放入新的数组中),所以我们在创建HashMap对象的最好能确定合适的初始化容量大小,避免扩容的发生。

HashMap指定初始化容量大小很简单,只需要在构造方法传入容量的大小。如:Map data = new HashMap(21); 值得注意的是,传入的容量大小并不是实际的容量大小,它会转为不小于该传入容量的大小的2的n次方(为什么一定要这样做呢?后面我们会讲到),比如,传入20 ,实际初始化容量大小为32 (2^5).

这个2的n次方值是怎么计算得到呢?HashMap巧妙的运用了位运算的或(|)和按位右移补零操作(>>>)获得。具体源码如下:

    static final int tableSizeFor(int cap) {
            int n = cap - 1;
            n |= n >>> 1;
            n |= n >>> 2;
            n |= n >>> 4;
            n |= n >>> 8;
            n |= n >>> 16;
            return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
        }

对于位运算不熟的同学来说,看到这样的代码会一脸懵逼吧。n |= n >>> 1 这是什么意思哦?稍微改变一下格式:n = n | (n >>> 1),这样好理解一点了吧。这样做的目的是什么呢?我们一步一步来试验一下,假设传入的是21。

int n = cap - 1;  // n = 20;

n |= n >>> 1;
解释:
n >>> 1将n的二进制码向右移位1位,如20的二进制码为10100,向右移1位,结果就是1010,
n = n | n >>> 1;
10100
 1010   或操作
-----
11010  结果

n |= n >>> 2;
解释:
11010
  110   (原n = 11010 向右移两位)
-----
11110  结果

n |= n >>> 4;
解释:
11110
    1   (原n = 11110 向右移四位)
-----
11111  结果

依此类推,我们可以发现,依次执行计算,可以逐渐把最高位前 n 位变为1,最终就是把传入的容量大小的二进制全都转为了1。最后结果再加上1,就变为了我们期望的结果,2 ^ n 次方。

ps: HashMap容器初始化是懒加载的,当添加第一条数据,它会真正开辟一个初始化容量大小的数组空间。

二、 HashMap的hash值计算及节点放入数组的位置

我们都知道每个对象都有一个确定的hashCode,它是一个int型,通过它与容量cap取余计算,可以获得它在数组[0, ..., cap-1]中的一个位置。在HashMap中,并没有直接使用hashCode进行取余计算以确定具体的位置,它是通过以下方法获得Hash值

static final int hash(Object key) {
        int h;
        // 获取key的hashCode, 再向右移动16位 (h >>> 16),从而得到原高位16位的一个数
        // 再将这个数与hashCode 进行异或运算,得到新的hash值。
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

为什么要进行这样的计算呢?我们先看看它是怎么根据hash值确定在数组的位置的

Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
    n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
    tab[i] = newNode(hash, key, value, null);

核心代码就是:i = (n - 1) & hash], n 为哈希表容量大小,前面说过, 容量大小为2的n次方,而 n - 1之后,它的二进制所有位都变为1了。 再与hash 进行「&」运算(位与运算,如果相对应位都是1,则结果为1,否则为0),实际得出来的值就是hash值的低位,具体取低位的几个数,与容量大小有关。(归功于容量为2的n次方,我们才能快速的直接通过&运算取余,这也回答了我们刚刚提的问题,为什么容量的大小应为2的n次方)。

通过上面的分析,我们可以得出这样的结论:根据hash值的低位确定它在数组的位置。正因为这样,如果不特殊处理,32位的hashCode我们大概率只会用到部分低位数据。而通过(h = key.hashCode()) ^ (h >>> 16),高位与低位的异或运算,就可以达到充分利用32位hashCode所有位的目的。

三、扩容:旧元素放在新空间的哪个位置?

当HashMap元素数量达到阈值(总容量 * 负载因子0.75)时,它就会扩容:table数组空间变为原来容量的2倍,并把旧元素重新放入新空间里。那么,旧元素会放入新空间的哪个位置呢?

HashMap存放元素会用三种数据结构:

  • 数组,当节点hash计算位置在数组为空时,直接放入数组中
  • 链表,当节点出现hash冲突时,会放入节点链表里。(前提链表长度小于8)
  • 红黑树,当链表长度达到8时,链表转为红黑树。

所以旧元素放入新空间的具体位置我们需要分三种情况考虑:

  • 数组, 源码核心代码:newTab[e.hash & (newCap - 1)] = e; 它是将节点hash值与新容量进行与运算得出新的位置,可能的结果是在原来的位置对应的位置上,也可能是原来位置*2上。因为旧的位置计算方法为,e.hash & (oldCap - 1),而newCap相对于oldCap,高位多了一个1。进行与运算时,结果可能与原来一样,也可能比原来在高位多出一个1

  • 链表:从上面数组中,我们得知节点hash值重新运算的结果可能与之前一样,也可能是之前的*2。同一个链表上的所有的节点计算结果也无外乎是这两种情况,所以它将拆为两个链表,分别放入对应的位置。

    // 源码
    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;
    }
    
  • 红黑树,原理跟链表一样,也会拆成两棵红黑树。但有点不一样的是,每棵红红黑树的节点数可能小于6,这时候就需要退化成链表了。涉及到红黑树的操作比较复杂,就不在这里细谈啦~