上一篇文章分析了 HashMap
源码中 put
方法的逻辑以及相关哈希算法,处理哈希冲突的部分。我们也看到了 HashMap
内部是使用一个数组来存储元素的。这次我们会分析当元素数量发生变化时,HashMap
是如何管理数组大小的。
数组的扩容
从 putVal
函数的末尾我们可以看到这样一段代码:
if (++size > threshold)
resize();
其中的 size
是 HashMap
存储元素的数量,当增加元素或是移除元素时会对这个变量进行增减的操作。在这段代码中,当 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[] 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[] newTab = (Node[])new Node[newCap];
table = newTab;
接着使用扩容后的 newCap
初始化数组,作为 HashMap
存放元素的容器。接着需要做的就是将原来数组中的元素放到新的数组中。
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node 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)e).split(this, newTab, j, oldCap);
……
这里的逻辑非常明了,取出原先数组的元素,如果不为空,且 next
为 null,即之前这个元素的 hash 值没有发生冲突,就重新计算在新数组中的序号,并放入其中。而如果当前节点是 TreeNode
的实例时,也就是说当前是使用红黑树,那么则调用 split
函数,这部分的逻辑会在下篇详细分析。
如果当前元素是有哈希冲突的,又是怎么处理的呢?继续往下看:
else { // preserve order
Node loHead = null, loTail = null;
Node hiHead = null, hiTail = null;
Node 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
在发生哈希冲突后的查找效率,希望你不会错过。
欢迎关注我的微信号「且把金针度与人」,获取更多高质量文章