HashMap 实现原理分析

HashMap 又叫 Hash 表或散列表,是基于哈希表的 Map 接口实现。此实现提供了基于 Key-Value 映射结构数据的所有可选操作,如:增、删、改、查等。HashMap 并不保证映射顺序,特别是它不保证插入顺序恒久不变(后文会说明为什么)。

1 到底“哈希表”、“散列表”是个什么东西?

HashMap 本质上是“基于哈希表的 Map 接口实现”,Map 接口的实现好理解,但“哈希表”或者说“散列表”到底是个什么东西呢?

在回答这个问题之前,我们先讨论一下我们日常使用的数据容器。我们日常使用的数据容器无论多么精巧复杂,其实现大多是基于数组链表(或者说引用)两种基础数据结构,而对其选择一般是出于查找修改性能的综合考虑决定的。二者的基本特点如下:

  • 数组:存储的物理结构是连续的,所以对内存分配要求较高。其结构决定了其访问效率非常高,时间复杂度为 O(1)。这样的结构同时造成了对其进行插入和删除改动范围大的问题,影响了修改效率,时间复杂度为 O(n)。总结数组的特点:寻址容易,插入和删除困难
  • 链表:顾名思义,链表的存储是通过一条(或两条)引用链条串联起来的,所以数据可以离散存储,对存储空间要求比较低。但其结构同样造成了其访问效率比较低,时间复杂度为 O(n)。这样的结构的好处就是,很方便进行数据的插入和删除,数据的变更最多只会影响其相邻的两个数据节点,其时间复杂度仅仅为 O(1)。总结链表的特点:寻址困难,插入和删除容易

可以看出,数据结构的设计的主要考虑因素,除了数据本身的形式外,主要就是数据项查询和修改的效率。二者看起来似乎“鱼与熊掌”不可兼得,那么有没有一种数据结构能结合二者优点,既能提高查询效率又便于频繁修改呢?

这就是“哈希表”,准确的说是基于拉链法的“哈希表”解决的问题。

哈希表的思想实际是基于数组可以通过下标随机访问数据的特性实现的。本质上就是通过稳定的哈希算法,将不定长度的 Key 映射为数组下标的过程。将来访问 Key 的数据的时候,实际上是再次通过哈希算法计算出数组下标,并访问下标对应的数据项的过程。
哈希表

听起来似乎只是用到了数组的特性,并没有兼取链表修改效率高的特性,别急,我们先了解一下哈希函数。

2 关于哈希函数

通过哈希函数获得哈希值的过程大概主要做了以下几件事:

输入 -> 转码 -> 压缩 -> 输出

从上文的介绍我们大体可以归纳出来,实现一个哈希函数大概有三点要求:

  1. 哈希函数得到的哈希值是一个非负整数;
  2. 如果 Key1 = Key2,那么 hash(Key1) = hash(Key2);
  3. 如果 Key1 != Key2, 那么 hash(Key1) != hash(Key2)。

我们来解释一下,第一点可能比较好理解,数组的下标是从 0 开始的,第二点也能够理解,相同的 Key,得出的哈希值必须是一样的。

但第三点可能就有点问题了,这个要求看似可以,但在实际实现的时候,几乎是不可能的。对哈希函数的过程的介绍中,我们提到了压缩过程,这一过程会将哈希码压缩对齐到统一的数位,所以难以避免会产生哈希冲突,更何况用来存储的数组本身就是有大小的。

3 哈希冲突

既然冲突难以避免,那么如何处理哈希冲突就比较关键了。常见的哈希冲突处理有以下算法:

3.1 开放寻址法

开放寻址法的核心思想就是,如果出现了哈希冲突,就重新找一个空闲位置,将数据插入。那么如何去寻找这个新位置呢?

其中一种比较简单的方法是线性探测

当我们往哈希表中插入数据冲突时,就从当前冲突位置往后寻找,看是否有空闲位置,直到找到为止。

开放寻址法

查找的过程类似于插入的过程,从找到的第一个位置开始依次往后寻找,直到出现空闲位置,说明数据不再哈希表中。

开放寻址法数据查找:数据在哈希表中

开放寻址法数据查找:数据不在哈希表中

开放寻址法解决哈希冲突,除了线性探测之外,比较经典的还有二次探测双重哈希(或再哈希法),这里不多做介绍,感兴趣的可以去了解。

3.2 公共溢出区

将哈希表分为主表和溢出表两部分,所有溢出的数据均存入溢出表中。

3.3 拉链法(或链地址法)

拉链法是将每个桶作为链表的头部,所有哈希地址为 i 的元素共同构成一个同义词链表。每次添加数据都添加到对应桶链表的尾部,理论上就不存在哈希冲突了。HashMap 中使用的正式这种算法。

拉链法

4 HashMap 的实际构成

至此,我们可以回答我们前面哈希表是如何结合数组和链表优势的问题了。

哈希表通过数组存储对哈希值分桶后的数据,数组的每个节点存储的实际上是链表的头部,所有新数据均被追加到链表的尾部。

如此,如果分桶方式合理,数据能够被均匀分布到所有桶中,在数据量不是特别巨大的前提下,哈希表的查询效率会接近于数组的查询效率——O(1),其插入或删除效率,接近于链表的插入或删除效率——O(1),完美结合了数组和链表的优点。

所以,HashMap 的实际存储结构为:数组+链表

5 HashMap 使用性能优化

上一节分析哈希表查询和修改效率的时候,有两个前提,一个是分桶方式要合理,而是数据量不能太大。这其实就关系到 HashMap 的性能问题。

前文我们已经得出了结论,HashMap 是由数组+链表实现的,所以其扩充实际上是和数组一样成本巨大的,需要经历新建和拷贝的过程。而且,由于 HashMap 的数据都是通过哈希函数换算分桶的,因此当数容积扩充后,其拷贝过程需要经历重新哈希和分桶的过程,这也回答了文章开头说 HashMap 不能保证插入顺序不变的问题,因为重新哈希的数据分桶时并不一定都能放到同一个桶里,因此其顺序自然就不能保证了。

既然重建数组开销如此巨大,那么最理想的是我们能够预先知道 HashMap 存储的数据量,在实例化的时候,就通过初始容量参数 initialCapacity 设定其数组初始大小,在不设置初始容量的时候,HashMap 默认大小为 16。

到这里大家会不会有一个疑问,既然 HashMap 是通过数组+链表来存储数据的,那么怎么会有数据满了需要扩充的情况呢?这就涉及到与 HashMap 性能休憩相关的另一个参数——装载因子(loadFactor)。

我们假设一种极端情况,如果数组的大小为 1,那么 HashMap 就退化成了一个链表,其查询时间复杂度也退化到了与链表相同的 O(n)。这种情况其实放大了哈希冲突对性能造成的破坏,如果哈希冲突严重,众多数据被分配到了同一只桶里,那么这个桶的查询效率就会退化,更接近于链表的查询效率。为了防止这种情况出现,HashMap 做了两方面的事情:

  1. 其哈希函数需要保证生成的值要尽可能随机且平均分布,这样是为了最小化避免冲突;
  2. 构造函数提供了装载因子参数 loadFactor,默认为 0.75,意思为当数组容量为 n,已存储数据为 m,如果 n/m > 0.75,就会认定当前 HashMap 已满,需要进行重新扩容。

所以,在使用 HashMap 的时候:

  1. 在知道数据量的情况下,尽量指定 HashMap 的大小,避免其频繁扩容(使用 ArrayList 等基于数组的数据容器也是同理);
  2. 综合存储空间和查询、插入效率,调整装载因子,以达到更有利当前业务场景的性能。

顺着这个思路,如果数据量过于庞大,其实无论是对于扩容还是查询优化都难以做到更好的优化。因此在 Java8 中,当某个桶内的链表长度大于 8,就会将链表替换为红黑树。因为引入了树,所以其基本操作就比较复杂了,比如 put 函数以前只需要找到对应的桶,并将查找当前 Key 是否已经存在,如果存在就替换,如果不存在就直接加。但到了 Java8 以后,就需要判断当前桶中是链表还是树,如果是链表还需要判断插入数据之后需不需要转换为树。不过这些操作的时间复杂度都是常量级别的,所以插入时间复杂度还是 O(1)。

6 引用声明

《数据结构与算法之美》——作者:王争
《HashMap 实现原理》——作者:李大辉

©️2020 CSDN 皮肤主题: 大白 设计师:CSDN官方博客 返回首页