Hashmap 面试题 + Hashmap 原理 + Hashmap 源码(史上最全)

HashMap作为我们日常使用最频繁的容器之一,相信你一定不陌生了。今天我们就从HashMap的底层实现讲起,深度了解下它的设计与优化。

常用的数据结构

我在05讲分享List集合类的时候,讲过ArrayList是基于数组的数据结构实现的,LinkedList是基于链表的数据结构实现的,而我今天要讲的HashMap是基于哈希表的数据结构实现的。

我们不妨 一起来温习下常用的数据结构,这样也有助于你更好地理解后面地内容。

数组:采用一段连续的存储单元来存储数据。对于指定下标的查找,时间复杂度为O(1),但在数 组中间以及头部插入数据时,需要复制移动后面的元素。

链表:一种在物理存储单元上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。

链表由一系列结点(链表中每一个元素)组成,结点可以在运行时动态生成。每个结点都包含“存储数据单元的数据域”和“存储下一个结点地址的指针域”这两个部分。

由于链表不用必须按顺序存储,所以链表在插入的时候可以达到O(1)的复杂度,但查找一个结点或者访问特定编号的结点需要O(n)的时间。

哈希表:根据关键码值(Key value)直接进行访问的数据结构。通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做哈希函数,存放记录的数组就叫做哈希表。

:由n(n≥1)个有限结点组成的一个具有层次关系的集合,就像是一棵倒挂的树。

什么是哈希表

从根本上来说,一个哈希表包含一个数组,通过特殊的关键码(也就是key)来访问数组中的元素。

哈希表的主要思想是:

  • 存放Value的时候,通过一个哈希函数,通过 关键码(key)进行哈希运算得到哈希值,然后得到 映射的位置, 去寻找存放值的地方 ,
  • 读取Value的时候,也是通过同一个哈希函数,通过 关键码(key)进行哈希运算得到哈希值,然后得到 映射的位置,从那个位置去读取。

最直接的例子就是字典,例如下面的字典图,如果我们要找 “啊” 这个字,只要根据拼音 “a” 去查找拼音索引,查找 “a” 在字典中的位置 “啊”,这个过程就是哈希函数的作用,用公式来表达就是:f(key),而这样的函数所建立的表就是哈希表。

 

哈希表的优势:加快了查找的速度。

比起数组和链表查找元素时需要遍历整个集合的情况来说,哈希表明显方便和效率的多。

常见的哈希算法

哈希表的组成取决于哈希算法,也就是哈希函数的构成,下面列举几种常见的哈希算法。

1) 直接定址法

  • 取关键字或关键字的某个线性函数值为散列地址。
  • 即 f(key) = key 或 f(key) = a*key + b,其中a和b为常数。

2) 除留余数法

  • 取关键字被某个不大于散列表长度 m 的数 p 求余,得到的作为散列地址。
  • 即 f(key) = key % p, p < m。这是最为常见的一种哈希算法。

3) 数字分析法

  • 当关键字的位数大于地址的位数,对关键字的各位分布进行分析,选出分布均匀的任意几位作为散列地址。
  • 仅适用于所有关键字都已知的情况下,根据实际应用确定要选取的部分,尽量避免发生冲突。

4) 平方取中法

  • 先计算出关键字值的平方,然后取平方值中间几位作为散列地址。
  • 随机分布的关键字,得到的散列地址也是随机分布的。

5) 随机数法

  • 选择一个随机函数,把关键字的随机函数值作为它的哈希值。
  • 通常当关键字的长度不等时用这种方法。

什么是哈希冲突(hash碰撞)

哈希表因为其本身的结构使得查找对应的值变得方便快捷,但也带来了一些问题,

以上面的字典图为例,key中的一个拼音对应一个字,那如果字典中有两个字的拼音相同呢?

例如,我们要查找 “按” 这个字,根据字母拼音就会跳到 “安” 的位置,这就是典型的哈希冲突问题。

哈希冲突问题,用公式表达就是:

key1 ≠  key2  , f(key1) = f(key2)

一般来说,哈希冲突是无法避免的,

如果要完全避免的话,那么就只能一个字典对应一个值的地址,也就是一个字就有一个索引 ( 和 就是两个索引),

这样一来,空间就会增大,甚至内存溢出。

需要想尽办法,减少 哈希冲突(hash碰撞)为啥呢?Hash碰撞的概率就越小,map的存取效率就会越高

哈希冲突的解决办法

常见的哈希冲突解决办法有两种:

  • 开放地址法
  • 链地址法。

一、开放地址法

开发地址法的做法是,当冲突发生时,使用某种探测算法在散列表中寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到。

按照探测序列的方法,一般将开放地址法区分为线性探查法、二次探查法、双重散列法等。

这里为了更好的展示三种方法的效果,我们用以一个模为8的哈希表为例,采用除留余数法

往表中插入三个关键字分别为26,35,36的记录,分别除8取模后,在表中的位置如下:

这个时候插入42,那么正常应该在地址为2的位置里,但因为关键字30已经占据了位置,

所以就需要解决这个地址冲突的情况,接下来就介绍三种探测方法的原理,并展示效果图。

1) 线性探查法:

fi=(f(key)+i) % m ,0 ≤ i ≤ m-1

探查时从地址 d 开始,首先探查 T[d],然后依次探查 T[d+1],…,直到 T[m-1],此后又循环到 T[0],T[1],…,直到探查到有空余的地址或者到 T[d-1]为止。

插入42时,探查到地址2的位置已经被占据,接着下一个地址3,地址4,直到空位置的地址5,所以39应放入地址为5的位置。

缺点:需要不断处理冲突,无论是存入还是査找效率都会大大降低。

2) 二次探查法

fi=(f(key)+di) % m,0 ≤ i ≤ m-1

探查时从地址 d 开始,首先探查 T[d],然后依次探查 T[d+di],di 为增量序列12,-12,22,-22,……,q2,-q2 且q≤1/2 (m-1) ,直到探查到 有空余地址或者到 T[d-1]为止。

缺点:无法探查到整个散列空间。

所以插入42时,探查到地址2被占据,就会探查T[2+1^2]也就是地址3的位置,被占据后接着探查到地址7,然后插入。

 

3) 双哈希函数探测法

fi=(f(key)+i*g(key)) % m (i=1,2,……,m-1)

其中,f(key) 和 g(key) 是两个不同的哈希函数,m为哈希表的长度

步骤:

双哈希函数探测法,先用第一个函数 f(key) 对关键码计算哈希地址,一旦产生地址冲突,再用第二个函数 g(key) 确定移动的步长因子,最后通过步长因子序列由探测函数寻找空的哈希地址。

比如,f(key)=a 时产生地址冲突,就计算g(key)=b,则探测的地址序列为 f1=(a+b) mod m,f2=(a+2b) mod m,……,fm-1=(a+(m-1)b) % m,假设 b 为 3,那么关键字42应放在 “5” 的位置。

 

开发地址法的问题:

开发地址法,通过持续的探测,最终找到空的位置。

上面的例子中,开发地址方虽然解决了问题,但是26和42,占据了一个数组同一个元素,42只能向下,此时再来一个取余为2 的值呢,只能向下继续寻找,同理,每一个来的值都只能向下寻找。

为了解决这个问题,引入了链地址法。

二、链地址法:

在哈希表每一个单元中设置链表,某个数据项对的关键字还是像通常一样映射到哈希表的单元中,而数据项本身插入到单元的链表中。

链地址法简单理解如下:

 

来一个相同的数据,就将它插入到单元对应的链表中,在来一个相同的,继续给链表中插入。

链地址法解决哈希冲突的例子如下:

(1)采用除留余数法构造哈希函数,而 冲突解决的方法为 链地址法

(2)具体的关键字列表为(19,14,23,01,68,20,84,27,55,11,10,79),则哈希函数为H(key)=key MOD 13。则采用除留余数法和链地址法后得到的预想结果应该为:

 

(3)哈希造表完成后,进行查找时,首先是根据哈希函数找到关键字的位置链,然后在该链中进行搜索,如果存在和关键字值相同的值,则查找成功,否则若到链表尾部仍未找到,则该关键字不存在。

哈希表性能

哈希表的特性决定了其高效的性能,大多数情况下查找元素的时间复杂度可以达到O(1), 时间主要花在计算hash值上,

然而也有一些极端的情况,最坏的就是hash值全都映射在同一个地址上,这样哈希表就会退化成链表,例如下面的图片:

 

当hash表变成图2的情况时,查找元素的时间复杂度会变为O(n),效率瞬间低下,

所以,设计一个好的哈希表尤其重要,如HashMap在jdk1.8后引入的红黑树结构就很好的解决了这种情况。

HashMap的类结构

类继承关系

Java为数据结构中的映射定义了一个接口java.util.Map,此接口主要有四个常用的实现类,分别是HashMap、Hashtable、LinkedHashMap和TreeMap,

类继承关系如下图所示:

 

下面针对各个实现类的特点做一些说明:

(1) HashMap:

它根据键的hashCode值存储数据,大多数情况下可以直接定位到它的值,因而具有很快的访问速度,但遍历顺序却是不确定的。

HashMap 最多只允许一条记录的键为null,允许多条记录的值为null。

HashMap非线程安全,即任一时刻可以有多个线程同时写HashMap,可能会导致数据的不一致。

如果需要满足线程安全,可以用:

  • Collections的synchronizedMap方法使HashMap具有线程安全的能力,
  • 或者使用ConcurrentHashMap。

(2) Hashtable:

Hashtable是遗留类,很多映射的常用功能与HashMap类似,不同的是它承自Dictionary类,并且是线程安全的。

这个是老古董,Hashtable不建议在代码中使用

不需要线程安全的场合可以用HashMap替换,需要线程安全的场合可以用ConcurrentHashMap替换。

为何不建议用呢?

任一时间只有一个线程能写Hashtable,并发性不如ConcurrentHashMap。后者使用了 分段保护机制,也就是 分而治之的思想。

(3) LinkedHashMap:

LinkedHashMap是HashMap的一个子类,其优点在于: 保存了记录的插入顺序

在用Iterator遍历LinkedHashMap时,先得到的记录肯定是先插入的,也可以在构造时带参数,按照访问次序排序。

(4) TreeMap:

TreeMap实现SortedMap接口,能够把它保存的记录根据键排序,默认是按键值的升序排序,也可以指定排序的比较器

当用Iterator遍历TreeMap时,得到的记录是排过序的。

如果使用排序的映射,建议使用TreeMap。

在使用TreeMap时,key必须实现Comparable接口, 或者在构造TreeMap传入自定义的Comparator,

否则会在运行时抛出java.lang.ClassCastException类型的异常。

注意:

对于上述四种Map类型的类,要求映射中的key是不可变的。

在创建内部的Entry后, key的哈希值不会被改变。

为啥呢?

如果对象的哈希值发生变化,Map对象很可能就定位不到映射的位置了。

static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;  //key的哈希值不会被改变
        final K key; // 映射中的key是不可变的
        V value;
        Node<K,V> next;

HashMap存储结构

通过上面的比较,我们知道了HashMap是Java的Map家族中一个普通成员,鉴于它可以满足大多数场景的使用条件,所以是使用频度最高的一个。

下文我们主要结合源码,从存储结构、常用方法分析、扩容以及安全性等方面深入讲解HashMap的工作原理。

HashMap的重要属性:table 桶数组

从HashMap的源码中,我们可以发现,HashMap有一个非常重要的属性 —— table,

这是由一个Node类型的元素构成的数组:

transient Node<K,V>[] table;

table 也叫 哈希数组, 哈希槽位 数组 ,table 桶数组 , 散列表, 数组中的一个 元素,常常被称之为 一个 槽位 slot

Node类作为HashMap中的一个内部类,每个 Node 包含了一个 key-value 键值对。

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

    public final int hashCode() {
 	   return Objects.hashCode(key) ^ Objects.hashCode(value);
    }
    ..........
}

Node 类作为 HashMap 中的一个内部类,除了 key、value 两个属性外,还定义了一个next 指针。

next 指针的作用:链地址法解决哈希冲突。

当有哈希冲突时,HashMap 会用之前数组当中相同哈希值对应存储的 Node 对象,通过指针指向新增的相同哈希值的 Node 对象的引用。

JDK1.8的table结构图

从结构实现来讲,HashMap是数组+链表+红黑树(JDK1.8增加了红黑树部分)实现的,如下如所示。

 

问题:

HashMap的有什么特点呢?

HashMap的有什么特点

(1)HashMap采用了链地址法解决冲突

HashMap就是使用哈希表来存储的。

Node是HashMap的一个内部类,实现了Map.Entry接口,本质是就是一个映射(键值对)。

上图中的每个黑色圆点就是一个Node对象。

Java中HashMap采用了链地址法。链地址法,简单来说,就是 数组加链表 的结合。

在每个数组元素上都一个链表结构, 当数据被Hash后,首先得到数组下标,然后 , 把数据放在对应下标元素的链表上。

例如程序执行下面代码:

map.put("keyA","value1");
map.put("keyB","value2");

对于 第一句, 系统将调用"keyA"的hashCode()方法得到其hashCode ,然后再通过Hash算法来定位该键值对的存储位置,然后将 构造 entry 后加入到 存储位置 指向 的 链表中

对于 第一句, 系统将调用"keyB"的hashCode()方法得到其hashCode ,然后再通过Hash算法来定位该键值对的存储位置,然后将 构造 entry 后加入到 存储位置 指向 的链表中

有时两个key会定位到相同的位置,表示发生了Hash碰撞。

Hash算法计算结果越分散均匀,Hash碰撞的概率就越小,map的存取效率就会越高。

(2)HashMap有较好的Hash算法和扩容机制

哈希桶数组的大小, 在空间成本和时间成本之间权衡,时间和空间 之间进行 权衡:

  • 如果哈希桶数组很大,即使较差的Hash算法也会比较分散, 空间换时间
  • 如果哈希桶数组数组很小,即使好的Hash算法也会出现较多碰撞, 时间换空间

所以, 就需要在空间成本和时间成本之间权衡,

其实就是在根据实际情况确定哈希桶数组的大小,并在此基础上设计好的hash算法减少Hash碰撞。

那么通过什么方式来控制map使得Hash碰撞的概率又小,哈希桶数组(Node[] table)占用空间又少呢?

答案就是好的Hash算法和扩容机制。

HashMap的重要属性:加载因子(loadFactor)和边界值(threshold)

HashMap还有两个重要的属性:

  • 加载因子(loadFactor)
  • 边界值(threshold)。

在初始化 HashMap时,就会涉及到这两个关键初始化参数。

loadFactor和threshol

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

倾听铃的声

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值