初始HashMap及扩容

229 阅读3分钟

内容主要包含 HashMap的初始、扩容,属于学习笔记,如果不对,欢迎指正。

一、HashMap

1、HashMap 中主要的成员变量:size、threshold、loadFactor

  • size:map中 KV 键值对的数量。
  • threshold:map 扩容的临界值,当 size 超过 threshold 时,map 进行扩容。
  • loadFactor:负载因子,默认 0.75f。
  • capacity:容量。

2、HashMap 的容量初始及扩容

参考:developer.aliyun.com/article/756…

HashMap 中有阈值:threshold,阈值的计算公式:threshold = capacity * loadFactor。当 hashmap 的 size 大于 threshold 时,hashmap 会扩容,触发 rehash,十分损耗性能。

故而,在初始化 hashmap 时,指定容量,避免 hashmap 的扩容,可以提升性能。

当初始化一个 hashmap 时,预测到了有100个键值对。

HashMap<Integer, Integer> hashMap = new HashMap<>(100);

此时,hashMap 的 capacity 是 100 么? 答案是否定的。

初始话 hashMap 的 capacity 的规则是,当初始化容量时,采用第一个大于它的2的幂作为初始容量。

@Slf4j
public class InitHashMapDemo {

    public static void main(String[] args) throws Exception {
        int size = 100;
        HashMap<Integer, Integer> map = new HashMap<>(100);
        Class<?> clz = map.getClass();
        Method method = clz.getDeclaredMethod("capacity");
        method.setAccessible(true);
        log.error("map capacity before any put action : {}", method.invoke(map));
    }
}

运行代码得到结果:

16:12:27.806 [main] ERROR com.project.collection.maps.InitHashMapDemo - map capacity before any put action : 128

即便初始化 hashmap 时传入的是 100,hashmap 的 初始capacity 却是 128。

128 已经大于 100 了,但当我们往 hashmap 中循环 100 次 put 元素时,上面的初始化 hashmap 的写法,依旧会触发 hashmap 的扩容。

@Slf4j
public class InitHashMapDemo {

    public static void main(String[] args) throws Exception {
        int size = 100;
        HashMap<Integer, Integer> map = new HashMap<>(100);
        Class<?> clz = map.getClass();
        Method method = clz.getDeclaredMethod("capacity");
        method.setAccessible(true);
        log.error("map capacity before any put action : {}", method.invoke(map));
        for (int i = 0; i < size; i++) {
            map.put(i, i);
        }
        log.error("map capacity after put 100 elements to map : {}", method.invoke(map));
    }
}

运行代码得到结果:

16:19:58.340 [main] ERROR com.project.collection.maps.InitHashMapDemo - map capacity before any put action : 128
16:19:58.346 [main] ERROR com.project.collection.maps.InitHashMapDemo - map capacity after put 100 elements to map : 256

可以看到,循环结束后,hashmap 的 capacity = 256,说明 hashmap 在循环过程中进行了扩容。『扩容后的容量是扩容前容量的两倍』

具体原因是:

threshold = capacity * loadFactor => threshold = 128 * 0.75f => threshold = 96 当 hashmap 中 size 大于 96 时,会触发 hashmap 的扩容。

那么当我们可以预测到键值对数量时,应该怎么初始化 hashmap 呢?

通过上面的例子可以看出,我们预测到键值对为 100 时,hashmap 的 capacity 为 256 时不需要进行扩容。那么如何将 100 与 256 结合起来呢?

HashMap 的源码给了答案。

    /**
     * Implements Map.putAll and Map constructor.
     *
     * @param m the map
     * @param evict false when initially constructing this map, else
     * true (relayed to method afterNodeInsertion).
     */
    final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
        int s = m.size();
        if (s > 0) {
            if (table == null) { // pre-size
                **float ft = ((float)s / loadFactor) + 1.0F;**
                int t = ((ft < (float)MAXIMUM_CAPACITY) ?
                         (int)ft : MAXIMUM_CAPACITY);
                if (t > threshold)
                    threshold = tableSizeFor(t);
            }
            else if (s > threshold)
                resize();
            for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
                K key = e.getKey();
                V value = e.getValue();
                putVal(hash(key), key, value, false, evict);
            }
        }
    }

当 map.putAll(?) 时,会调用这段代码。hashmap 初始化 capacity 用的公式是:ft = size / loadFactor + 1.0f ⇒ ft = size / 0.75f + 1.0f,再取大于 ft 的第一个2的幂。

当我们的预测键值对 100 时,我们初始化 hashMap 时,传入 100/0.75 + 1.0 = 134。大于 134 的第一个2的幂就是 256,此刻 threshold = capacity * loadFactor = 256 * 0.75 = 192,这样就不会再次触发扩容了。

知道了公式后,每次我们初始化 hashmap 都需要进行计算吗?不用,guava 已经提供好了现成的方法了。

HashMap<Integer, Integer> hashMap = Maps.newHashMapWithExpectedSize(100);

初始化时,还是传入预测的键值对数量,内部会自动计算出最佳的入参。

它的内部实现也是根据 JDK8 中的公式:

  public static <K, V> HashMap<K, V> newHashMapWithExpectedSize(int expectedSize) {
    return new HashMap<>(capacity(expectedSize));
  }

  /**
   * Returns a capacity that is sufficient to keep the map from being resized as long as it grows no
   * larger than expectedSize and the load factor is ≥ its default (0.75).
   */
  static int capacity(int expectedSize) {
	    if (expectedSize < 3) {
	      checkNonnegative(expectedSize, "expectedSize");
	      return expectedSize + 1;
	    }
	    if (expectedSize < Ints.MAX_POWER_OF_TWO) {
	      // This is the calculation used in JDK8 to resize when a putAll
	      // happens; it seems to be the most conservative calculation we
	      // can make.  0.75 is the default load factor.
	      **return (int) ((float) expectedSize / 0.75F + 1.0F);**
	    }
	    return Integer.MAX_VALUE; // any large value
	  }

此刻修改下代码再次运行

@Slf4j
public class InitHashMapDemo {

    public static void main(String[] args) throws Exception {
        int size = 100;
        HashMap<Integer, Integer> map = Maps.newHashMapWithExpectedSize(size);
        Class<?>  clz = map.getClass();
        Method method = clz.getDeclaredMethod("capacity");
        method.setAccessible(true);
        log.error("map capacity before any put action : {}", method.invoke(map));
        for (int i = 0; i < size; i++) {
            map.put(i, i);
        }
        log.error("map capacity after put 100 elements to map : {}", method.invoke(map));
    }
}

可以看到结果,hashmap 并没有触发扩容

17:23:25.114 [main] ERROR com.project.collection.maps.InitHashMapDemo - map capacity before any put action : 256
17:23:25.119 [main] ERROR com.project.collection.maps.InitHashMapDemo - map capacity after put 100 elements to map : 256

3、HashMap 的一些概念

哈希碰撞

hashmap 的 put 操作,先计算 key 的 hashCode,再通过哈希算法的高位运算和取模运算,最终确定键值对的存储位置。当两个 key 定位到了相同的位置,就会发生哈希碰撞。

哈希得到的结果越分散,哈希碰撞的概率就越小,hashmap 的存储效率就越高。

哈希碰撞取决于 哈希桶数组 和 哈希算法。哈希桶数组属于空间成本,哈希算法属于时间成本,好的哈希算法加上扩容,减少哈希碰撞。

负载因子 load factor 为什么默认是 0.75f

结论:0.75f 是时间和空间效率的一个平衡选择。

hashmap 的阈值 threshold = load factor * capacity,当 hashmap 的 size 大于 threshold 时,hashmap 会进行扩容,扩容后的容量是扩容前的两倍。

当 load factor 过于小时,threshold 也随着变小,当持续进行 hashmap 的 put 操作时,也会触发 hashmap 的扩容,造成空间的浪费;

当 load factor 过于大时,threshold 也随便变大,扩容的次数减少,但产生哈希碰撞的概率上升,造成了时间上的低效。

在时间和空间比较特殊的情况下,可以做适当修改。例如,空间 > 时间,可适当减小 load factor 的值,其余情况不建议修改。

4、HashMap 与其它 Map 的比较

NameDesc
HashMap根据 key 的 hashCode 来存储数据,访问速度快;遍历时的顺序不可确定;只允许存在一个为 null 的 key;非线程安全。
TreeMap实现了 SortedMap 接口,默认按 key 升序;
LinkedHashMap是 HashMap 的一个子类,保存了数据插入的顺序。