面试再也不怕被问 HashMap 了

一.HashMap基本知识

1.哈希冲突的定义
如果两个不同的元素,通过哈希函数得出的实际存储地址相同怎么办?也就是说,当我们对某个元素进行哈希运算,得到一个存储地址后,然后要进行插入的时候,发现该位置已经被其他元素占用了,其实这就是所谓的哈希冲突,也叫哈希碰撞。由此可见哈希函数的设计至关重要,好的哈希函数会尽可能地保证 计算简单和散列地址分布均匀,但是我们需要清楚的是,数组是一块连续的固定长度的内存空间,再好的哈希函数也不能保证得到的存储地址绝对不发生冲突。那么哈希冲突如何解决呢?哈希冲突的解决方案有多种:开放定址法(当发生冲突时,继续寻找下一块未被占用的存储地址,然后再放入元素即可),再散列函数法,链地址法,而 HashMap 即是采用了链地址法,也就是数组+链表的方式。

2.hashmap的实现原理
HashMap 的主干是一个 Entry 数组。Entry 是 HashMap 的基本组成单元,每一个 Entry 包含一个 key-value 键值对。
HashMap的总体结构如下:
在这里插入图片描述
简单来说,HashMap由数组+链表组成的,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的,如果定位到的数组位置不含链表(当前entry的next指向null),那么查找,添加等操作很快,仅需一次寻址即可;如果定位到的数组包含链表,对于添加操作,其时间复杂度为O(n),首先遍历链表,存在即覆盖,否则新增;对于查找操作来讲,仍需遍历链表,然后通过key对象的equals方法逐一比对查找。所以,性能考虑,HashMap中的链表出现越少,性能才会越好。

3.HashMap有4个构造器,其他构造器如果用户没有传入initialCapacity 和loadFactor这两个参数,会使用默认值:initialCapacity默认为16,loadFactory默认为0.75

二.具体问题如下

1.HashMap的内部数据结构

数组 + 链表/红黑树

2.HashMap允许空键空值么

HashMap最多只允许一个键为Null(多条会覆盖),但允许多个值为Null

3.影响HashMap性能的重要参数

  • 初始容量:创建哈希表时桶的数量(数组的大小),默认为 16
  • 负载因子:哈希表在其容量扩容之前可以达到一种尺度,默认为 0.75

4.HashMap的工作原理

HashMap是基于hashing的原理,我们使用put(key, value)存储对象到HashMap中,使用get(key)从HashMap中获取对象

5.HashMap中put()的工作原理

在这里插入图片描述

6.HashMap 的底层数组长度为何总是2的n次方

  • 使数据分布均匀,减少碰撞
  • 当length为2的n次方时,hash&(length - 1) 就相当于对length取模,而且在速度效率上比直接取模要快得多

具体解释:
这里我觉得可以用逆向思维来解释这个问题,我们计算桶的位置完全可以使用h % length,如果这个length是随便设定值的话当然也可以,但是如果你对它进行研究,设计一个合理的值得话,那么将对HashMap的性能发生翻天覆地的变化。

没错,JDK源码作者就发现了,那就是当length为2的N次方的时候,那么,为什么这么说呢?

第一:当length为2的N次方的时候,h & (length-1) = h % length
为什么&效率更高呢?因为位运算直接对内存数据进行操作,不需要转成十进制,所以位运算要比取模运算的效率更高

第二:当length为2的N次方的时候,数据分布均匀,减少冲突
此时我们基于第一个原因进行分析,此时hash策略为h & (length-1)。

我们来举例当length为奇数、偶数时的情况:
在这里插入图片描述
在这里插入图片描述
从上面的图表中我们可以看到,当 length 为15时总共发生了8次碰撞,同时发现空间浪费非常大,因为在 1、3、5、7、9、11、13、15 这八处没有存放数据。

这是因为hash值在与14(即 1110)进行&运算时,得到的结果最后一位永远都是0,那么最后一位为1的位置即 0001、0011、0101、0111、1001、1011、1101、1111位置处是不可能存储数据的。这样,空间的减少会导致碰撞机率的进一步增加,从而就会导致查询速度慢。

而当length为16时,length – 1 = 15, 即 1111,那么,在进行低位&运算时,值总是与原来hash值相同,而进行高位运算时,其值等于其低位值。所以,当 length=2^n 时,不同的hash值发生碰撞的概率比较小,这样就会使得数据在table数组中分布较均匀,查询速度也较快。

如果上面这句话大家还看不明白的话,可以多试一些数,就可以发现规律。当length为奇数时,length-1为偶数,而偶数二进制的最后一位永远为0,那么与其进行 & 运算,得到的二进制数最后一位永远为0,那么结果一定是偶数,那么就会导致下标为奇数的桶永远不会放置数据,这就不符合我们均匀放置,减少冲突的要求了。

那么为什么默认是16呢?怎么不是4?不是8?

关于这个默认容量的选择,JDK并没有给出官方解释,那么这应该就是个经验值,既然一定要设置一个默认的2^n 作为初始值,那么就需要在效率和内存使用上做一个权衡。这个值既不能太小,也不能太大。太小了就有可能频繁发生扩容,影响效率。太大了又浪费空间,不划算。所以,16就作为一个经验值被采用了。

7. 1.8中做了哪些优化优化

  • 数组+链表 改成了 数组+链表或红黑树
  • 链表的插入方式从头插法改成了尾插法
  • 扩容的时候1.7需要对原数组中的元素进行重新hash,定位在新数组中的位置,1.8采用更简单的判断逻辑,位置不变或索引+旧容量大小
  • 在插入时,1.7先判断是否需要扩容,再插入,1.8先进行插入,插入完成再判断是否需要扩容;

8.HashMap线程安全方面会出现什么问题

  • 在jdk1.7中,在多线程环境下,扩容时会造成环形链或数据丢失。
  • 在jdk1.8中,在多线程环境下,会发生数据覆盖的情况

9.HashMap线程安全方面会出现什么问题

(1)put的时候导致的多线程数据不一致,产生错误情况

比如有两个线程 A和 B,首先 A希望插入一个 key-valul 对到 HashMap 中,首先计算记录所要落到的 hash 桶的索引座标,然后获取到该桶里面的链表头结点,而此时刚好线程 A 的时间片用完了,此时线程 B 被调度得以执行,和线程 A一样的执行过程 ,只不过线程 B 成功的将记录插到了桶里面(假设线程 A 插入的记录计算出来的 hash 桶索引和线程 B 要插入的记录计算出来的 hash 桶索引是一样的)那么当线程 B 成功插入之后, 线程 A 再次被调度运行时,它依然持有过期的链表头但是它对此一无所知(头插法),以至于它认为它应该继续采用头插法插入数据(它拿到的头节点刚好是刚刚线程B插入的节点的下一个节点),如此一来就覆盖了线程 B 插入的记录,这样线程 B 插入的记录就凭空消失了,造成了数据不一致的行为。

(2)resize引起死循环

这种情况发生在 HashMap 自动扩容时,当 2 个线程同时检测到元素个数超过 数组大小 ×负载因子 的时候。此时 2 个线程会在 put() 方法中调用resize() ,那么此时若两个线程同时修改一个链表结构会产生一个循环链表,接下来再想通过get()获取某一个元素,就会出现死循环。

10.为什么1.8改用红黑树

比如某些人恶意找到你的hash碰撞值,来让你的HashMap不断地产生碰撞,那么相同 index 位置的链表就会不断增长,当你需要对这个HashMap的相应位置进行查询的时候,就会去循环遍历这个超级大的链表,性能十分不好。java8 使用红黑树来替代超过 8个节点数 的链表后,查询方式性能得到了很好的提升,从原来的是 O(n) 到 O(logn)。

原文来自:https://potato.blog.csdn.net/article/details/106835525 感觉总结的十分好,学到了不少东西。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章