深刻理解HashMap(一): 从源头提及

前言

系列文章目录java

HashMap咱们都不陌生, 也是java面试几乎必问的考点, 本系列咱们来深刻思考有关HashMap的设计思想和实现细节.node

HashMap解决了什么问题?

任何数据结构的产生总对应着要解决一个实际的问题, HashMap的产生要解决问题就是:面试

如何有效的 一组 key-vaule 键值对

key-value键值对是最常使用的数据形式, 如何有效地存取他们是众多语言都须要关注的问题. 注意这里有四个关键字:算法

  1. key-value键值对
  2. 一组

下面咱们逐个来思考:编程

如何表示 key-value 键值对

在java这种面向对象的语言中, 表示一个数据结构天然要用到类, 因为对于键值对的数据类型事先并不清楚, 显而易见这里应该要用泛型, 则, 表示key-value键值对最简单的形式能够是:segmentfault

class Node<K,V> {
    K key;
    V value;
}

这里咱们自定义一个Node类, 它只有两个属性, 一个 key属性表示键, 一个value属性表示值, 则这个类就表明了一个 key-value键值对.数组

是否是很简单?网络

固然, 咱们还须要定义一些方法来操纵这两个属性, 例如get和set方法等,不过根据设计原则, 咱们应该面向接口编程, 因此应该定义一个接口来描述须要执行的操做, 这个接口就是Entry<K,V>, 它只不过是对于Node<K,V>这个类的抽象, 在java中, 这个接口定义在Map这个接口中, 因此上面的类能够改成:数据结构

class Node<K,V> implements Map.Entry<K,V>{
    K key;
    V value;
}

这里咱们总结一下:性能

咱们定义了一个Node类来表示一个键值对, 为了面向接口编程, 咱们抽象出一个 Entry接口, 并使Node类实现了这个接口.

至于这个接口须要定义哪些方法, 咱们暂不细表.

这样, 到目前为止, 咱们完成了对于 key-value 键值对的表示.

如何存储 key-value 键值对的集合

在常见的业务逻辑中, 咱们经常须要处理一组键值对的集合, 将一组键值对存储在一处, 并根据key值去查找对应的value.

那么咱们要如何存储这些键值对的集合呢?

其实换个问法可能更容易回答:

应该怎样存储一组对象?

(毕竟键值对已经被咱们表示为Node对象了)

在java中, 存储一个对象的集合无外乎两种方式:

  1. 数组
  2. 链表

关于数组和链表的优缺点你们已经耳熟能详了:

  • 数组大小有限, 查找性能好, 插入和删除性能差
  • 链表大小不限, 查找性能差, 插入和删除性能好

这里应该选哪一种形式呢? 那得看实际的应用了, 在使用键值对时, 查找和插入,删除等操做都会用到, 可是在实际的应用场景中, 对于键值对的查找操做居多, 因此咱们固然选择数组形式.

Node<K,V>[] table;

总结: 咱们选择数组形式来存储key-value对象.

为了便于下文描述, 咱们将数组的下标称为索引(index), 将数组中的一个存储位置称为数组的一个存储桶(bucket).

如何有效地根据key值查找value

前面已经讲到, 咱们选择数组形式来存储key-value对象, 以利用其优良的查找性能, 数组之因此查找迅速, 是由于能够根据索引(数组下标)直接定位到对应的存储桶(数组所存储对象的位置.)
可是实际应用中, 咱们都是经过key值来查找value值, 怎么办呢?

一种方式就是遍历数组中的每个对象, 查看它的key是否是咱们要找的key, 可是很明显, 这种方式效率低下(并且这不就是链表的顺序查找方式吗?) 彻底违背了咱们选择数组来存储键值对的初衷.

为了利用索引来查找, 咱们须要创建一个 key -> index 的映射关系, 这样每次咱们要查找一个 key时, 首先根据映射关系, 计算出对应的数组下标, 而后根据数组下标, 直接找到对应的key-value对象, 这样基本能以o(1)的时间复杂度获得结果.

这里, 将key映射成index的方法称为hash算法, 咱们但愿它能将 key均匀的分布到数组中.

这里插一句,使用Hash算法一样补足了数组插入和删除性能差的短板, 咱们知道, 数组之因此插入删除性能差是由于它是顺序存储的, 在一个位置插入节点或者删除节点须要一个个移动它的后续节点来腾出位或者覆盖位置.

使用hash算法后, 数组再也不按顺序存储, 插入删除操做只须要关注一个存储桶便可, 而不须要额外的操做.

如何解决hash冲突

这个问题实际上是由上一个问题引出的, 虽然咱们要求hash算法能将key均匀的分布到数组中, 可是它只能尽可能作到, 并非绝对的, 更况且咱们的数组大小是有限的, 保不齐咱们的hash算法将就两个不一样的key映射成了同一个index值, 这就产生了hash冲突, 也就是两个Node要存储在数组的同一个位置该怎么办?

解决hash冲突的方法有不少, 在HashMap中咱们选择链地址法, 即在产生冲突的存储桶中改成单链表存储.(拓展阅读: 解决哈希冲突的经常使用方法 )

其实, 最理想的效果是,Entry数组中每一个位置都只有一个元素,这样,查询的时候效率最高,不须要遍历单链表,也不须要经过equals去比较Key,并且空间利用率最大。

链地址法使咱们的数组转变成了链表的数组:

链地址法
(图片来自网络)

至此, 咱们对key-value键值对的表示变为:

class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    V value;
    Node<K,V> next;
    
    Node(int hash, K key, V value, Node<K,V> next) {
        this.hash = hash;
        this.key = key;
        this.value = value;
        this.next = next;
    }
    ...
}

链表长度过长怎么办

咱们知道, 链表查找只能经过顺序查找来实现, 所以, 时间复杂度为o(n), 若是很不巧, 咱们的key值被Hash算法映射到一个存储桶上, 将会致使存储桶上的链表长度愈来愈长, 此时, 数组查找退化成链表查找, 则时间复杂度由原来的o(1) 退化成 o(n).

为了解决这一问题, 在java8中, 当链表长度超过 8 以后, 将会自动将链表转换成红黑树, 以实现 o(log n) 的时间复杂度, 从而提高查找性能.

链表转变成红黑树

(图片来自网络)

何时扩容

前面已经说到, 数组的大小是有限的, 在新建的时候就要指定, 若是加入的节点已经到了数组容量的上限, 已经没有位置可以存储key-value键值对了, 此时就须要扩容.

可是很明显, 咱们不会等到迫不及待了才想起来要扩容, 在实际的应用中, 数组空间已使用3/4以后, 咱们就会括容.

为何是0.75呢, 官方文档的解释是:

the default load factor (.75) offers a good tradeoff between time and space costs.

想要更深刻的理解能够看这里.

再说回扩容, 有的同窗就要问了, 咱上面不是将数组的每个元素转变成链表了吗? 就算此时节点数超过了数组大小, 新加的节点会存在数组某一个位置的链表里啊, 链表的大小不限, 能够存储任意数量的节点啊!

没错, 理论上来讲这样确实是可行的, 但这又违背了咱们一开始使用数组来存储一组键值对的初衷, 还记得咱们选择数组的缘由是什么吗? 为了利用索引快速的查找!

若是咱们试图期望利用链表来扩容的话, 当一个存储桶的中的链表愈来愈大, 在这个链表上的查找性能就会不好(退化成顺序查找了)

为此, 在数组容量不足时, 为了继续维持利用数组索引查找的优良性能, 咱们必须对数组进行扩容.

链表存在的意义只是为了解决hash冲突, 而不是为了增大容量. 事实上, 咱们但愿链表的长度越短越好, 或者最好不要出现链表.

每次扩容扩多大

上一节咱们讨论了扩容的时机, 接下来的另外一问题就是每次多增长多少空间.

咱们知道, 数组的扩容是一个很耗费CPU资源的动做, 须要将原数组的内容复制到新数组中去, 所以频繁的扩容必然会致使性能下降, 因此不可能数组满了以后, 每多加一个node, 咱们就扩容一次.
可是, 一次扩容太大, 致使大量的存储空间用不完, 势必又形成很大的浪费, 所以, 必须根据实际状况设定一个合理的扩容大小.

在HashMap的实现中, 每次扩容咱们都会将新数组的大小设为原数组大小的两倍.

总结

关于HashMap的设计思路, 咱们能够用一句话来归纳:

不忘初心 !

咱们设计HashMap的初心是什么呢, 是找到一种方法, 能够存储一组键值对的集合, 并实现快速的查找.

==> 为了实现快速查找, 咱们选择了数组而不是链表. 以利用数组的索引实现o(1)复杂度的查找效率.

==> 为了利用索引查找, 咱们引入Hash算法, 将 key 映射成数组下标: key -> Index

==> 引入Hash算法又致使了Hash冲突

==> 为了解决Hash冲突, 咱们采用链地址法, 在冲突位置转为使用链表存储.

==> 链表存储过多的节点又致使了在链表上节点的查找性能的恶化

==> 为了优化查找性能, 咱们在链表长度超过8以后转而将链表转变成红黑树, 以将 o(n)复杂度的查找效率提高至o(log n)

可见, 每一次结构的调整, 都是始终围绕咱们的初心:

实现快速的查找

来进行的, 始终不忘这一点, 在每一次出现问题的时候, 一切的选择是否是看起来就很天然了?(≧∇≦)ノ

(完)

下一篇: 深刻理解HashMap(二): 关键源码逐行分析之hash算法

查看更多系列文章:系列文章目录

相关文章
相关标签/搜索