面试官:"准备用HashMap存1w条数据,构造时传10000还会触发扩容吗?"

// 预计存入 1w 条数据,初始化赋值 10000,避免 resize。
HashMap<String,String> map = new HashMap<>(10000)
// for (int i = 0; i < 10000; i++)

Java 集合的扩容

HashMap 算是咱们最经常使用的集合之一,虽然对于 Android 开发者,Google 官方推荐了更省内存的 SparseArray 和 ArrayMap,可是 HashMap 依然是最经常使用的。java

咱们经过 HashMap 来存储 Key-Value 这种键值对形式的数据,其内部经过哈希表,让存取效率最好时能够达到 O(1),而又由于可能存在的 Hash 冲突,引入了链表和红黑树的结构,让效率最差也差不过 O(logn)。编程

总体来讲,HashMap 做为一款工业级的哈希表结构,效率仍是有保障的。数组

编程语言提供的集合类,虽然底层仍是基于数组、链表这种最基本的数据结构,可是和咱们直接使用数组不一样,集合在容量不足时,会触发动态扩容来保证有足够的空间存储数据数据结构

动态扩容,涉及到数据的拷贝,是一种「较重」的操做。那若是可以提早肯定集合将要存储的数据量范围,就能够经过构造方法,指定集合的初始容量,来保证接下来的操做中,不至于触发动态扩容。编程语言

这就引入了本文开篇的问题,若是使用 HashMap,当初始化是构造函数指定 1w 时,后续咱们当即存入 1w 条数据,是否符合与其不会触发扩容呢?函数

在分析这个问题前,那咱们先来看看,HashMap 初始化时,指定初始容量值都作了什么?学习

PS:本文所涉及代码,均以 JDK 1.8 中 HashMap 的源码举例。优化

HashMap 的初始化

在 HashMap 中,提供了一个指定初始容量的构造方法 HashMap(int initialCapacity),这个方法最终会调用到 HashMap 另外一个构造方法,其中的参数 loadFactor 就是默认值 0.75f。this

public HashMap(int initialCapacity, float loadFactor) {
  if (initialCapacity < 0)
    throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);
  if (initialCapacity > MAXIMUM_CAPACITY)
    initialCapacity = MAXIMUM_CAPACITY;
  if (loadFactor <= 0 || Float.isNaN(loadFactor))
    throw new IllegalArgumentException("Illegal load factor: " + loadFactor);

  this.loadFactor = loadFactor;
  this.threshold = tableSizeFor(initialCapacity);
}

其中的成员变量 threshold 就是用来存储,触发 HashMap 扩容的阈值,也就是说,当 HashMap 存储的数据量达到 threshold 时,就会触发扩容。spa

从构造方法的逻辑能够看出,HashMap 并非直接使用外部传递进来的 initialCapacity,而是通过了 tableSizeFor() 方法的处理,再赋值到 threshole 上。

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

tableSizeFor() 方法中,经过逐步位运算,就可让返回值,保持在 2 的 N 次幂。以方便在扩容的时候,快速计算数据在扩容后的新表中的位置。

那么当咱们从外部传递进来 1w 时,实际上通过 tableSizeFor() 方法处理以后,就会变成 2 的 14 次幂 16384,再算上负载因子 0.75f,实际在不触发扩容的前提下,可存储的数据容量是 12288(16384 * 0.75f)。

这种场景下,用来存放 1w 条数据,绰绰有余了,并不会触发咱们猜测的扩容。

HashMap 的 table 初始化

当咱们把初始容量,调整到 1000 时,状况又不同了,具体状况具体分析。

再回到 HashMap 的构造方法,threshold 为扩容的阈值,在构造方法中由 tableSizeFor() 方法调整后直接赋值,因此在构造 HashMap 时,若是传递 1000,threshold 调整后的值确实是 1024,但 HashMap 并不直接使用它。

仔细想一想就会知道,初始化时决定了 threshold 值,但其装载因子(loadFactor)并无参与运算,那在后面具体逻辑的时候,HashMap 是如何处理的呢?

在 HashMap 中,全部的数据,都是经过成员变量 table 数组来存储的,在 JDK 1.7 和 1.8 中虽然 table 的类型有所不一样,可是数组这种基本结构并无变化。那么 table、threshold、loadFactor 三者之间的关系,就是:

table.size == threshold * loadFactor

那这个 table 是在何时初始化的呢?这就要说会到咱们一直在回避的问题,HashMap 的扩容。

在 HashMap 中,动态扩容的逻辑在 resize() 方法中。这个方法不只仅承担了 table 的扩容,它还承担了 table 的初始化。

当咱们首次调用 HashMap 的 put() 方法存数据时,若是发现 table 为 null,则会调用 resize() 去初始化 table,具体逻辑在 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;
    if ((tab = table) == null || (n = tab.length) == 0)
    n = (tab = resize()).length; // 调用 resize()
    // ...
}

resize() 方法中,调整了最终 threshold 值,以及完成了 table 的初始化。

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;
        }
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; 
    }
    else if (oldThr > 0) 
        newCap = oldThr; // ①
    else {               
        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; // ③
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab; // ④
      // ....
}

注意看代码中的注释标记。

由于 resize() 还糅合了动态扩容的逻辑,因此我将初始化 table 的逻辑用注释标记出来了。其中 xxxCap 和 xxxThr 分别对应了 table 的容量和动态扩容的阈值,因此存在旧和新两组数据。

当咱们指定了初始容量,且 table 未被初始化时,oldThr 就不为 0,则会走到代码 的逻辑。在其中将 newCap 赋值为 oldThr,也就是新建立的 table 会是咱们构造的 HashMap 时指定的容量值。

以后会进入代码 的逻辑,其中就经过装载因子(loadFactor)调整了新的阈值(newThr),固然这里也作了一些限制须要让 newThr 在一个合法的范围内。

在代码 中,将使用 loadFactor 调整后的阈值,从新保存到 threshold 中。并经过 newCap 建立新的数组,将其指定到 table 上,完成 table 的初始化(代码 )。

到这里也就清楚了,虽然咱们在初始化时,传递进来的 initialCapacity 虽然被赋值给 threshold,可是它实际是 table 的尺寸,而且最终会经过 loadFactor 从新调整 threshold

那么回到以前的问题就有答案了,虽然 HashMap 初始容量指定为 1000,可是它只是表示 table 数组为 1000,扩容的重要依据扩容阈值会在 resize() 中调整为 768(1024 * 0.75)。

它是不足以承载 1000 条数据的,最终在存够 1k 条数据以前,还会触发一次动态扩容。

一般在初始化 HashMap 时,初始容量都是根据业务来的,而不会是一个固定值,为此咱们须要有一个特殊处理的方式,就是将预期的初始容量,再除以 HashMap 的装载因子,默认时就是除以 0.75。

例如想要用 HashMap 存放 1k 条数据,应该设置 1000 / 0.75,实际传递进去的值是 1333,而后会被 tableSizeFor() 方法调整到 2048,足够存储数据而不会触发扩容。

当想用 HashMap 存放 1w 条数据时,依然设置 10000 / 0.75,实际传递进去的值是 13333,会被调整到 16384,和咱们直接传递 10000 效果是同样的。

小结时刻

到这里,就了解清楚了 HashMap 的初始容量,应该如何科学的计算,本质上你传递进去的值可能并没有法直接存储这么多数据,会有一个动态调整的过程。其中就须要将咱们预期的值进行放大,比较科学的就是依据装载因子进行放大。

最后咱们再总结一下:

  1. HashMap 构造方法传递的 initialCapacity,虽然在处理后被存入了 loadFactor 中,但它实际表示 table 的容量。
  2. 构造方法传递的 initialCapacity,最终会被 tableSizeFor() 方法动态调整为 2 的 N 次幂,以方便在扩容的时候,计算数据在 newTable 中的位置。
  3. 若是设置了 table 的初始容量,会在初始化 table 时,将扩容阈值 threshold 从新调整为 table.size * loadFactor。
  4. HashMap 是否扩容,由 threshold 决定,而 threshold 又由初始容量和 loadFactor 决定。
  5. 若是咱们预先知道 HashMap 数据量范围,能够预设 HashMap 的容量值来提高效率,可是须要注意要考虑装载因子的影响,才能保证不会触发预期以外的动态扩容。

HashMap 做为 Java 最经常使用的集合之一,市面上优秀的文章不少,可是不多有人从初始容量的角度来分析其中的逻辑,而初始容量又是集合中比较实际的优化点。其实很多人也搞不清楚,在设置 HashMap 初始容量时,是否应该考虑装载因子,才有了此文。

若是本文对你有所帮助,留言、转发、点好看是最大的支持,谢谢!


公众号后台回复成长『 成长』,将会获得我准备的学习资料,也能回复『 加群』,一块儿学习进步。

相关文章
相关标签/搜索