深入浅出HashMap的设计与优化

本文深入解析HashMap的内部结构,包括数组、链表和红黑树的运用,探讨哈希冲突解决策略,以及如何通过合理设置初始容量和加载因子优化性能。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

可以在我的个人网站中查看该文章?深入浅出HashMap的设计与优化
该篇文章是 极客时间 《Java性能调优实战》中的内容,以下是自己整理的算是笔记吧。

深入浅出HashMap的设计与优化

我们先了解一下常用的数据结构。

常用的数据结构

  • 数组:采用一段连续的存储单元来存储数据。对于指定下表的查找,时间复杂度为O(1),但在数组中间以及头部插入数据时,需要复制移动后面的元素。
  • 链表:一种在物理存储单元上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。
    • 链表由一系列结点(链表中每一个元素)组成,结点可以在运行时动态生成。每个结点都包含“存储数据单元的数据域”和“存储下一个结点的指针域”这两部分。
    • 由于链表不用必须按顺序存储,所以链表在插入的时候可以达到O(1)的复杂度,但查找一个结点或者访问特定编号的结点需要O(n)的时间。
  • 哈希表:根据关键码值(key value)直接进行访问的数据结构。通过把关键码映射到表中一个位置来访问记录,以加快查找速度。这个映射函数叫做哈希函数,存放记录的数组就叫做哈希表。
  • :由n(n>1)个有限结点组成的一个具有层次关系的集合,就像是一棵倒挂的树。

HashMap的实现结构

作为最常用的Map类,它是基于哈希表实现的,继承了AbstractMap并实现了Map接口。

哈希表将键Hash值映射到内存地址,即根据键获取对应的值,并将其存储到内存地址。也就是说HashMap是根据键的Hash值来决定对应值的存储位置。通过这种索引方式,HashMap获取数据的速度会非常快。

例如:存储键值对(x,"aa")时,哈希表会通过哈希函数f(x)得到"aa"的实现存储位置。

但也会有新的问题。如果再来一个(y,"bb"),哈希函数f(y)的哈希值跟之前f(x)是一样的,这样两个对象的存储地址就冲突了,这种现象就被称为哈希冲突解决哈希冲突的方式很多,比如:开放定址法、再哈希函数法和链地址法

  • 开放定址法:开放定址法很简单,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置后面的空位置上去,这种方式存在很多缺点,例如,查找、扩容等。
  • 再哈希法:再哈希法就是在同义词产生地址冲突时再计算另一个哈希函数地址,直到冲突不再发生,这种方法不易产生“聚集”,但却增加了计算时间。如果不考虑添加元素的时间成本,且对查询元素的要求极高,可以考虑这种算法设计。
  • 链地址法:HashMap则是综合考虑了所有元素,采用链地址法解决哈希冲突问题。这种方式采用了数组(哈希表) + 链表的数据结构,当发生哈希冲突时,就用一个链表结构存储相同Hash值的数据。

HashMap的重要性

HashMap的源码中,可以发现,HashMap是由一个Node数组构成,每个Node包含了一个key-value键值对。

Node类作为HashMap中的一个内部类,除了key、value两个属性外,还定义了一个next指针。当有哈希冲突 时,HashMap会用之前数组当中相同哈希值对应存储的Node对象,通过指针指向新增的相同哈希值的Node对象的引用。

HashMap还有两个重要的属性:加载因子(loadFactor)和边界值(threshold)。在初始化HashMap时,就会涉及到这两个关键初始化参数。

loadFactor属性是用来间接设置Entry数组(哈希表)的内存空间大小,在初始化HashMap不设置参数的情况下,默认loadFactor值为0.75

  • 为什么是0.75?
    • 因为对于使用链表法的哈希表来说,查找一个元素的平均时间是O(1+n),这里的 n 指的是遍历链表的长度,因此加载因子越大,对空间的利用就越充分,这就意味着链表的长度越长,查找效率也就越低。如果设置的加载因子太小,那么哈希表的数据将过于稀疏,对空间造成严重浪费。

Entry数组的Threshold是通过初始容量(16)和loadFactor计算得到的。在初始HashMap不设置参数的情况下,默认边界值为12。(16 * 0.75)。如果在初始化时,设置初始容量较小,HashMap中Node的数量超过边界值,HashMap就会调用resize()方法重新分配table数组。这将会导致HashMap数组复制,迁移到另一块内存中去,从而影响HashMap的效率。

HashMap添加元素优化

初始化完成后,HashMap就可以使用put()方法添加键值对了。从下面源码可以看出,当程序将一个key-value对添加到HashMap中,程序首先会根据该key的hashCode()返回值,再通过hash()方法计算出hash值,再通过putVal方法中的(n-1) & hash决定该Node的存储位置。

如果不太清楚hash()以及(n-1)&hash的算法,就请看下面详述:

如果我们没有使用hash()方法计算hashCode,而是直接使用对象的hashCode值,会出现什么问题?

假设要添加两个对象 a 和 b,如果数组长度是16,这时对象 a 和 b 通过公式(n-1)&hash运算,也就是(16-1) & a.hashCode(16-1) & b.hashCode,15的二进制为0000000000000000000000000001111,假设对象 a 的hashCode为1000010001110001000001111000000,对象 b 的hashCode为0111011100111000101000010100000,你会发现上述与运算结果都是0,这样的hash结果让人失望,不是一个好的哈希算法。

但如果将hashCode值右移16位(h >>> 16代表无符号右移16位),也就是取int类型的一半,刚好可以将该二进制数对半切开,并且使用位异或运算(如果两个数对应的位置相反,则结果为1,反之为0),这样的话,就能避免上面的情况发生。这就是hash()方法的具体实现方式。就是尽量打乱hashCode真正参与运算的低16位

(n-1) & hash的设计,这里的n代表哈希表的长度,哈希表习惯将长度设置为2的n次方,这样恰好可以保证(n-1) & hash的计算得到的索引值总是位于table数组的索引之内。例如:hash=15,n=16时,结果为15;hash=17,n=16时,结果为1。

在获得Node的存储位置后,如果判断Node不在哈希表中,就新增一个Node,并添加到哈希表中。

从图中可以看出

  • 如果创建HashMap时没有指定大小,是不会在构造函数中指定默认初始大小的,而是在第一次put添加元素时初始化。

  • 在JDK1.8中,HashMap引入了红黑树结构来提升链表的查询效率。

    • 这是因为链表的长度超过8后,红黑树的查询效率要比链表高,所以当链表超过8时,HashMap就会将链表转换为红黑树,这里值得注意的一点是,这时新增由于存在左旋、右旋效率会降低。这也就解决了上面提到过的,链表长度过长导致查询时间复杂度高的问题。

以下是put的实现源码:

 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)
//1、判断当 table 为 null 或者 tab 的长度为 0 时,即 table 尚未初始化,此时通过 resize() 方法得到初始化的 table
            n = (tab = resize()).length;
        if ((p = tab[i = (n - 1) & hash]) == null)
//1.1、此处通过(n - 1) & hash 计算出的值作为 tab 的下标 i,并另 p 表示 tab[i],也就是该链表第一个节点的位置。并判断 p 是否为 null
            tab[i] = newNode(hash, key, value, null);
//1.1.1、当 p 为 null 时,表明 tab[i] 上没有任何元素,那么接下来就 new 第一个 Node 节点,调用 newNode 方法返回新节点赋值给 tab[i]
        else {
//2.1 下面进入 p 不为 null 的情况,有三种情况:p 为链表节点;p 为红黑树节点;p 是链表节点但长度为临界长度 TREEIFY_THRESHOLD,再插入任何元素就要变成红黑树了。
            Node<K,V> e; K k;
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
//2.1.1HashMap 中判断 key 相同的条件是 key 的 hash 相同,并且符合 equals 方法。这里判断了 p.key 是否和插入的 key 相等,如果相等,则将 p 的引用赋给 e

                e = p;
            else if (p instanceof TreeNode)
//2.1.2 现在开始了第一种情况,p 是红黑树节点,那么肯定插入后仍然是红黑树节点,所以我们直接强制转型 p 后调用 TreeNode.putTreeVal 方法,返回的引用赋给 e
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
//2.1.3 接下里就是 p 为链表节点的情形,也就是上述说的另外两类情况:插入后还是链表 / 插入后转红黑树。另外,上行转型代码也说明了 TreeNode 是 Node 的一个子类
                for (int binCount = 0; ; ++binCount) {
// 我们需要一个计数器来计算当前链表的元素个数,并遍历链表,binCount 就是这个计数器

                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        if (binCount >= TREEIFY_THRESHOLD - 1) 
// 插入成功后,要判断是否需要转换为红黑树,因为插入后链表长度加 1,而 binCount 并不包含新节点,所以判断时要将临界阈值减 1
                            treeifyBin(tab, hash);
// 当新长度满足转换条件时,调用 treeifyBin 方法,将该链表转换为红黑树
                        break;
                    }
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }


HashMap获取元素优化

当HashMap中只存在数组,而数组中没有Node链表时,是HashMap查询数据性能最好的时候。一旦发生大量的哈希冲突,就会产生Node链表,这个时候每次查询元素都可能遍历Node链表,从而降低查询数据的性能。

特别是在链表长度过长的情况下,性能将明显降低,红黑树的使用很好解决了这个问题,使得查询的平均复杂度降低到了O(log(n)),链表越长,使用红黑树替换后的查询效率提升就越明显。

我们在编码中也可以优化HashMap的性能,例如,重写key值的hashCode()方法,降低哈希冲突,从而减少链表的产生,高效利用哈希表,达到提高性能的效果。

HashMap扩容优化

HashMap也是数组类型的数据结构,所以一样存在扩容的情况。

在JDK1.7中,HashMap整个扩容过程就是分别取出数组元素,一般该元素是最后一个放入链表中的元素,然后遍历以该元素为头的单向链表元素,依据每个被遍历元素的hash值计算其在新数组中的下标,然后进行交换。这样的扩容方式会将原来哈希冲突的单向链表尾部变成扩容后单向链表的头部。

而在JDK1.8中,HashMap对扩容操作做了优化。由于扩容数组的长度是2倍关系,所以对于假设初始tableSize = 4要扩容到8来说就是0100到1000的变化(左移一位就是2倍),在扩容中只用判断原来的hash值和左移动的一位(newtable的值)按位与操作是0或1就行,0的话索引不变,1的话索引变成原索引加上扩容前数组。

之所以能通过这种“与运算”来重新分配索引,是因为hash值本来就是随机的,而hash按位与上newTable得到的0(扩容前的索引位置)和1(扩容前索引位置加上扩容前数组长度的数值索引处)就是随机的,所以扩容的过程就能把之前哈希冲突的元素再随机分布到不同的索引中去。

问:实际应用中,设置初始容量,一般得是2的整数次幂,为什么?

2的幂次方减 1 ,让数组每一个位置都能添加到元素。例如十进制8,对应二进制1000,减1是0111,这样在&hash值使数组每个位置都是可以添加到元素的,如果有一个位置为0,也就是说数组下标为2的位置总是空的。

如果初始化大小设置的不是2的幂次方,HashMap也会调整到比初始化值大且最近的一个幂作为capacity。

就是为了减少哈希冲突,均匀分布元素。

总结

  • HashMap通过哈希表数据结构的形式来存储键值对,这种设计的好处就是查询键值对的效率高。
  • 在使用HashMap时,可以结合场景来设置初始容量和加载因子两个参数,当查询操作较为频繁时,可以适当减少加载因子;如果对内存利用率要求比较高,可以适当增加加载因子。
  • 在预知存储数据量的情况下,提前设置初始容量。初始容量 = 预知数据量 / 加载因子。这样做的好处可以减少resize()操作,提高HashMap效率。
  • HashMap使用了数组+链表这两种数据结构相结合的方式实现了链地址法,当有哈希冲突时,就可以将冲突的键值对链成一个链表。
    • 这种方式又存在链表过长,查询数据时间复杂度增加的问题。HashMap在Java8中使用了红黑树来解决链表过长导致的查询性能下降问题。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值