深挖 HashMap

1.1 前言

作过 java 开发的朋友们相信都很熟悉 HashMap 这个类,它是一个基于 hashing 原理用于存储 Key-Value 键值对的集合,其中的每个键也叫作 Entry,这些键分别存储在一个数组当中,系统会根据 hash 方法来计算出 Key-Value 的存储位置,能够经过 key 快速存取 value。
HashMap 基于 hashing 原理,当咱们将一个键值对(Key-Value) 传入 put 方法时,它将调用这个 key 的 hashcode 方法计算出 key 的 hashcode 值,而后根据这个 hashcode 值来定位其存放数组的位置来存储对象(HashMap 使用链表来解决碰撞问题,当其发生碰撞了,对象将会存储在链表的下一个节点中,在链表的每一个节点中存储 Entry 对象,在 JDK 1.8+ 中,当链表的节点个数超过必定值时会转为红黑树来进行存储),当经过 get 方法传入一个 key 来获取其对应的值时,也是先经过 key 的 hashcode 方法来定位其存储在数组的位置,而后经过键对象的 eqauls 方法找到对应的 value 值。接下来让咱们看看其内部的一些实现细节。(PS:如下代码分析都是基于 JDK 1.8java

1.2 为何容量始终是 2 的整数次幂

由于获取 key 在数组中对应的下标是经过 key 的哈希值与数组的长度减一进行与运算来肯定的(tab[(n - 1) & hash])。当数组的长度 n 为 2 的整数次幂,这样进行 n - 1 运算后,以前为 1 的位后面全是 1 ,这样就能保证 (n - 1) & hash 后相应位的值既多是 1 又多是 0 ,这彻底取决于 key 的哈希值,这样就能保证散列的均匀,同时与运算(位运算)效率高。若是数组的长度 n 不是 2 的整数次幂,会形成更多的 hash 冲突。HashMap 提供了以下四个重载的构造方法来知足不一样的使用场景:算法

  1. 无参构造:HashMap(),使用该方法表示所有使用 HashMap 的默认配置参数
  2. 指定容量初始值构造:HashMap(int initialCapacity),在初始化 HashMap 时指定其容量大小
  3. 指定容量初始值和扩容因子构造:HashMap(int initialCapacity, float loadFactor),使用自定义初始化容量和扩容因子
  4. 经过 Map 来构造 HashMap:HashMap(Map<? extends K, ? extends V> m),使用默认的扩容因子,其容量大小有传入的 Map 大小来决定

前三个构造方法最终都是调用第三个即自定义容量初始值和扩容因子构造 HashMap(int initialCapacity, float loadFactor),其源码实现以下数组

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

从源码实现能够看出,若是咱们传入的初始容量值大于 MAXIMUM_CAPACITY 时,就设置容量为 MAXIMUM_CAPACITY,其值以下:this

/**
  * The maximum capacity, used if a higher value is implicitly specified
  * by either of the constructors with arguments.
  * MUST be a power of two <= 1<<30.
  */
static final int MAXIMUM_CAPACITY = 1 << 30;

也就是容量的最大值为 2 的 30 次方(1 << 30)。咱们知道,HashMap 的容量始终是 2 的整数次幂,无论咱们传入的初始容量是什么,它都会使用最接近这个值而且是 2 的整数次幂做为 HashMap 的初始容量,这一步处理是经过 tableSizeFor 方法来实现的,咱们看看它的源码:spa

hashmap-tableSizeFor.png

经过方法的注释咱们也能够知道(英语对于从事技术开发的人过重要了~~~),此方法的返回值始终是 2 的整数次幂,它是如何作到的呢?接下来咱们经过一个例子一步一步来看,假设咱们传入的初始容量大小 cap 的值 cap 为 15。设计

第 ① 步:将 cap - 1 后,n 的值为 14(15 - 1)。 3d

第 ② 步:将 n 的值先右移 1 位后与 n 进行 或预算(二者都为 0 结果为 0,其它状况都为 1),下面是具体的计算过程:code

hashmap-tableSizeFor-2.png

第 ③ 步:将 n 的值先右移 2 位后与 n 进行 或预算(二者都为 0 结果为 0,其它状况都为 1),下面是具体的计算过程:对象

hashmap-tableSizeFor-3.png

第 ④ 步:将 n 的值先右移 4 位后与 n 进行 或预算(二者都为 0 结果为 0,其它状况都为 1),下面是具体的计算过程:blog

hashmap-tableSizeFor-4.png

第 ⑤ 步:将 n 的值先右移 8 位后与 n 进行 或预算(二者都为 0 结果为 0,其它状况都为 1),下面是具体的计算过程:

hashmap-tableSizeFor-5.png

第 ⑥ 步:将 n 的值先右移 16 位后与 n 进行 或预算(二者都为 0 结果为 0,其它状况都为 1),下面是具体的计算过程:

hashmap-tableSizeFor-6.png

最后若是 n 的值小于 0,则返回 1,若是大于最大值 MAXIMUM_CAPACITY 则返回 MAXIMUM_CAPACITY,不然返回 n + 1。 如今 n 为 15,因此返回 n + 1(16),而 16 正好是 2 的 4 次幂。有的朋友可能会问,刚刚上文假设的初始容量大小 cap 是 15,原本就不是 2 的整数次幂,若是我传入初始容量的就是 2 的整数次幂那会怎么样呢?如今假设传的的初始容量大小的 32(2 的 5 次方)看看结果是什么。

第 ① 步:将 cap - 1 后,n 的值为 31(32 - 1)。

第 ② 步:将 n 的值先右移 1 位后与 n 进行 或预算(二者都为 0 结果为 0,其它状况都为 1),下面是具体的计算过程:

hashmap-tableSizeFor-even-1.png

第 ③ 步:将 n 的值先右移 2 位后与 n 进行 或预算(二者都为 0 结果为 0,其它状况都为 1),下面是具体的计算过程:

hashmap-tableSizeFor-even-2.png

第 ④ 步:将 n 的值先右移 4 位后与 n 进行 或预算(二者都为 0 结果为 0,其它状况都为 1),下面是具体的计算过程:

hashmap-tableSizeFor-even-4.png

第 ⑤ 步:将 n 的值先右移 8 位后与 n 进行 或预算(二者都为 0 结果为 0,其它状况都为 1),下面是具体的计算过程:

hashmap-tableSizeFor-even-5.png

第 ⑥ 步:将 n 的值先右移 16 位后与 n 进行 或预算(二者都为 0 结果为 0,其它状况都为 1),下面是具体的计算过程:

hashmap-tableSizeFor-even-6.png

通过以上 6 步计算后得出 n 的值为 31,大于 0 小于 MAXIMUM_CAPACITY 返回 n + 1,因此通过计算后的初始容量大小的 32。稍微总结一下,咱们能够得出:若是咱们传入的初始容量大小不是 2 的整数次幂,那么通过计算后的初始容量大小为大于咱们传入初始容量值的最小值而且是 2 的整数次幂。细心的朋友会发现,为何第一步要进行 cap - 1 的操做呢?那是由于,若是不进行 - 1 运算的话,当咱们传入的初始容量大小为 2 的整数次幂的时候,经过以上步骤计算出来的结果值为传入值的 2 倍。假设咱们传入的初始容量大小为 32,此时没有第 ① 步(cap - 1)的操做,那么依次经过以上 ②、③、④、⑤、⑥ 后为 63,最后再进行 n + 1 操做,结果为 64 是 传入值 32 的 2 倍,显然和预期结果(32)不符。这个计算初始容量的算法仍是很巧妙的,先进行了 -1 的操做,保证传入初始容量值为 2 的整数次幂的时候,返回传入的原始值。

1.3 hash 方法是如何实现的

不论是经过 get 方法获取 key 对应的 Value 值或者经过 put 方法存储 Key-Value 键值对时,都会先根据 key 的哈希值定位到数组的位置,咱们看看 HashMap 里的 hash 方法是如何实现的,源码以下:

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

当 key 为 null 时,返回 0,不然进行 h = key.hashCode()) ^ (h >>> 16 运算,先调用 key 的 hashCode 方法获取 key 的哈希值,而后与 key 的哈希值右移 16 位后的值进行异或运算(相同为 0,不一样为 1,简称 同假异真),为何获取 key 的哈希值还要再进行异或运算,直接返回 key 的哈希值好像也没什么问题,若是没有后面的异或运算,直接返回哈希值,咱们假设数组的长度为 16,如今要往 HashMap 存入的三个键值对的 key 的哈希值分别为 3283一、3355449五、2097215,根据 hash 方法返回值定位到数组的位置((n - 1) & hash),以上三个值和 15(16 - 1)进行 & 运算(都为 1 才为 1,其它状况都为 0) 以下:

hashmap-hashcode-2.png

能够发现以上三个哈希值都定位的数组下标为 15 的位置上。因此 hash 若是方法没有后面与哈希值右移 16 位后的值进行异或运算的话,当数组长度比较小时很容易形成 哈希碰撞,即多个 key(不一样的哈希值)都会定位到数组上的同一个位置,也就是说会放入到同一个链表或者红黑树中,由于此时 key 的哈希值只有低位的才会参与运算,显然和咱们的预期不符合。可见 hash 方法将 key 的哈希值与其右移 16 位后进行异或运算能减小哈希碰撞的次数,把高位和低位都参与了运算,提升了分散性。

1.4 总结

HashMap 其实还有不少值得咱们深刻研究的点,看懂了上面两个方法后,不得不佩服做者的代码设计能力,JDK 中有不少优秀源码都值得咱们好好品味,看代码的时候必定要多看几遍多问几个为何,特别是经典的源代码,而后将这些思想运用到咱们的实际工做中。

相关文章
相关标签/搜索