HashMap的实现原理和扩容原理是什么样的? | 话题写作

1,595 阅读12分钟

本文已参与掘金创作者训练营第三期「话题写作」赛道,详情查看:掘力计划|创作者训练营第三期正在进行,「写」出个人影响力

📖前言

手执烟花以谋生 心怀诗意以谋爱

我也不知道是什么让我有勇气参与话题写作,能力不高,欢迎指点,切勿吐槽谢谢哦!


🚀什么是 HashMap

HashMap 是基于哈希表的 Map接口 的非同步实现(HashtableHashMap 很像,唯一的区别是 Hashtalbe 中的方法是线程安全的,也就是同步的)。此实现提供所有可选的映射操作,并允许使用 null 值和 null 键。此类不保证映射的顺序,特别是它不保证该顺序恒久不变。

数据结构:

image.png


🐱‍🏍看几个问题

问题结论
HashMap 是否允许空KeyValue 都允许为空
HashMap 是否允许重复数据Key 重复会覆盖、Value 允许重复
HashMap 是否有序无序,特别说明这个无序指的是遍历 HashMap 的时候,得到的元素的顺序基本不可能是 put 的顺序
HashMap 是否线程安全非线程安全

🤳HashMap 的数据结构:

java 编程语言中,最基本的结构就是两种,一个是数组,另外一个是模拟指针(引用),所有的数据结构都可以用这两个基本结构来构造的,HashMap 也不例外。HashMap 实际上是一个“链表的数组”的数据结构,每个元素存放链表头结点的数组,即数组和链表的结合体。

HashMap 底层就是一个数组结构,数组中的每一项又是一个链表。当新建一个 HashMap 的时候,就会初始化一个数组。源码如下:

/**
 * The table, resized as necessary. Length MUST Always be a power of two.
 */
transient Entry[] table;

static class Entry<K,V> implements Map.Entry<K,V> {
    final K key;
    V value;
    Entry<K,V> next;
    final int hash;
    ……
}

可以看出,Entry 就是数组中的元素,每个 Map.Entry 其实就是一个 key-value 对,它持有一个指向下一个元素的引用,这就构成了链表。

HashMap 的底层主要是基于数组和链表来实现的,它之所以有相当快的查询速度主要是因为它是通过计算散列码来决定存储的位置。HashMap 中主要是通过 keyhashCode 来计算 hash 值的,只要 hashCode 相同,计算出来的 hash 值就一样。如果存储的对象对多了,就有可能不同的对象所算出来的 hash 值是相同的,这就出现了所谓的 hash 冲突。HashMap 底层是通过链表来解决 hash 冲突的。这里请自行百度查找!


🎶HashMap 的构造函数:

HashMap 提供了三个构造函数:

  • HashMap():构造一个具有默认 初始容量 (16) 默认加载因子 (0.75) 的空 HashMap
  • HashMap(int initialCapacity):构造一个带 指定初始容量默认加载因子 (0.75) 的空 HashMap
  • HashMap(int initialCapacity, float loadFactor):构造一个带指定初始容量和加载因子的空 HashMap

初始容量加载因子。这两个参数是影响 HashMap性能 的重要参数,其中容量表示哈希表中桶的数量,初始容量是创建哈希表时的容量,加载因子是哈希表在其容量自动增加之前可以达到多满的一种尺度,它衡量的是一个散列表的空间的使用程度,负载因子越大表示散列表的装填程度越高,反之愈小。对于使用链表法的散列表来说,查找一个元素的平均时间是 O(1+a),如果负载因子越大,对空间的利用更充分,然而后果是查找效率的降低;如果负载因子太小,那么散列表的数据将过于稀疏,对空间造成严重浪费。系统默认负载因子为0.75

一般我们用 HashMap() 就够了


👏HashMap 的存取实现等

✨存储

public V put(K key, V value) {
    //当key为null,调用putForNullKey方法,保存null与table第一个位置中,这是HashMap允许为null的原因
    if (key == null)
        return putForNullKey(value);
    //计算key的hash值
    int hash = hash(key.hashCode());                  ------(1)
    //计算key hash 值在 table 数组中的位置
    int i = indexFor(hash, table.length);             ------(2)
    //从i出开始迭代 e,找到 key 保存的位置
    for (Entry<K, V> e = table[i]; e != null; e = e.next) {
        Object k;
        //判断该条链上是否存在相同的key值
        //若存在相同,则直接覆盖value,返回旧value
        if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
            V oldValue = e.value;    //旧值 = 新值
              e.value = value;
            e.recordAccess(this);
            return oldValue;     //返回旧值
        }
    }
    //修改次数增加1
    modCount++;
    //将key、value添加至i位置处
     addEntry(hash, key, value, i);
    return null;
}

HashMap 保存数据的过程为:首先判断 key 是否为 null,若为 null,则直接调用 putForNullKey 方法,将 value 放置在数组第一个位置上。若不为空则根据 keyhashCode 重新计算 hash 值,然后根据 hash 值得到这个元素在 table数组 中的位置(即下标),如果 table数组 在该位置处已经存放有其他元素了,则通过比较是否存在相同的 key,若存在则覆盖原来 keyvalue,否则将该元素保存在链头(最先保存的元素放在链尾)。若 table 在该处没有元素,就直接将该元素放到此数组中的该位置上。看以下几点

  1. 先看迭代。此处迭代是为了防止存在相同的 key 值,若发现两个 key 值相同时,HashMap 的处理方式是用新 value 替换旧 value,这里并没有处理 key,这就解释了 HashMap 中没有两个相同的 key。另外,注意一点,对比 Key 是否相同,是先比 HashCode 是否相同,HashCode 相同再判断 equals 是否为 true查阅博主历史文章有这个的讲解--等等我发在掘金),这样大大增加了 HashMap 的效率。

  2. 在看(1)、(2)处。这里是 HashMap 的精华所在。首先是 hash 方法,该方法为一个纯粹的数学计算,就是计算h的 hash 值。此算法加入了高位计算,防止低位不变,高位变化时,造成的 hash 冲突。(咱也不太懂咱也不敢乱说哈哈哈哈

  3. HashMap 是基于 数组+链表和红黑树 实现的,但用于存放 key 值得的数组桶的长度是固定的,由 初始化决定 。随着 数据的插入数量增加以及负载因子 的作用下,就 需要扩容来存放更多的数据。而扩容中有一个非常重要的点,就是 jdk1.8 中的优化操作,可以不需要再重新计算每一个元素的哈希值。

static int hash(int h) {
    h ^= (h >>> 20) ^ (h >>> 12);
    return h ^ (h >>> 7) ^ (h >>> 4);
}

我们知道对于 HashMaptable 而言,数据分布需要均匀(最好每项都只有一个元素,这样就可以直接找到),不能太紧也不能太松,太紧会导致查询速度慢,太松则浪费空间。计算 hash值后,怎么才能保证 table 元素分布均与呢?我们会想到取模,但是由于取模的消耗较大,HashMap 是这样处理的:调用 indexFor 方法。

static int indexFor(int h, int length) {
    return h & (length-1);
}

🎎链的产生

这是一个非常优雅的设计。系统总是将新的 Entry 对象添加到 bucketIndex 处。如果 bucketIndex 处已经有了对象,那么新添加的 Entry 对象将指向原有的 Entry 对象,形成一条 Entry 链,但是若 bucketIndex 处没有 Entry 对象,也就是 e==null,那么新添加的 Entry 对象指向 null,也就不会产生 Entry 链了。

🎊扩容问题

随着 HashMap 中元素的数量越来越多,发生碰撞的概率就越来越大,所产生的链表长度就会越来越长,这样势必会影响 HashMap 的速度,为了保证 HashMap 的效率,系统必须要在某个临界点进行扩容处理。该临界点在当 HashMap中元素的数量等于 table数组长度\*加载因子。但是扩容是一个非常耗时的过程,因为它需要重新计算这些数据在新 table数组 中的位置并进行复制处理。所以如果我们已经预知 HashMap 中元素的个数,那么预设元素的个数能够有效的提高 HashMap 的性能。

根据上面 put 方法的源代码可以看出,当程序试图将一个 key-value 对放入 HashMap 中时,程序首先根据该 keyhashCode() 返回值决定该 Entry 的存储位置:如果两个 EntrykeyhashCode() 返回值相同,那它们的存储位置相同。如果这两个 Entrykey 通过 equals 比较返回 true,新添加 Entryvalue 将覆盖集合中原有 Entryvalue,但 key 不会覆盖。如果这两个 Entrykey 通过 equals 比较返回 false,新添加的 Entry 将与集合中原有 Entry 形成 Entry 链,而且新添加的 Entry 位于 Entry 链的头部。

上源码:(注释直接写在代码里面)

image.png

final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    // Cap 是 capacity 的缩写--容量。若不为空就已经初始化过了
    if (oldCap > 0) {
        // 若容量达到最大 1<< 30 则停止扩容
        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
    }
    else if (oldThr > 0) // initial capacity was placed in threshold
    
        // initial capacity was placed in threshold 翻译过来的意思,如下;
        // 初始化时,将 threshold 的值赋值给 newCap,
        // HashMap 使用 threshold 变量暂时保存 initialCapacity 参数的值
        newCap = oldThr;
    else {               // zero initial threshold signifies using defaults
        // 这一部分也是,源代码中也有相应的英文注释
        // 调用无参构造方法时,数组桶数组容量为默认容量 1 << 4; aka 16
        // 阀值;是默认容量与负载因子的乘积,0.75
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    
    // newThr为0,则使用阀值公式计算容量
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;
    @SuppressWarnings({"rawtypes","unchecked"})
    // 初始化数组桶,用于存放key
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    if (oldTab != null) {
        // 如果旧数组桶,oldCap有值,则遍历将键值映射到新数组桶中
        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)
                    // 这里split,是红黑树拆分操作。在重新映射时操作的。
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                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;
                    }
                }
            }
        }
    }
    return newTab;
}
  1. 扩容时计算出新的 newCap、newThr,这是两个单词的缩写,一个是 Capacity 容量,另一个是阀值 Threshold
  2. newCap 用于创新的数组桶 new Node[newCap];
  3. 随着扩容后,原来那些因为哈希碰撞,存放成链表和红黑树的元素,都需要进行拆分存放到新的位置中。

📖读取

public V get(Object key) {
    // 若为null,调用getForNullKey方法返回相对应的value
    if (key == null)
        return getForNullKey();
    // 根据该 key 的 hashCode 值计算它的 hash 码  
    int hash = hash(key.hashCode());
    // 取出 table 数组中指定索引处的值
    for (Entry<K, V> e = table[indexFor(hash, table.length)]; e != null; e = e.next) {
        Object k;
        //若搜索的key与查找的key相同,则返回相对应的value
        if (e.hash == hash && ((k = e.key) == key || key.equals(k)))
            return e.value;
    }
    return null;
}

从上面的源代码中可以看出:从HashMap中get元素时,首先计算key的hashCode,找到数组中对应位置的某一元素,然后通过key的equals方法在对应位置的链表中找到需要的元素。

HashMap 的每个 bucket(桶) 里存储的 Entry 只是单个 Entry ——也就是没有通过指针产生 Entry 链时,此时的 HashMap 具有最好的性能:当程序通过 key 取出对应 value 时,系统只要先计算出该 keyhashCode() 返回值,在根据该 hashCode 返回值找出该 keytable 数组中的索引,然后取出该索引处的 Entry,最后返回该 key 对应的 value 即可。

如果 HashMap 的每个 bucket 里只有一个 Entry 时,HashMap 可以根据索引、快速地取出该 bucket 里的 Entry;在发生“Hash 冲突”的情况下,单个 bucket 里存储的不是一个 Entry,而是一个 Entry 链,系统只能必须按顺序遍历每个 Entry,直到找到想搜索的 Entry 为止——如果恰好要搜索的 Entry 位于该 Entry 链的最末端(该 Entry 是最早放入该 bucket 中),那系统必须循环到最后才能找到该元素。

HashMap 在底层将 key-value 当成一个整体进行处理,这个整体就是一个 Entry 对象。HashMap 底层采用一个 Entry[] 数组来保存所有的 key-value 对,当需要存储一个 Entry 对象时,会根据 hash 算法来决定其在数组中的存储位置,在根据 equals 方法决定其在该数组位置上的链表中的存储位置;当需要取出一个 Entry 时,也会根据 hash 算法找到其在数组中的存储位置,再根据 equals 方法从该位置上的链表中取出该 Entry

  • HashMap 中对 KeyHashCode 要做一次 rehash,防止一些糟糕的 Hash算法 生成的糟糕的 HashCode,那么为什么要防止糟糕的 HashCode
    • 糟糕的 HashCode 意味着的是 Hash 冲突,即多个不同的 Key 可能得到的是同一个 HashCode,糟糕的 Hash算法 意味着的就是 Hash 冲突的概率增大,这意味着 HashMap 的性能将下降,表现在两方面
    • (1)、有10个 Key,可能6个 KeyHashCode 都相同,另外四个 Key 所在的Entry均匀分布在 table 的位置上,而某一个位置上却连接了 6个Entry。这就失去了 HashMap 的意义,HashMap 这种数据结构性高性能的前提是,Entry均匀地分布在 table 位置上,但现在确是 1 1 1 1 6 的分布。所以,我们要求 HashCode 有很强的随机性,这样就尽可能地可以保证了 Entry 分布的随机性,提升了 HashMap 的效率。
    • (2)、HashMap 在一个某个 table位置 上遍历链表的时候的代码:if (e.hash == hash && ((k = e.key) == key || key.equals(k)))

🎉最后

  • 更多参考精彩博文请看这里:陈永佳的博客
  • 喜欢博主的小伙伴可以加个关注、点个赞哦,持续更新嘿嘿!