• 首页 首页 icon
  • 工具库 工具库 icon
    • IP查询 IP查询 icon
  • 内容库 内容库 icon
    • 快讯库 快讯库 icon
    • 精品库 精品库 icon
    • 问答库 问答库 icon
  • 更多 更多 icon
    • 服务条款 服务条款 icon

Java面试HashMap面试题

武飞扬头像
ZhangBlossom
帮助1


如需获取更多的面试题,可以通过给我的Github项目点赞来免费的向我获取
Github项目地址

说一下 HashMap 的实现原理?

HashMap概述: HashMap是基于哈希表的Map接口的非同步实现(线程不安全)。此实现提供所有可选的映射操作,并允许使用null值和null键。此类不保证映射的顺序,特别是它不保证该顺序恒久不变。
HashMap的数据结构: 在Java编程语言中, 基本的结构就是两种,一个是数组,另外一个是模拟指针(引用),所有的数据结构都可以用这两个基本结构来构造的,HashMap也不例外。HashMap实际上是一个“链表散列”的数据结构,即数组和链表的结合体。
HashMap 是基于 Hash 算法实现的:

  1. 当我们往Hashmap中put元素时,利用key的hashCode重新hash计算出当前对象的元素在数组中
    的下标
  2. 存储时,如果出现hash值相同的key,此时有两种情况。(1)如果key相
    同,则覆盖原始值;(2)如果key不同(出现冲突),则将当前的key-value 放入链表中
  3. 获取时,直接找到hash值对应的下标,在进一步判断key是否相同,从而找到对应值。
  4. 理解了以上过程就不难明白HashMap是如何解决hash冲突的问题,核心就是使用了数组的存储方
    式,然后将冲突的key的对象放入链表中,一旦发现冲突就在链表中做进一步的对比。
    需要注意Jdk 1.8中对HashMap的实现做了优化,当(某一)链表中的节点数据超过八个
    之后,该链表会转为红黑树来提高查询效率,从原来的O(n)到O(logn)

    学新通

HashMap在JDK1.7和JDK1.8中有哪些不同?HashMap的底层实现?

在Java中,保存数据有两种比较简单的数据结构:数组和链表。
数组的特点是:寻址容易,插入和删除困难;
链表的特点是:寻址困难,但插入和删除容易;
所以我们将数组和链表结合在一起,发挥两者各自的优势,使用一种叫做拉链法的方式可以解决哈希冲突。(就是用链表去存储冲突元素)
JDK1.8之前
JDK1.8之前采用的是拉链法。拉链法:将链表和数组相结合。也就是说创建一个链表数组,数组中每一格就是一个链表。若遇到哈希冲突,则将冲突的值加到链表中即可。
JDK1.8之后
相比于之前的版本,jdk1.8在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8)时,将链表转化为红黑树(自平衡排序二叉树),以减少搜索时间。
JDK1.7 VS JDK1.8 比较
JDK1.8主要解决或优化了一下问题:

  1. resize 扩容优化
  2. 引入了红黑树,目的是避免单条链表过长而影响查询效率
  3. 解决了多线程死循环问题,但仍是非线程安全的,多线程时可能会造成数据丢失问题

学新通

HashMap的put方法的具体流程?

这里先将JDK1.8中的put方法,然后在讲JDK1.7中的put方法。
Hash计算可以看这篇
当我们put的时候,首先计算 key的hash值,这里调用了 hash方法,hash方法实际是key.hashCode()与key.hashCode()>>>16进行异或操作,高16bit补0,一个数和0异或不变,所以 hash 函数大概的作用就是:高16bit不变,低16bit和高16bit做了一个异或,目的是减少碰撞。按照函数注释,因为bucket数组大小是2的幂,计算下标index = (table.length - 1) & hash,如果不做 hash 处理,相当于散列生效的只有几个低 bit 位,为了减少散列的碰撞,设计者综合考虑了速度、作用、质量之后,使用高16bit和低16bit异或来简单处理减少碰撞,而且JDK8中用了复杂度 O(logn)的树结构来提升碰撞下的性能。
下面是put方法的执行流程图(就这一个手绘,不得给我点个赞?)
其实就是插入数据的时候需要判断key是否存在,以及判断冲突时是放入到TreeNode(树型节点)还是List(链表)中,如果插入节点后List长度大于8,那么就将List结构换为Tree结构。并且如果插入数据后超过了设定的resize阈值(threshold),那么就进行Resize操作扩容。
学新通

1 public V put(K key, V value) {
2 	return putVal(hash(key), key, value, false, true);
3 }
4
5 static final int hash(Object key) {
6 	int h;
7 	return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);//二次扰动
8 }
9
10 //实现Map.put和相关方法
11 final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
12 boolean evict) {
13 Node<K,V>[] tab; Node<K,V> p; int n, i;
14 // 步骤①:tab为空则创建
15 // table未初始化或者长度为0,进行扩容
16 if ((tab = table) == null || (n = tab.length) == 0)
17 n = (tab = resize()).length;
18 // 步骤②:计算index,并对null做处理
19 // (n - 1) & hash 确定元素存放在哪个桶中,桶为空,
//新生成结点放入桶中(此时,这个结点是放在数组中)
20 if ((p = tab[i = (n - 1) & hash]) == null)
21 tab[i] = newNode(hash, key, value, null);
22 // 桶中已经存在元素
23 else {
24 Node<K,V> e; K k;
25 // 步骤③:节点key存在,直接覆盖value
26 // 比较桶中第一个元素(数组中的结点)的hash值相等,key相等
27 if (p.hash == hash &&
28 ((k = p.key) == key || (key != null && key.equals(k))))
29 // 将第一个元素赋值给e,用e来记录
30 e = p;
31 // 步骤④:判断该链为红黑树
32 // hash值不相等,即key不相等;为红黑树结点
33 // 如果当前元素类型为TreeNode,表示为红黑树,putTreeVal返回待存放的node, e可能为null
34 else if (p instanceof TreeNode)
35 // 放入树中
36 e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
37 // 步骤⑤:该链为链表
38 // 为链表结点
39 else {
40 // 在链表最末插入结点
41 for (int binCount = 0; ;   binCount) {
42 // 到达链表的尾部
43
44 //判断该链表尾部指针是不是空的
45 if ((e = p.next) == null) {
46 // 在尾部插入新结点
47 p.next = newNode(hash, key, value, null);
48 //判断链表的长度是否达到转化红黑树的临界值,临界值为8
49 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
50 //链表结构转树形结构
51 treeifyBin(tab, hash);
52 // 跳出循环
53 break;
54 }
55 // 判断链表中结点的key值与插入的元素的key值是否相等
56 if (e.hash == hash &&
57 ((k = e.key) == key || (key != null && key.equals(k))))
58 // 相等,跳出循环
59 break;
60 // 用于遍历桶中的链表,与前面的e = p.next组合,可以遍历链表
61 p = e;
62 }
63 }
64 //判断当前的key已经存在的情况下,再来一个相同的hash值、key值时,返回新来的value这个值
65 if (e != null) {
66 // 记录e的value
67 V oldValue = e.value;
68 // onlyIfAbsent为false或者旧值为null
69 if (!onlyIfAbsent || oldValue == null)
70 //用新值替换旧值
71 e.value = value;
72 // 访问后回调
73 afterNodeAccess(e);
74 // 返回旧值
75 return oldValue;
76 }
77 }
78 // 结构性修改
79   modCount;
80 // 步骤⑥:超过最大容量就扩容
81 // 实际大小大于阈值则扩容
82 if (  size > threshold)
83 resize();
84 // 插入后回调
85 afterNodeInsertion(evict);
86 return null;
87 }

①.判断键值对数组table[i]是否为空或为null,否则执行resize()进行扩容(HashMap是惰性创建数组的,首次使用才创建数组);
②.根据键值key计算hash值得到插入的数组索引i,如果table[i]==null,直接新建节点添加,转向⑥,如果table[i]不为空,转向③;
③.判断table[i]的首个元素是否和key一样,如果相同直接覆盖value,否则转向④,这里的相同指的是hashCode以及equals;
④.判断table[i] 是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对,否则转向⑤;
⑤.遍历table[i],判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;遍历过程中若发现key已经存在直接覆盖value即可;
⑥.插入成功后,判断实际存在的键值对数量size是否超多了 大容量threshold,如果超过,进行扩容,然后再将旧元素迁移到新数组中

这里补充的是,在JDK1.8中,如果插入的是链表,那么使用的是尾插法,而JDK1.7中使用的是头插法,在多线程情况下会导致HashMap死循环。

JDK1.7中是大于等于阈值且没有空位时才扩容,也就是新插入的数据如果插入到的是一个有空位的位置,那么即使节点个数已经超过阈值了,依旧不会扩容,只有再次出现Hash冲突的时候且满足阈值,那么才会扩容,因此比较节省空间。

而JDK1.8是大于阈值就会扩容,并且JDK1.8在扩容计算Node索引的时候会优化(下文说的二次扰动)

总结:
在使用put方法进行数据的插入的时候,如果数组此时还没有初始化,那么先进行数组的扩容,然后得到当前key的hash值,并计算出对应的索引位置,如果要插入的位置已经有数据了,那么就通过hashcode以及equals方法判断是覆盖还是插入,如果相同,那么就进行覆盖,不同就判断此时是树结构还是链表结构,如果是树结构就插入到树中,否则根据JDK版本进行头插或者尾插到链表中。插入成功后,判断此时数组中数据个数是否超过阈值,如果超过,那么进行扩容后在进行数据的迁移,否则不扩容并返回插入的数据。

HashMap的扩容操作是怎么实现的?

①.在jdk1.8中,resize方法是在hashmap中的键值对大于阀值时或者初始化时,就调用resize方法进行
扩容;
②.每次扩展的时候,都是扩展2倍;
③.扩展后Node对象的位置要么在原位置,要么移动到原偏移量两倍的位置(原位置 旧容量)。在putVal()中,我们看到在这个函数里面使用到了2次resize()方法,resize()方法表示的在进行第一次初始化时会对其进行扩容,或者当该数组的实际大小大于其临界值值(第一次为12),这个时候在扩容的同时也会伴随的桶上面的元素进行重新分发,这也是JDK1.8版本的一个优化的地方,在1.7中,扩容之后需要重新去计算其Hash值,根据Hash值对其进行分发,但在1.8版本中,则是根据在同一个桶的位置中进行判断(e.hash & oldCapacity)是否为0,重新进行hash分配后,该元素的位置要么停留在原始位置,要么移动到原始位置 增加的数组大小(旧容量)这个位置上。

1 final Node<K,V>[] resize() {
2 Node<K,V>[] oldTab = table;//oldTab指向hash桶数组
3 int oldCap = (oldTab == null) ? 0 : oldTab.length;
4 int oldThr = threshold;
5 int newCap, newThr = 0;
6 if (oldCap > 0) {//如果oldCap不为空的话,就是hash桶数组不为空
7 if (oldCap >= MAXIMUM_CAPACITY) {//如果大于最大容量了,就赋值为整数最大的阀
值
8 threshold = Integer.MAX_VALUE;
9 return oldTab;//返回
10 }//如果当前hash桶数组的长度在扩容后仍然小于最大容量 并且oldCap大于默认值16
11 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
12 oldCap >= DEFAULT_INITIAL_CAPACITY)
13 newThr = oldThr << 1; // double threshold 双倍扩容阀值threshold
14 }
15 // 旧的容量为0,但threshold大于零,代表有参构造有cap传入,threshold已经被初
始化成最小2的n次幂
16 // 直接将该值赋给新的容量
17 else if (oldThr > 0) // initial capacity was placed in threshold
18 newCap = oldThr;
19 // 无参构造创建的map,给出默认容量和threshold 16, 16*0.75
20 else { // zero initial threshold signifies using defaults
21 newCap = DEFAULT_INITIAL_CAPACITY;
22 newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
23 }
24 // 新的threshold = 新的cap * 0.75
25 if (newThr == 0) {
26 float ft = (float)newCap * loadFactor;
27 newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
28 (int)ft : Integer.MAX_VALUE);
29 }
30 threshold = newThr;
31 // 计算出新的数组长度后赋给当前成员变量table
32 @SuppressWarnings({"rawtypes","unchecked"})
33 Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];//新建hash桶数组
34 table = newTab;//将新数组的值复制给旧的hash桶数组
35 // 如果原先的数组没有初始化,那么resize的初始化工作到此结束,否则进入扩容元素
重排逻辑,使其均匀的分散
36 if (oldTab != null) {
37 // 遍历新数组的所有桶下标
38 for (int j = 0; j < oldCap;   j) {
39 Node<K,V> e;
40 if ((e = oldTab[j]) != null) {
41 // 旧数组的桶下标赋给临时变量e,并且解除旧数组中的引用,否则就数组无法被GC回收
42 oldTab[j] = null;
43 // 如果e.next==null,代表桶中就一个元素,不存在链表或者红黑树
44 if (e.next == null)
45 // 用同样的hash映射算法把该元素加入新的数组
46 newTab[e.hash & (newCap - 1)] = e;
47 // 如果e是TreeNode并且e.next!=null,那么处理树中元素的重排
48 else if (e instanceof TreeNode)
49 ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
50 // e是链表的头并且e.next!=null,那么处理链表中元素重排
51 else { // preserve order
52 // loHead,loTail 代表扩容后不用变换下标,见注1
53 Node<K,V> loHead = null, loTail = null;
54 // hiHead,hiTail 代表扩容后变换下标,见注1
55 Node<K,V> hiHead = null, hiTail = null;
56 Node<K,V> next;
57 // 遍历链表
58 do {
59 next = e.next;
60 if ((e.hash & oldCap) == 0) {
61 if (loTail == null)
62 // 初始化head指向链表当前元素e,e不一定是链表的第一个元素,初始化后loHead
63 // 代表下标保持不变的链表的头元素
64 loHead = e;
65 else
66 // loTail.next指向当前e
67 loTail.next = e;
68 // loTail指向当前的元素e
69 // 初始化后,loTail和loHead指向相同的内存,所以当loTail.next指向下一个元素
时,
70 // 底层数组中的元素的next引用也相应发生变化,造成lowHead.next.next.....
71 // 跟随loTail同步,使得lowHead可以链接到所有属于该链表的元素。
72 loTail = e;
73 }
74 else {
75 if (hiTail == null)
76 // 初始化head指向链表当前元素e, 初始化后hiHead代表下标更改的链表头元素
77 hiHead = e;
78 else
79 hiTail.next = e;
80 hiTail = e;
81 }
HashMap是怎么解决哈希冲突的?
答:在解决这个问题之前,我们首先需要知道什么是哈希冲突,而在了解哈希冲突之前我们还要知道什
么是哈希才行;什么是哈希?
Hash,一般翻译为“散列”,也有直接音译为“哈希”的,这就是把任意长度的输入通过散列算法,变换成
固定长度的输出,该输出就是散列值(哈希值);这种转换是一种压缩映射,也就是,散列值的空间通
常远小于输入的空间,不同的输入可能会散列成相同的输出,所以不可能从散列值来唯一的确定输入
值。简单的说就是一种将任意长度的消息压缩到某一固定长度的消息摘要的函数。
所有散列函数都有如下一个基本特性:根据同一散列函数计算出的散列值如果不同,那么输入值肯定也
不同。但是,根据同一散列函数计算出的散列值如果相同,输入值不一定相同。
什么是哈希冲突?
当两个不同的输入值,根据同一散列函数计算出相同的散列值的现象,我们就把它叫做碰撞(哈希碰
撞)。
HashMap的数据结构
在Java中,保存数据有两种比较简单的数据结构:数组和链表。数组的特点是:寻址容易,插入和删除
困难;链表的特点是:寻址困难,但插入和删除容易;所以我们将数组和链表结合在一起,发挥两者各
自的优势,使用一种叫做链地址法的方式可以解决哈希冲突:
82 } while ((e = next) != null);
83 // 遍历结束, 将tail指向null,并把链表头放入新数组的相应下标,形成新的映射。
84 if (loTail != null) {
85 loTail.next = null;
86 newTab[j] = loHead;
87 }
88 if (hiTail != null) {
89 hiTail.next = null;
90 newTab[j   oldCap] = hiHead;
91 }
92 }
93 }
94 }
95 }
96 return newTab;
97 }

HashMap是怎么解决哈希冲突的?

在解决这个问题之前,我们首先需要知道什么是哈希冲突,而在了解哈希冲突之前我们还要知道什么是哈希才行;什么是哈希?
Hash,一般翻译为“散列”,也有直接音译为“哈希”的,这就是把任意长度的输入通过散列算法,变换成
固定长度的输出,该输出就是散列值(哈希值);这种转换是一种压缩映射,也就是,散列值的空间通常远小于输入的空间,不同的输入可能会散列成相同的输出,所以不可能从散列值来唯一的确定输入值。简单的说就是一种将任意长度的消息压缩到某一固定长度的消息摘要(Message Digest , MD5算法就是一种消息摘要函数)的函数。
所有散列函数都有如下一个基本特性:根据同一散列函数计算出的散列值如果不同,那么输入值肯定也不同。但是,根据同一散列函数计算出的散列值如果相同,输入值不一定相同。
什么是哈希冲突?
当两个不同的输入值,根据同一散列函数计算出相同的散列值的现象,我们就把它叫做碰撞(哈希碰
撞)。
HashMap的数据结构
在Java中,保存数据有两种比较简单的数据结构:数组和链表。数组的特点是:寻址容易,插入和删除困难;链表的特点是:寻址困难,但插入和删除容易;所以我们将数组和链表结合在一起,发挥两者各自的优势,使用一种叫做链地址法的方式可以解决哈希冲突。

这样我们就可以将拥有相同哈希值的对象组织成一个链表放在hash值所对应的 bucket下,但相比
于hashCode返回的int(32bit)类型,我们HashMap初始的容量大小DEFAULT_INITIAL_CAPACITY = 1 << 4(即2的四次方16)要远小于int类型的范围,所以我们如果只是单纯的用hashCode取余来获取对应的bucket这将会大大增加哈希碰撞的概率,并且最坏情况下还会将HashMap变成一个单链表,所以我们还需要对hashCode作一定的优化。
上面提到的问题,主要是因为如果使用hashCode取余,那么相当于参与运算的只有hashCode的低位(原因是因为取余的大小是1<<4(16),那么其实在1<<5(32)以上的位置,他们对1<<4取余都等于0,也就是不会影响余数),高位是没有起到任何作用的,所以我们的思路就是让 hashCode取值出的高位也参与运算,进一步降低hash碰撞的概率,使得数据分布更平均,我们把这样的操作称为扰动,在JDK 1.8中的hash()函数如下:
学新通
这比在JDK 1.7中,更为简洁,相比在1.7中的4次位运算,5次异或运算(9次扰动),在1.8中,只进行了1次位运算和1次异或运算(2次扰动);
扰动计算图解

JDK1.8新增红黑树
通过上面的链地址法(使用散列表)和扰(img)动函数我们成功让我们的数据分布更平均,哈希碰撞减
少,但是当我们的HashMap中存在大量数据时,加入我们某个 bucket下对应的链表有n个元素,那么遍历时间复杂度就为O(n),为了针对这个问题,JDK1.8在HashMap中新增了红黑树的数据结构,进一步使得遍历复杂度降低至O(logn);
学新通
简单总结一下HashMap是使用了哪些方法来有效解决哈希冲突的:

  1. 使用链地址法(使用散列表)来链接拥有相同hash值的数据;
  2. 使用2次扰动函数(hash函数)来降低哈希冲突的概率,使得数据分布更平均;
  3. 引入红黑树进一步降低遍历的时间复杂度,使得遍历更快;

能否使用任何类作为 Map 的 key?

可以使用任何类作为 Map 的 key,然而在使用之前,需要考虑以下几点: 如果类重写了 equals() 方法,也应该重写 hashCode() 方法。类的所有实例需要遵循与 equals() 和 hashCode() 相关的规则。
如果一个类没有使用 equals(),不应该在 hashCode() 中使用它。
用户自定义 Key 类的最佳实践是使之为不可变的,这样 hashCode() 值可以被缓存起来,拥有更好的性能。不可变的类也可以确保 hashCode() 和 equals() 在未来不会改变,这样就会解决与可变相关的问题了。这也就是为什么上面推荐使用String,Integer作为键。如果键是可变的,那么使用的时候就可能出现由于键内部的属性改变,导致hashcode返回的值改变,那么就会查询不到原有的数据了。

为什么HashMap中String、Integer这样的包装类适合作为K?

String、Integer等包装类的特性能够保证Hash值的不可更改性和计算准确性,能够有效的减少
Hash碰撞的几率。

  1. 都是final类型,即不可变性,保证key的不可更改性,不会存在获取 hash值不同的情况
  2. 内部已重写了equals()、hashCode()等方法,遵守了HashMap内部的规范(不清楚可以去上面看看
    putValue的过程),不容易出现Hash值计算错误的情况

如果使用Object作为HashMap的Key,应该怎么办呢?

必须重写hashCode()和equals()方法!!!

  1. 重写hashCode()是因为需要计算存储数据的存储位置,需要注意不要试图从散列码计算中排除掉一个对象的关键部分来提高性能,这样虽然能更快但可能会导致更多的Hash碰撞;
  2. 重写equals()方法,需要遵守自反性、对称性、传递性、一致性以及对于任何非null的引用值x,x.equals(null)必须返回false的这几个特性,目的是为了保证key在哈希表中的唯一性,具体如何设计一个良好的equals方法可以直接搜索 “如何设计一个完美的equals方法”,可以查找到许多答案。

HashMap为什么不直接使用hashCode()处理后的哈希 值直接作为table的下标而是需要进行二次Hash?

答:hashCode()方法返回的是int整数类型,其范围为-(2 ^ 31)~(2 ^ 31 - 1),约有40亿个映射空间,而HashMap的容量范围是在16(初始化默认值)~2 ^ 30,HashMap通常情况下是取不到 大值的,并且设备上也难以提供这么多的存储空间,从而导致通过hashCode()计算出的哈希值可能不在数组大小范围内,进而无法匹配存储位置;
那怎么解决呢?

  1. HashMap自己实现了自己的hash()方法,通过两次扰动使得它自己的哈希值高低位自行进行异或运算,降低哈希碰撞概率也使得数据分布更平均;
  2. 在保证数组长度为2的幂次方的时候,使用hash()运算之后的值与运算(&)(数组长度 - 1)来获取数组下标的方式进行存储,这样一来是比取余操作更加有效率,二来也是因为只有当数组长度为2的幂次方时,h&(length-1)才等价于h%length,三来解决了“哈希值与数组大小范围不匹配"的问题

HashMap 的长度为什么是2的幂次方

这道题的答案其实就是上面那道题的答案
为了能让 HashMap 存取高效,尽量较少碰撞,也就是要尽量把数据分配均匀,每个链表/红黑树长度大致相同。这个实现就是把数据存到哪个链表/红黑树中的算法。
这个算法应该如何设计呢?
我们首先可能会想到采用%取余的操作来实现。但是,重点来了:“取余(%)操作中如果除数是2的幂次则等价于与其除数减一的与(&)操作(也就是说hash%length==hash&(length-1)的前提是 length 是2的 n 次方;)。”
并且采用二进制位操作 &,相对于%能够提高运算效率,这就解释了 HashMap 的长度为什么是2的幂次方。
并且扩容时hash&oldCap==0的元素留在原来位置,否则新位置=旧位置 oldCap
那为什么是两次扰动呢?
这样就是加大哈希值低位的随机性,使得分布更均匀,从而提高对应数组存储下标位置的随机性与均匀性, 对于减少Hash冲突,两次就够了,已经达到了高位低位同时参与运算的目的

HashMap 的长度不使用2的幂次方可以吗?

可以,首先使用2的幂次方带来的第一个问题就是数据的分布可能不那么均匀了。
因为对于偶数的数字进行与运算的时候,就可能导致大部分的偶数数据全都扎堆在偶数索引处,导致数据分布不均匀。而如果想要追求更高的Hash分布的均匀,那么推荐使用质数作为容量,并且如果容量为质数,那么甚至都不需要进行二次hash也可以获取很好的hash分布性。
所以选择使用2的幂次方主要是为了追求性能。
而如果要求追求hash分布性的,那么是可以不使用2的幂次方的。
例如.net中的实现就是扩容的时候先翻倍,然后查找比当前容量大的最接近的质数作为新容量,因此.net更加追求分布性,而Java则追求性能。

HashMap 与 HashTable 有什么区别?

  1. 线程安全: HashMap 是非线程安全的,HashTable 是线程安全的;
    HashTable 内部的方法基本都经过 synchronized 修饰。(如果你要保证线程安全的话就使用
    ConcurrentHashMap 吧!);
  2. 效率: 因为线程安全的问题,HashMap 要比 HashTable 效率高一点。另外,HashTable 基本被
    淘汰,不要在代码中使用它;
  3. 对Null key 和Null value的支持: HashMap 中,null 可以作为键,这样的键只有一个,可以有一
    个或多个键所对应的值为 null。但是在HashTable 中 put 进的键值只要有一个 null,直接抛NullPointerException。
  4. 初始容量大小和每次扩充容量大小的不同 :
    ①创建时如果不指定容量初始值,Hashtable 默认的初始大小为11,之后每次扩充,容量变为原来的2n 1。HashMap 默认的初始化大小为16。之后每次扩充,容量变为原来的2倍。
    ②创建时如果给定了容量初始值,那么 Hashtable 会直接使用你给定的大小,而 HashMap 会将其扩充为2的幂次方大小。也就是说 HashMap 总是使用2的幂作为哈希表的大小,后面会介绍到为什么是2 的幂次方(其实上面的回答就已经给出答案了)。
  5. 底层数据结构: JDK1.8 以后的 HashMap 在解决哈希冲突时有了较大的变化,当链表长度大于阈
    值(默认为8)时,将链表转化为红黑树,以减少搜索时间。Hashtable 没有这样的机制。
  6. 推荐使用:在 Hashtable 的类注释可以看到,Hashtable 是保留类不建议使用,推荐在单线程环境下使用 HashMap 替代,如果需要多线程使用则用 ConcurrentHashMap 替代。

如何决定使用 HashMap 还是TreeMap?

对于在Map中插入、删除和定位元素这类操作,HashMap是 好的选择。
然而,假如你需要对一个有序的key集合进行遍历,TreeMap是更好的选择。基于你的collection的大小,也许向HashMap中添加元素会更快,但将map换为TreeMap允许你进行有序key的遍历,具体使用情况看场景。

HashMap 和 ConcurrentHashMap (并发HashMap)的区别

  1. ConcurrentHashMap对整个桶数组进行了分割分段(Segment),然后在每一个分段上都用lock锁进
    行保护,相对于HashTable的synchronized 锁的粒度更精细了一些,并发性能更好,而HashMap
    没有锁机制,不是线程安全的。(JDK1.8之后ConcurrentHashMap启用了一种全新的方式实现,利用
    CAS算法。)
  2. HashMap的键值对允许有null,但是ConCurrentHashMap都不允许。

ConcurrentHashMap 和 Hashtable 的区别?

ConcurrentHashMap 和 Hashtable 的区别主要体现在实现线程安全的方式上不同。

  • 底层数据结构:
    JDK1.7的 ConcurrentHashMap 底层采用 分段的数组 链表 实现,
    JDK1.8 采用的数据结构跟HashMap1.8的结构一样,数组 链表/红黑二叉树
    Hashtable 和 JDK1.8 之前的 HashMap 的底层数据结构类似都是采用 数组 链表 的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的;
  • 实现线程安全的方式(重要):
    ① 在JDK1.7的时候,ConcurrentHashMap(分段锁) 对整个桶数组进行了分割分段(Segment),每一把锁只锁容器其中一部分数据(一把锁锁一个Segment),多线程访问容器(不同Segment)里不同数据段的数据,就不会存在锁竞争,提高并发访问率。(默认分配16个Segment,也就是把数组分为了16段,最优情况下比Hashtable效率提高16倍!!!。) ,而如果想要读取Segment中的数据,那么就得先获取到这把锁,所以他最多支持16个线程的并发。
    到了 JDK1.8 的时候已经摒弃了Segment的概念,不再继续使用分段锁的方式,而是直接用 Node 数组 链表 红黑树 的数据结构来实现,并发控制使用synchronized 和 CAS 来操作。(JDK1.6以后 对 synchronized锁做了很多优化),它使用了更加细粒度的锁,他锁的不再是一个分段了,而是哈希桶中的一个最小单位,然后再写入键值对的时候,可以锁住哈希桶中的链表的头节点,锁住了链表的头节点,那么后面的节点也无法访问了,就保证了线程安全。或者是锁住树的根节点,是一样的。 整个看起来就像是优化过且线程安全的 HashMap,虽然在JDK1.8中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本;
    ②Hashtable(同一把锁) :使用 synchronized 来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。
    两者对比图如下:
    学新通
    JDK1.7的ConcurrentHashMap:
    学新通
    学新通
    JDK1.8的ConcurrentHashMap(TreeBi(img)n: 红黑二叉树节点 Node: 链表节点):
    学新通

JDK1.7中的ConcurrentHashMap的规则

基本结构

在JDK1.7中ConcurrentHashMap的底层结构为Segment数组 数组 链表。
其中只需要对Segment数组进行加锁,那么Segment中的数组的元素就不允许并发访问了,但是如果访问的是不同的Segment数组中的数据,那么就允许并发访问。
下面说一下这个版本中的ConcurrentHashMap的构造函数,他有三个参数,分别是capacity:容量
factor:数组扩容因子,指的不是Segment数组,而是Segment数组中的数组
clevel:并发度,代表了Segment数组的长度
刚才说过Segment中的元素是一个数组和链表,而这个数组的长度的初始值为
capacity/clevel,也就是如果你的capacity为64,clevel为16,那么初始的数组长度就为4,并且当元素个数达到3个,还会触发扩容。而如果capacity的值小于clevel,那么默认数组大小为2。

Segment[0]与原型模式

JDK1.7中的ConcurrentHashMap中在创建的时候只有Segment[0]这个下标有数组,这里设定数组大小为2,那么之后再有数据插入的时候,在其他Segment数组处会以Segment[0]此时的数组状态进行创建。
例如如果要插入到Segment[1] ,此时如果Segment[0]长度为2,那么创建Segment[1]时候的数组大小也是2,如果要插入到Segment[2],此时的Segment[1]的数组大小为4,那么创建的数组大小也为4。这使用了设计模式中的原型模式。

Hash规则

JDK1.7中的ConcurrentHashMap的hash规则与hashmap不相同。
当输入的数据得到原始hash之后,会经过二次hash,二次hash的二进制数中,取出高n位组成的十进制数作为数据要插入到的Segment下表。取出低m位作为Segment数组中的数组的下标。其中n满足2 ^ n=clevel,2 ^ m=Segment数组中的数组大小
例如二次hash后得到的32位为 1100 0000 … 0001。设定此时clevel为16,Segment的数组中的数组大小为2,那么就有n=4,m=1。
所以此时定位到Segment的位置为12,并且定位到Segment数组中的数组下标为1。

JDK1.8中的ConcurrentHashMap

底层结构

不再使用重量级的Segment数组,而是使用数组 链表 红黑树的结构。
相比于JDK1.7中的饿汉式创建数组,JDK1.8中使用的是懒汉式,也就是必须调用了PUT方法之后才会创建ConcurrentHashMap。
他的初始容量也是16,他的扩容规则和JDK1.8中的HashMap一样,也是只要元素个数达到阈值就直接扩容,并且重新Hash。

构造函数

JDK1.8中的ConcurrentHashMap的构造函数参数为capacity和factor,但是这里是capacity表示的是ConcurrentHashMap要放入16个元素,很明显如果要放入16个元素,是需要进行扩容的,因此如果你设定capacity为16,那么创建的ConcurrentHashMap数组大小为32。如果你设定的是12,那么由于默认是0.75,因此还是超过了,所以创建的还是32。
而这里的factor扩容因子,也只是对于构造函数的时候有用,而之后的扩容因子大小依旧为0.75。例如一开始你设置capacity为7,然后factor为0.5,因此此时不会触发扩容,而就算再放入一个元素也不会扩容,你可能以为16*0.5=8应该要发生扩容了,但是这个factor代表的只是与你第一次构建数组的时候的扩容因子大小。因此如果你一开始设定的capacity为8,然后factor设定为0.5,那么此时创建的数组大小就是32。

扩容过程

这里设定数组大小为16,扩容因子为0.75,数组中已经有了11个元素了,此时再次插入一个元素的时候就会触发数组扩容。对于ConcurrentHashMap,扩容的方式为从数组尾巴开始,将链表中的数据经过rehash放入到新的数组中去。然后每一个被处理完毕的链表,都会被设定为forwardingNode。直到所有的数组位置都被复制,那么此时旧数组就会被新数组给代替了。
那么,其中扩容的细节是什么呢?
以为ConcurrentHashMap代表的是多线程下的HashMap呀,如果在扩容的过程中有其他线程来get或者put数据怎么办呢?总不能支持多线程的ConcurrentHashMap让我阻塞吧?

get

首先我们来说说get方法。
情况1:某条链表中的数据已经完整复制到新数组中
我们现在假设数组中的部分数据已经被拷贝完毕了,也就是数组链表被设定为forwardingNode。那么此时分为两种情况,第一种是如果访问的是还没有被拷贝的数组位置,那么就直接访问旧数组里面的数据即可,第二种情况就是访问到了已经拷贝完毕的数据,那么此时链表被设定为forwardingNode,那么这个线程就要去新数组中查找数据了。所以此时对于get方法,是不会阻塞的,可以并发运行。
情况2:查询时数据只复制了一半,还有一半还在链表中
这种情况就是get方法查询到的是一条比较长的链表,此时链表还没有被设定为forwardingNode,那么查询就会在旧的链表中进行查询。
但是由于会发生rehash,所以会导致迁移节点后,新数组中的1节点和旧数组中的1节点不是同一个对象,下图中1数组中的节点的next就不再是2而是null了。因此如果说使用的还是原来的对象,那么就完全可以去新数组中查找呀,以为那样子新数组中的节点1的next代表的还是2,但是此时其实已经是null了,所以进行扩容的时候其实进行的是创建了新的节点,而不是直接把原有的数据复制过去。当然ConcurrentHashMap也做了优化,就是在链表的最后几个元素,比如这里的3,4元素,他们如果rehash之后还是在对方的下面,那么就是直接复制过去了,不需要重新的创建。
学新通

put

put的并发情况分为三种。
情况1:
put的时候发生的是还没有开始发生扩容迁移时候的链表位置,那么此时就是正常情况,直接put即可,并且允许并发执行。
情况2:
put的位置是发生迁移的链表位置,由于我们知道扩容的时候会需要加锁,所以此时链表头加锁了,是无法访问的,所以也无法进行put操作,必须阻塞直到完成扩容。
情况3:
put发生的位置是节点类型为forwardingNode的时候,此时并不是将这个数据put到新数组中。因为ConcurrentHashMap既然是多线程的,那么其实他的扩容并不是完全由一个线程去执行的,而是一个线程去扩容16个数组大小,例如原本的数组大小为32,那么原有的线程可能扩容的是0-15这个位置的数组,然后此时这个put线程发现他put的时候要阻塞,所以他也没有闲着,而是去帮忙这个线程去做扩容,他可能扩容的久是16-31这个位置的数组。

ConcurrentHashMap可以使用ReentrantLock作为锁吗?

理论上是可以的,但是不推荐。
在JDK1.6之后,synchronized关键字已经被升级了。
他引入了偏向锁,轻量级锁,重量级锁,而这些在ReentrantLock中是没有的。
并且随着JDK版本的升级,synchronized也在进一步优化,而ReentrantLock本身也是使用Java代码来实现的,因此优化的空间比较少。因此选择优先选择synchronized,次之选择ReentrantLock。

ConcurrentHashMap 底层具体实现知道吗?实现原理是什么?

JDK1.7
首先将数据分为一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问。
在JDK1.7中,ConcurrentHashMap采用Segment HashEntry的方式进行实现,结构如下:
一个 ConcurrentHashMap 里包含一个 Segment 数组。Segment 的结构和 HashMap类似,是一种数组和链表结构,一个 Segment 包含一个HashEntry 数组,每个 HashEntry 是一个链表结构的元素,每个 Segment 守护着一个 HashEntry数组里的元素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment的锁。
Segment的大小默认是16,也就是说 ConcurrentHashMap 有 16 个 Segments,所以理论上,这个时候,最多可以同时支持 16 个线程并发写,只要它们的操作分别分布在不同的 Segment 上。这个值可以在初始化的时候设置为其他值,但是一旦初始化以后,它是不可以扩容的。再具体到每个 Segment 内部,其实每个 Segment 很像之前介绍的 HashMap,不过它要保证线程安全,所以处理起来要麻烦些。
学新通

  1. 该类包含两个静态内部类 HashEntry 和 Segment ;前者用来封装映射表的键值对,后者用来充当锁的角色;
  2. Segment 是一种可重入的锁 ReentrantLock,每个 Segment 守护一个HashEntry 数组里得元素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment 锁。

JDK1.8
在JDK1.8中,放弃了Segment臃肿的设计,取而代之的是采用Node CAS Synchronized来保证并发安全进行实现,synchronized只锁定当前链表或红黑二叉树的首节点,这样只要hash不冲突,就不会产生并发,效率又提升N 倍!!!。
学新通
看插入元素过程(建议去看看源码):
如果相应位置的Node还没有初始化,则调用CAS插入相应的数据;

1 else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
2 	if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
3 	break; // no lock when adding to empty bin
4 }

如果相应位置的Node不为空,且当前该节点不处于移动状态,则对该节点加synchronized锁,如果该节点的hash不小于0,则遍历链表更新节点或插入新节点;

1 if (fh >= 0) {
2 binCount = 1;
3 for (Node<K,V> e = f;;   binCount) {
4 K ek;
5 if (e.hash == hash &&
6 ((ek = e.key) == key ||
7 (ek != null && key.equals(ek)))) {
8 oldVal = e.val;
9 if (!onlyIfAbsent)
10 e.val = value;
11 break;
12 }
13 Node<K,V> pred = e;
14 if ((e = e.next) == null) {
15 pred.next = new Node<K,V>(hash, key, value, null);
16 break;
17 }
18 }
19 }
  1. 如果该节点是TreeBin类型的节点,说明是红黑树结构,则通过 putTreeVal方法往红黑树中插入节
    点;如果binCount不为0,说明put操作对数据产生了影响,如果当前链表的个数达到8个,则通过
    treeifyBin方法转化为红黑树,如果oldVal不为空,说明是一次更新(覆盖)操作,没有对元素个数产生影响,则直接返回旧值;
  2. 如果插入的是一个新节点,则执行addCount()方法尝试更新元素个数 baseCount;

HashTable,HashMap,TreeMap区别?

  1. HashTable线程同步,HashMap,TreeMap非线程同步。
  2. HashTable,TreeMap不允许<键,值>有空值,HashMap允许<键,值>有空值。
  3. HashTable中hash数组的默认大小是11,增加方式的old*2 1,HashMap中hash数组的默认大小
    是16,增长方式一定是2的指数倍。
  4. TreeMap能够把它保存的记录根据键排序,默认是按升序排序,因此如果你的键需要有序,建议使用TreeMap代替HashMap。

HashMap的扩容因子(HashMap 什么时候进行扩容呢)重点!

扩容因子:尺寸/容量。空表的负载因子是0,半满表的负载因子是0.5,以此类推。负载轻的表产生冲突的可能性小,因此对于插入和查找都是最理想的状态(但是会减慢使用迭代器遍历的过程)。HashMap 与HashSet 都允许指定负载因子的构造器,表示当负载情况达到该负载因子的水平时,容器将会自动扩容(增加桶位数),实现方式时使容量大致加倍,并重新将现有对象分布到新的桶位集中(这个过程被称为再散列)。

当HashMap中的元素越来越多的时候,碰撞的几率也就越来越高(因为数组的长度是固定的),所以为了提高查询的效率,就要对HashMap的数组进行扩容,数组扩容这个操作也会出现在ArrayList中,所以这是一个通用的操作,很多人对它的性能表示过怀疑,不过想想我们的“均摊”原理,就释然了,而在hashmap数组扩容之后,最消耗性能的点就出现了:原数组中的数据必须重新计算其在新数组中的位置,并放进去,这就是resize。
  那么HashMap什么时候进行扩容呢?当hashmap中的元素个数超过数组大小*loadFactor时,就会进行数组扩容,loadFactor的默认值为0.75,也就是说,默认情况下,数组大小为16,那么当hashmap中元素个数超过16 * 0.75=12的时候,就把数组的大小扩展为2 *16=32,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常消耗性能的操作,所以如果我们已经预知hashmap中元素的个数,那么预设元素的个数能够有效的提高hashmap的性能。比如说,我们有1000个元素new HashMap(1000), 但是理论上来讲new HashMap(1024)更合适,不过上面已经说过,即使是1000,hashmap也自动会将其设置为1024。 但是new HashMap(1024)还不是更合适的,因为0.75 * 1024 < 1000, 那么再次插入数据的时候就可能发生数组扩容了,也就是说为了让0.75 * size > 1000,我们必须这样new HashMap(2048)才最合适,既考虑了&的问题,也避免了resize的问题。

加载因子(扩容因子)为何默认是0.75?

  • 在空间占用与查询时间之间取得了较好的权衡
  • 大于这个值,空间节省了,但是链表可能过长就会影响性能
  • 小于这个值,冲突减少了,但是扩容就会比较频繁,空间占用多并且扩容也会消耗性能

多线程修改HashMap

多线程同时写入,同时执行扩容操作,多线程扩容可能死锁、丢数据;可以对HashMap 加入同步锁Collections.synchronizedMap(hashMap),但是效率很低,因为该锁是互斥锁,同一时刻只能有一个线程执行读写操作,这时候应该使用ConcurrentHashMap
注意:在使用Iterator遍历的时候,LinkedHashMap会产生java.util.ConcurrentModificationException

扩展HashMap增加双向链表的实现,号称是最占内存的数据结构。支持iterator()时按Entry的插入 顺序来排序(但是更新不算,如果设置accessOrder属性为true,则所有读写访问都算)。实现上是
在Entry上再增加属性before/after指针,插入时把自己加到Header Entry的前面去。如果所有读
写访问都要排序,还要把前后Entry的before/after拼接起来以在链表中删除掉自己。

多线程下使用HashMap会有什么问题?

扩容死链(JDK1.7)

HashMap为什么会产生死循环?

数据错乱(JDK1.7/8)

多线程情况下,可能出现两个线程同时插入的数据要插入到同一个索引处,也就是key不同,但是hashcode相同的情况,然后可能出现两个线程都进入到了为这个为同一个索引添加新节点的情况,那么先来的数据先赋值完毕后,另一个数据再赋值,那么就会出现覆盖问题。
学新通
这里我使用IDEA的debug并且设定进入断点的条件,来模拟这种情况的发生
学新通
下面是线程1,线程1插入的数据的索引为1
学新通
下面是线程2,线程2要插入的数据的索引也为1
学新通
我让线程2先完成数据的插入,那么此时线程1中的情况如下
学新通
之后我让线程1执行完毕插入操作,那么此时的情况如下,出现了数据的覆盖
学新通
可以发现一样的代码,但是只有一个key的数据了,这就是数据错乱
学新通

为何要用红黑树?

链表在链表长度过长的时候,遍历的时间复杂度为O(n),而红黑树的时间复杂度为O(logn),因此在链表长度过长的时候使用红黑树可以加快遍历速度。

为何不一上来就树化?

这是基于时间和空间的综合考虑。
时间上,在Hash冲突比较小的时候,即使转换为了红黑树,也不会有效率上多大的提升,反倒会导致在put的时候由于需要进行红黑树的旋转算法,所以反倒会导致时间效率降低。
红黑树的节点类型为TreeNode,而链表的节点类型为Node,红黑树消耗的空间更多,需要维护更多的指针,对内存的占用更大。
而HashMap之所以选择红黑树而不是普通的二叉搜索树,是因为普通的二叉搜索树在特殊的情况它会变成一个倾斜的情况,最差的情况会变成一个类似于链表的情况,那么查找效率就会退化为和链表差不多。而红黑树他是可以防止这种退化的,并且它又不像其他的完全的平衡二叉树那样有严格的平衡条件,因此红黑树它的插入效率又比那种完全的平衡二叉树要高,因此选择红黑树可以防止极端环境下的退化,又可以兼顾查询和插入的效率。

树化阈值为何是8?

其实,一个链表的长度能超过8的概率非常低。
红黑树用来避免DoS攻击,防止链表超长时性能下降,树化应当是偶然情况,
hash表的查找,更新的时间复杂度是0(1),而红黑树的查找,更新的时间复杂度是0(log2n),TreeNode占用空间也比普通Node的大,如非必要,尽量还是使用链表。
hash值如果足够随机,则在 hash表内按泊松分布,在负载因子0.75的情况下,长度超过8的链表出现概率是0.00000006,选择8就是为了让树化几率足够小.

何时会树化?何时会退化为链表?

树化有两个条件:链表长度超过树化阈值并且数组容量大于等于64。
由于树化的开销很大,因此能使用扩容的方式解决链表长度过长的话,是不会树化的。
退化情况1:在扩容的时候如果拆分了树,使得树中元素个数小于等于6,那么红黑树会退化为链表。
退化情况2:remove树中的节点的时候,在remove之前,若root,root.left,root.right,root.left.left中有一个为null,也会退化为链表,然后再移除这个节点。

HashMap为什么会产生死循环?

其他集合类型面试题

Set集合
List集合(重要)

这篇好文章是转载于:学新通技术网

  • 版权申明: 本站部分内容来自互联网,仅供学习及演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系,请提供相关证据及您的身份证明,我们将在收到邮件后48小时内删除。
  • 本站站名: 学新通技术网
  • 本文地址: /boutique/detail/tanhfijaah
系列文章
更多 icon
同类精品
更多 icon
继续加载