Java Collections Framework 源码分析(6.2 - HashMap 扩容)

Joshua

上一篇文章分析了 HashMap 源码中 put 方法的逻辑以及相关哈希算法,处理哈希冲突的部分。我们也看到了 HashMap 内部是使用一个数组来存储元素的。这次我们会分析当元素数量发生变化时,HashMap 是如何管理数组大小的。

数组的扩容

putVal 函数的末尾我们可以看到这样一段代码:

if (++size > threshold)
    resize();

其中的 sizeHashMap 存储元素的数量,当增加元素或是移除元素时会对这个变量进行增减的操作。在这段代码中,当 size 大于 threshold 时会调用 resize() 函数。我们先看一下 threshold 变量。从名称上可以看出它起到了阈值的作用,当超过它的值时会重新调整数组的大小,接着让我们看一下它是如何发生变化的。

threshold

如果使用 HashMap 无参数的构造函数创建对象,在第一次放入元素时,会初始化用来存储元素的数组,其中核心的逻辑在 resize() 函数中,看一下下面的代码片段:

else {               // zero initial threshold signifies using defaults
    newCap = DEFAULT_INITIAL_CAPACITY;
    newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
……
threshold = newThr;

其中 DEFAULT_INITIAL_CAPACITY 为 16,而 DEFAULT_LOAD_FACTOR 为 0.75,而 newCap 就是新数组的大小。默认情况下当 HashMap 元素数量超过 3/4 时会引起数组的扩容。面试中往往会问到一个问题: 为什么默认的 load factor 是 0.75?要回答这个问题不妨看一下 HashMap 开头的注释:

* Because TreeNodes are about twice the size of regular nodes, we
* use them only when bins contain enough nodes to warrant use
* (see TREEIFY_THRESHOLD). And when they become too small (due to
* removal or resizing) they are converted back to plain bins.  In
* usages with well-distributed user hashCodes, tree bins are
* rarely used.  Ideally, under random hashCodes, the frequency of
* nodes in bins follows a Poisson distribution
* (http://en.wikipedia.org/wiki/Poisson_distribution) with a
* parameter of about 0.5 on average for the default resizing
* threshold of 0.75, although with a large variance because of
* resizing granularity. Ignoring variance, the expected
* occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
* factorial(k)). The first values are:
*
* 0:    0.60653066
* 1:    0.30326533
* 2:    0.07581633
* 3:    0.01263606
* 4:    0.00157952
* 5:    0.00015795
* 6:    0.00001316
* 7:    0.00000094
* 8:    0.00000006
* more: less than 1 in ten million

注释开头提及了普通的数组+链表的结构转换到红黑树的逻辑。之后提到在理想情况下,hashCode 应该是随机,服从泊松分布的,因此出现在数组某个位置的概率为 0.5,那么使用泊松分布公示计算列表长度为 k 的概率,即注释中使用的公式: (exp(-0.5) * pow(0.5, k) / factorial(k)),从后面的计算结果来看 0.75 是个比较折中,避免哈希冲突的参数。

resize() 函数

接着让我们看一下扩容的核心函数: resize()

Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;

在函数的开始先保留原始数组的与 threshold 的值。

if (oldCap > 0) {
    if (oldCap >= MAXIMUM_CAPACITY) {
        threshold = Integer.MAX_VALUE;
        return oldTab;
    }
    else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
             oldCap >= DEFAULT_INITIAL_CAPACITY)
        newThr = oldThr << 1; // double threshold
}

扩容时的逻辑很简单,在没有超过最大容量 MAXIMUM_CAPACITY 情况下会直接在原来的基础上翻倍(使用位运算 <<1 )。

threshold = newThr;
@SuppressWarnings({“rawtypes”,”unchecked”})
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;

接着使用扩容后的 newCap 初始化数组,作为 HashMap 存放元素的容器。接着需要做的就是将原来数组中的元素放到新的数组中。

if (oldTab != null) {
    for (int j = 0; j < oldCap; ++j) {
        Node<K,V> e;
        if ((e = oldTab[j]) != null) {
            oldTab[j] = null;
            if (e.next == null)
                newTab[e.hash & (newCap - 1)] = e;
            else if (e instanceof TreeNode)
                ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
……

这里的逻辑非常明了,取出原先数组的元素,如果不为空,且 next 为 null,即之前这个元素的 hash 值没有发生冲突,就重新计算在新数组中的序号,并放入其中。而如果当前节点是 TreeNode 的实例时,也就是说当前是使用红黑树,那么则调用 split 函数,这部分的逻辑会在下篇详细分析。

如果当前元素是有哈希冲突的,又是怎么处理的呢?继续往下看:

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;
    }
}

上述代码中复制了链表的数据,需要注意的是判断之前节点在放入 HashMap 时是否放入原先数组的第一个元素,且 hash 正好等于数组长度的特殊情况。

小结

这次分析了 HashMap 增加元素时,底层数组扩容的逻辑,在默认情况下当已有的元素数量超过数组容量的 75% 就会发生数组扩容的操作。会在原始大小的基础上翻倍,然后将原先数组中的元素复制给新的数组。从源码中我们可以看到默认的数组大小为 16,当元素达到 12 时会发生扩容。因此在一些需要长时间存储大量元素时,应该使用带参数的构造函数初始化 HashMap,避免在扩容时引起的数组复制,消耗不必要的资源。

下一次会分析 HashMap 红黑树转换的部分,这是 JDK8 新增的部分,目的在于提升 HashMap 在发生哈希冲突后的查找效率,希望你不会错过。

欢迎关注我的微信号「且把金针度与人」,获取更多高质量文章
QR.png

阅读 919

Reading-Thinking-Sharing
编程 架构 设计 -- 你想要的这里都有
17 声望
13 粉丝
0 条评论
17 声望
13 粉丝
文章目录
宣传栏