尼恩说在前面
HashMap的工作原理是目前java面试问的较为常见的问题之一,在40岁老架构师 尼恩的读者交流群(50+)中,最近有小伙伴拿到了一线互联网企业如得物、阿里、滴滴、极兔、有赞、shein 希音、百度、网易的面试资格,遇到很多很重要的面试题:
是否用过Hashmap,hashMap的解决hash碰撞的机制是什么?
hashMap是如何扩容的?
hashMap的底层数据结构是什么?
HashMap为什么将头插法,改尾插法?
小伙伴 没有回答好,导致面试挂了。这个是一个非常常见的面试题,考察的是hashmap的基本功。
如何才能回答得很漂亮,才能 让面试官刮目相看、口水直流呢?这里,尼恩给大家做一下系统化、体系化的梳理,让面试官爱到 “不能自已、口水直流”,然后帮大家 实现 ”offer自由”。
当然,这道面试题,以及参考答案,也会收入咱们的 《尼恩Java面试宝典》V175版本PDF集群,供后面的小伙伴参考,提升大家的 3高 架构、设计、开发水平。
注:本文以 PDF 持续更新,最新尼恩 架构笔记、面试题 的PDF文件,请关注本公众号【技术自由圈】获取。
什么是哈希表
HashMap是Java中的一种基于哈希表实现的,它允许我们使用键值对的形式来存储和获取数据。
从根本上来说,一个哈希表包含一个数组,但是元素访问不是通过 index 编号的形式(比如 array[i]的形式),而是通过特殊的关键码(也就是key)来访问数组中的元素。
哈希表的主要思想是:
- 存放Value的时候,通过一个哈希函数,通过 **关键码(key)**进行哈希运算得到哈希值,然后得到 映射到(map到)的位置, 去存放值 ,
- 读取Value的时候,也是通过同一个哈希函数,通过 **关键码(key)**进行哈希运算得到哈希值,然后得到 映射到(map到)的位置,从那个位置去读取。
非常类似下面的字典图,如果我们要找 “啊” 这个字,只要根据拼音 “a” 去查找拼音索引,
查找 “a” 在字典中的索引位置 “啊”,这个过程就是哈希函数的作用,用公式来表达就是:f(key),而这样的函数所建立的表就是哈希表。
尼恩提示,哈希表这么做的优势:主要是为了加快了查找key的速度。
在不存在key的冲突场景,时间复杂度为 O(1),一下就命中。
比起数组和链表查找元素时需要遍历整个集合的情况来说,哈希表明显方便和效率的多。
硬币的反面是:寻址容易,插入和删除困难。
特点:寻址容易,插入和删除困难。
HashMap主要依赖数组来存储数据。 哈希表中的每个元素被称为“bucket” (桶)。当然,也有叫做槽位(slot)的,反正都是这么个意思。
叫做槽位的例子,请参见尼恩这篇阅读量超过2万的硬核文章
在 hashtable的 数组的每个位置(bucket)上,都可以存放一个元素(键值对),bucket的定位,通过key的hash函数值取模(具体算法依据hash函数去定)之后去获得, 这样,可以O(1)的时间复杂度快速定位到数组的某个位置,取出相应的值,这就是HashMap快速获取数据的原理。
什么是hash冲突(/hash碰撞)
哈希表 通过key的hash函数值取模(具体算法依据hash函数去定)之后去获得 bucket 槽位索引,不同的key值,可能会出现同一个 bucket 槽位,这就是 哈希冲突。
哈希冲突问题,用公式表达就是:
key1 ≠ key2 , f(key1) = f(key2)
以上面的字典图为例,那如果字典中有两个字的拼音相同 (比如安 和 按),就是 哈希冲突。
一般来说,哈希冲突是无法避免的,如果要完全避免的话,那么就只能一个key对应一个bucket 槽位索引,也就是一个字就有一个索引 (安 和 按就是两个索引),这样一来,需要大量的内存空间,内存空间就会增大,甚至内存溢出。
那么,有什么哈希冲突的解决办法呢?
常见的哈希冲突解决办法有两种:
- 开放地址法
- 链地址法。
关于 开放地址法, 链地址法的详细介绍,请参考 《尼恩Java面试宝典 》,里边非常细致,这里不做赘述。
哈希表1.7/哈希表1.8 采用链地址法,解决hash碰撞
采用链地址法解决hash碰撞的极端情况
哈希表的特性决定了其高效的性能,大多数情况下查找元素的时间复杂度可以达到O(1), 时间主要花在计算hash值上,
然而也有一些极端的情况,最坏的就是hash值全都映射在同一个地址上,这样哈希表就会退化成链表,例如下面的图片:
当hash表变成图2的情况时,查找元素的时间复杂度会变为O(n),效率瞬间低下,
所以,设计一个好的哈希表尤其重要,如HashMap在jdk1.8后引入的红黑树结构就很好的解决了这种情况。
JDK1.7 中头插法
采用链地址法后,冲突数据使用链表管理。 但是数据插入链表的时候,有两种方式:
- 头插
- 尾插
在 JDK1.7 中HashMap采用的是头插法,就是在链表的头部插入,新插入的 slot槽位数据保存在链表的头部。
比如插入同一个 槽位的三个 key A B C 之后, 示意图如下。
在 JDK1.7 中HashMap采用的是头插法,大致的源码如下:
//newTable表示新创建的扩容后的数组
//rehash表示元素是否需要重新计算哈希值
void transfer(Entry[] newTable, boolean rehash) {
//记录新数组的容量
int newCapacity = newTable.length;
//遍历原数组的桶位置
for (Entry<K,V> e : table) {
//如果桶位置不为空,则遍历链表的元素
while(null != e) {
//next表示原数组链表的下一个节点
Entry<K,V> next = e.next;
//确定元素在新数组的索引位置
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
//头插法,新元素始终指向新数组对应桶位置的第一个元素
e.next = newTable[i];
//新插入的元素成为桶位置的第一个元素
newTable[i] = e;
//遍历原数组链表的下一个元素
e = next;
}
}
}
JDK1.7的底层数据结构
JDK1.7的底层数据结构 包括一个 槽位数组 table, 每个桶中的元素都需要一个单独的Entry, 用于存储冲突链表的头。
/**
* An empty table instance to share when the table is not inflated.
*/
static final Entry<?,?>[] EMPTY_TABLE = {};
/**
* The table, resized as necessary. Length MUST Always be a power of two.
*/
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;
int hash;
...
}
![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/a797a2605595e378f947d79d1001f52d.png)
JDK1.7之前(1.8之前)使用头插法的好处
使用头插法的好处,40岁老架构师尼恩,给大家总结出如下几点:
-
效率高
- 扩容的时候,插入在头部,效率高一些,时间复杂度为O(1)
- 但如果插入尾部,都要遍历到最后一个节点,时间复杂度为O(N)
-
满足时间局部性原理
根据时间局部性原理,最近插入的最有可能被使用
JDK1.7头插法导致的在扩容场景导致恶性死循环的问题
来看看hashmap的扩容。
回顾一下hashmap的内部结构。HashMap底层存储的数据结构如下:
在JDK1.7及前
- 数组
- 链表
在JDK1.8后
- 数组
- 链表 -当链表的长度临近于8时,转为红黑树
- 红黑树
一般情况下,当元素数量超过阈值时便会触发扩容。每次扩容的容量都是之前容量的 2 倍。
HashMap 初始容量默认为16。如果写了初始容量,如果写的为11,他其实初始化的并不是11,而是取2n ,取与11最相近的那个值,必须大于等于11,所以为16。
但是HashMap 的容量是有上限的,必须小于 1<<30,即 1073741824。如果容量超出了 1<<30,即 1073741824这个数,则不再增长,且阈值会被设置为 Integer.MAX_VALUE。
JDK7 中的扩容机制
- 空参构造函数:以默认容量、默认负载因子、默认阈值初始化数组。内部数组是空数组。
- 有参构造函数:根据参数确定容量、负载因子、阈值等。第一次 put 时会初始化数组,其容量变为不小于指定容量的 2 的幂数,然后根据负载因子确定阈值。
- 如果不是第一次扩容,则 新容量=旧容量 x 2 ,新阈值=新容量 x 负载因子 。
JDK8 的扩容机制
-
空参构造函数:实例化的 HashMap 默认内部数组是 null,即没有实例化。第一次调用 put 方法时,则会开始第一次初始化扩容,长度为 16。
-
有参构造函数:用于指定容量。会根据指定的正整数找到不小于指定容量的 2 的幂数,将这个数设置赋值给阈值(threshold)。第一次调用 put 方法时,会将阈值赋值给容量,然后让 阈值 = 容量 x 负载因子。
-
如果不是第一次扩容,则容量变为原来的 2 倍,阈值也变为原来的 2 倍。(容量和阈值都变为原来的 2 倍时,负载因子还是不变)。
此外还有几个细节需要注意:
- 首次 put 时,先会触发扩容(算是初始化),然后存入数据,然后判断是否需要扩容;
- 不是首次 put,则不再初始化,直接存入数据,然后判断是否需要扩容;
JDK1.7中(JDK1.8之前)HashMap触发扩容机制时,会创建新的Entry[ ]数组,将旧的Entry数据进行复制.
这就是头插法数据。当某个entry上具有链式结构时,采用头插方式进行数据迁移,即将旧链表数据从头部遍历,每次取到的数据,插入到重新散列到的slot槽位的新链表的头部。
JDK1.7中HashMap的插入方法采用的是头插法,即新插入的元素会插入到链表的头部。
这样会产生以下问题:
- 破坏了链表元素的插入顺序,链表的顺序被反转:由于头插法是将新插入的元素插入到链表的头部,这样就导致链表的顺序与元素插入的顺序相反,不利于一些需要按照插入顺序遍历的场景。
- 容易引起链表环形问题:是因为多个线程并发扩容时,因为一个线程先期完成了扩容,将原 Map 的链表重新散列到自己的表中,并且链表变成了倒序,后一个线程再扩容时,又进行二次散列,再次将倒序链表变为正序链表。这个过程中会造成链表尾部丢失,形成环形链表,从而开始死循环、甚至CPU 100%的噩梦。
现象1,链表的顺序被反转:
头插法扩容之后, 假设原来的元素重新hash还在同一个槽位(这是假设,大概率不是统一槽位),同一个链表上的元素顺序与元素插入的顺序变了,变反了。
当然,下面这个死循环,才是致命的问题,会导致CPU 100%, 程序直接废了。
这个是两个大的问题:
- 导致的死循环
- CPU 100%, 程序直接废了
头插法,在扩容时导致的死循环
由于头插法需要修改链表头, JDK1.7 头插法,在扩容时导致的死循环
先扩容的代码
void resize(int newCapacity)
{
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
......
//创建一个新的Hash Table
Entry[] newTable = new Entry[newCapacity];
//将Old Hash Table上的数据迁移到New Hash Table上
transfer(newTable);
table = newTable;
threshold = (int)(newCapacity * loadFactor);
}
迁移的代码
//newTable表示新创建的扩容后的数组
//rehash表示元素是否需要重新计算哈希值
void transfer(Entry[] newTable, boolean rehash) {
//记录新数组的容量
int newCapacity = newTable.length;
//遍历原数组的桶位置
for (Entry<K,V> e : table) {
//如果桶位置不为空,则遍历链表的元素
while(null != e) {
//next表示原数组链表的下一个节点
Entry<K,V> next = e.next;
//确定元素在新数组的索引位置
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
//头插法,新元素始终指向新数组对应桶位置的第一个元素
e.next = newTable[i];
//新插入的元素成为桶位置的第一个元素
newTable[i] = e;
//遍历原数组链表的下一个元素
e = next;
}
}
}
JDK1.7链表头插法扩容step1
假设两个线程thread 1、thread2 进行重新,并且,都执行到了transfer 方法的 if (rehash) {...}
之前,
此时,thread 1、thread2 都确定了e和next,如下图所示:
JDK1.7链表头插法扩容step2
假设,此时thread 2线程的时间片没了,被操作系统挂起来了
只有thread 2 线程可以向下执行,一个人把活儿干完了,得到可扩容后的 大致结果,如下图:
注意,这个的元素次序已经倒过来了。 如果step1 是正序的话,这里是倒序了。
JDK1.7链表头插法扩容step3
thread 2线程的时间片又有了,继续执行
//确定元素在新数组的索引位置
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
//头插法,新元素始终指向新数组对应桶位置的第一个元素
e.next = newTable[i];
//新插入的元素成为桶位置的第一个元素
newTable[i] = e;
//遍历原数组链表的下一个元素
e = next;
头插方式执行,这个时候,危险就悄悄来了
我们把图画细致一点, 聚焦到 这个虚拟的 链表,如下图:
咱们的线程,开始执行三面的三句核心的语句:
//头插法,新元素始终指向新数组对应桶位置的第一个元素
e.next = newTable[i];
//新插入的元素成为桶位置的第一个元素
newTable[i] = e;
//遍历原数组链表的下一个元素
e = next;
先调整 e.next指针,指向 链表头部的newTable[i] 也就是 A,如下图
实际上,这个时候,链表已经变成了 死循环链表了。 链表的 尾部节点已经丢失,形成了环形链表。
这里提示一下,尼恩为啥写这个文章呢,发现网上很多小伙写博客,解释尾插法的时候, 没有解释清楚。
看了好几篇文章,没有一篇讲清楚了的, 而且那些文章的点击量还很高。
而且网上抄来抄去的,画很多很复杂的图,来解释一个不明确的、不清晰的答案。
40岁老架构师尼恩,给大家来一个简单明了的答案: 就是因为 倒序+ 正序 这种乱序的插入,导致了 尾部的丢失,从而形成了环形链表。
尼恩的宗旨,就是用深厚的内功,把复杂知识简单化,帮助大家成为技术高手。
OK,咱们继续正文。
形成了环形链表之后, 由于后面的risize 是用null != e 作为条件 去终止内部循环的,大家思考一下,这个循环还有终止的可能吗?
来看看代码,具体如下:
//遍历原数组的桶位置
for (Entry<K,V> e : table) {
//如果桶位置不为空,则遍历链表的元素
while(null != e) {
//next表示原数组链表的下一个节点
Entry<K,V> next = e.next;
//...省略 插入头部
//遍历原数组链表的下一个元素
e = next;
}
}
上面的链表, 唯一的一个next =null的元素 C, 他的next值,也就不为空了,
那么 这个risize的循环,从此,也就永远出不来了。
另外,如果这个时候,来一个线程去get 元素,如果没有找到对应的key,也会死循环。
不行,咱们走着瞧,
咱们的扩容step3 还没有结束,第三步结束之后:
这一步,C处理完了,到了头部,
但是下一个要处理的,是之前历史资产B, 而不是 A。
//如果桶位置不为空,则遍历链表的元素
while(null != e) {
//next表示原数组链表的下一个节点
Entry<K,V> next = e.next; //step3 e=C next=B
//确定元素在新数组的索引位置
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
//头插法,新元素始终指向新数组对应桶位置的第一个元素
e.next = newTable[i]; // c.next=a,空指针丢失, 循环噩梦开始
//新插入的元素成为桶位置的第一个元素
newTable[i] = e; //c到头部
//遍历原数组链表的下一个元素
e = next; //e=b
}
JDK1.7链表头插法扩容step4
下面正式开始无限插入循环的噩梦。
第三步之后的第4步, e变成了B, 进入下一轮循环后, next=C
执行过程大致如下
//如果桶位置不为空,则遍历链表的元素
while(null != e) {
//next表示原数组链表的下一个节点
Entry<K,V> next = e.next; //step4 e=B next=C
//确定元素在新数组的索引位置
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
//头插法,新元素始终指向新数组对应桶位置的第一个元素
e.next = newTable[i]; // B.next=B,自己指向自己, 已经彻底短链了,彻底失控了
//新插入的元素成为桶位置的第一个元素
newTable[i] = e; //B还是到头部
//遍历原数组链表的下一个元素
e = next; //e=C
}
执行之后的结果如下,已经彻底失控了:
JDK1.7链表头插法扩容step5
下面正式开始无限插入循环的噩梦。
第三步之后的第5步, e变成了C, 进入下一轮循环后, next=A
执行过程大致如下
//如果桶位置不为空,则遍历链表的元素
while(null != e) {
//next表示原数组链表的下一个节点
Entry<K,V> next = e.next; //step4 e=C next=A
//确定元素在新数组的索引位置
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
//头插法,新元素始终指向新数组对应桶位置的第一个元素
e.next = newTable[i]; // C.next=B,
//新插入的元素成为桶位置的第一个元素
newTable[i] =e; //C到了头部
//遍历原数组链表的下一个元素
e = next; //e=A
}
执行之后的结果如下,已经彻底失控了:
其实已经没有推演下去的意义了,自从丢失空指针null之后, 这个已经是循环链表了。
循环永远出不来了。
另外,如果来一个,来一个线程去get 元素,如果没有找到对应的key,也会死循环。
如何破解环形链表
40岁老架构师尼恩,前面给大家来一个简单明了的答案: 就是因为 倒序+ 正序 这种乱序的插入,导致了 尾部的丢失,从而形成了环形链表。
这就像 负负得正 一样的。 第一次头插是倒序, 第二次 头插是 正序, 第三次头插是倒序, 第四次 头插是 正序。 而只要 倒序+ 正序 一组合,就会丢掉尾部的 空指针。
如何破解, 很简单, 保证插入的次序一致就OK了。
如果保证每一次插入的次序一致呢? 采用 尾插法。 新的Entry都插入到尾部, 并且新的尾部的 Entry.next 为空,这样做有两个结果:
- 永远是正序
- 永远有尾部
结论是,不会产生环形链表。 当然也破解了头插法导致的 死循环和CPU 100%的问题。
那么,使用微插法的不足是啥?40岁老架构师尼恩,给大家总结出如下几点:
-
扩容的效率低些
扩容的时候,插入在尾部,效率高一些,时间复杂度为O(N)
-
不满足时间局部性原理
根据时间局部性原理,最近插入的最有可能被使用,这时候已经插入到尾部去了,要找到尾部才能找得到。
那么 JDK1.8的底层,用的就是尾插。
JDK1.8的底层数据结构
每个桶中的元素使用Node保存,使用TreeNode支持红黑树,并且Node中的hash属性使用final修饰,一旦确定将不可变。
首先来看看,一个JDK 1.8版本ConcurrentHashMap(HashMap的并发版本)实例的内部结构,示例如图7-16所示。
图7-16 一个JDK 1.8 版本ConcurrentHashMap实例的内部结构
以上的内容,来自 尼恩的 《Java 高并发核心编程 卷2 加强版》,尼恩的高并发三部曲,很多小伙伴反馈说:相见恨晚,爱不释手。
关于table 数组的解释:
table 数组在第一次往HashMap中put元素的时候初始化,如果HashMap初始化的时候没有指定容量,那么初始化table的时候会使用默认的DEFAULT_INITIAL_CAPACITY
参数,也就是16
,作为table初始化时的长度。
如果HashMap初始化的时候指定了容量,HashMap会把这个容量修改为2
的倍数,然后创建对应长度的table。因为table在HashMap扩容的时候,长度会翻倍。所以table的长度肯定是2
的倍数。
请注意:如果要往HashMap中放1000
个元素,又不想让HashMap不停的扩容,最好一开始就把容量设为2048
,设为1024
不行,因为元素添加到769
的时候还是会扩容。
/**
* The table, initialized on first use, and resized as
* necessary. When allocated, length is always a power of two.
* (We also tolerate length zero in some operations to allow
* bootstrapping mechanics that are currently not needed.)
*/
transient Node<K,V>[] table;
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; // key的hash值
final K key; // 节点的key,类型和定义HashMap时的key相同
V value; // 节点的value,类型和定义HashMap时的value相同
Node<K,V> next; // 该节点的下一节点
...
}
/**
* Entry for Tree bins. Extends LinkedHashMap.Entry (which in turn
* extends Node) so can be used as extension of either regular or
* linked node.
*/
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent; // red-black tree links
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev; // needed to unlink next upon deletion
boolean red;
...
}
/**
* Returns a power of two size for the given target capacity.
*/
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/ae5caa6ff058091e9506531022cba1f7.png)
底层基础数据结构:数组 + 链表(哈希表或散列表),在1.8中为了提升元素获取速度,引进了红黑树,用以解决冲突时性能问题。
当数组链表变得过长时,HashMap会将链表转为红黑树,以此来提高元素的查找、插入和删除操作的性能。
理解 JDK1.8的HashMap扩容原理
扩容(resize)就是重新计算容量,进行扩大数组容量,以便装入更多的元素。
向hashMap不停的添加元素,当hashMap无法装载新的元素,对象将需要扩大数组容量,以便装入更多的元素。
扩容临界值计算公式:threadshold = loadFactory * capacity。loadFactory 负载因子的默认值是0.75,capacity容量大小默认是16。
也就是说,第1次扩容的动作会在元素个数达到12的时候触发,扩容的大小是原来的2倍。
HashMap的最大容量是Integer.MAX_VALUE也就是2的31次方减1。
注意:以下扩容原理讲解基于JDK1.8
理解 JDK1.8的HashMap的 扩容
创建一个新的的Entry空数组,长度是原数组的2倍。
Node<K,V> loHead = null, loTail = null; //低位链表的头尾结点
Node<K,V> hiHead = null, hiTail = null; //高位链表的头尾结点
Node<K,V> next; //next指针 指向下一个元素
理解 JDK1.8的HashMap的迁移
遍历原Entry数组,把所有的Entry重新Hash(迁移)到新数组。
由于扩容之后,数组长度变大,hash的规则也会随之改变,所以需要重新hash。
-
扩容前,临界监测: 这里将其设置为长度为 8(用8举例主要是为了画图 ,hashMap默认容量是16),扩容临界点 8 * 0.75 = 6
-
数组扩容:长度达到 临界点后开始扩容,扩容后开始迁移。
-
扩容后,迁移数据:重新计算元素的hashCode,并存储到相应位置。
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length; //数组容量(旧)
int oldThr = threshold; //扩容临界点(旧)
int newCap, newThr = 0; //数组容量(新)、扩容临界点(新)
if (oldCap > 0) {
//如果旧容量大于等于了最大的容量 2^30
if (oldCap >= MAXIMUM_CAPACITY) {
//将临界值设置为Integer.MAX_VALUE
threshold = Integer.MAX_VALUE;
return oldTab;
}
//扩容2倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // 新阈值设置2倍
}
else if (oldThr > 0) // HashMap(int initialCapacity, float loadFactor)调用
newCap = oldThr;
else { // 第一次put操作的时候,因为jdk1.8hashMap先添加元素再扩容
//构造函数将jdk1.7的扩容移动到这
newCap = DEFAULT_INITIAL_CAPACITY; //默认容量 16
//临界值 16 *0.75 =12
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {//如果新阈值为0,根据负载因子设置新阈值
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
//创建新的 槽位数组
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
//迁移数据
for (int j = 0; j < oldCap; ++j) {如果旧的数组中有数据,循环
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null; //gc处理
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e; //只有一个节点,赋值,返回
else if (e instanceof TreeNode) //判断是否为红黑树结点
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
Node<K,V> loHead = null, loTail = null; //低位链表
Node<K,V> hiHead = null, hiTail = null; //高位链表
Node<K,V> next;
do {
next = e.next; //指向下个元素结点,做为while循环的条件
if ((e.hash & oldCap) == 0) { //判断是否为低位链表
if (loTail == null) //链表没有元素,则将该元素作为头结点
loHead = e;
else
loTail.next = e; //加在链表的下方
loTail = e;
}
else { {//不为0,元素位置在扩容后数组中的位置发生了改变,新的下
//标位置是(原下标位置+原数组长)
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
//遍历完成后,进行数据迁移
if (loTail != null) {
//链表最后
loTail.next = null;
//将元素原位置迁移到新数组中,位置一样
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
//高位链表迁移 + 旧数组容量
newTab[j + oldCap] = hiHead;
}
}
}
}
}
//返回新数组
return newTab;
}
JDK1.8的HashMap的总结
-
扩容就是将旧表的数据迁移到新表。
-
迁移过去的值需要重新计算hashCode,也就是他的存储位置。
-
关于位置可以这样理解:比如旧表的长度8、新表长度16
旧表位置4有6个数据,假如前三个hashCode是一样的,后面的三个hashCode是一样的迁移的时候;就需要计算这6个值的存储位置。 -
如何计算位置?采用低位链表和高位链表;
如果位置4下面的数据e.hash & oldCap等于0,那么它对应的就是低位链表,也就是数据位置不变,
e.hash & oldCap不等于0就要重写计算他的位置,也就是j + oldCap(4+8);这个12,就是高位链表位置(新数组12位置)。
JDK1.8链表尾插法性能优化
前面讲到 , 尾插法在扩容场景进行数据迁移的时候时间复杂度为O(N),头插法在扩容场景进行数据迁移的时候时间复杂度为 O(1)。 但是这个仅仅是理论。
JDK1.8链表除了采用为尾插之外,做了很多性能优化,比如:使用MurmurHash算法提高哈希算法的效率,减少了某些链表长度过长的情况减少遍历的次数等等。
总之针对JDK1.7中死循环问题,将HashMap的插入方法改为了尾插法,即新插入的元素会插入到链表的尾部,这样可以解决很多问题并且有以下优点:
- 避免链表环形问题:尾插法是将新插入的元素插入到链表的尾部,不需要修改链表头,因此可以避免在并发环境下多个线程修改链表头导致的链表环形问题。
- 提高哈希算法的效率:Java8使用的是MurmurHash算法,该算法具有良好的随机性和分布性,能够有效地降低哈希冲突的概率,从而提高HashMap的性能。
- 提高查询效率:尾插法使得链表元素的插入顺序与元素插入的顺序一致,从而方便了元素的查找和遍历操作,提高了HashMap的查询效率。
- 提高链表长度的平衡:尾插法可以使得链表长度比较平衡,减少了某些链表长度过长的情况,从而提高了HashMap的性能。
头插法和尾插法的性能对比
我们可以通过一个简单的例子来说明Java8中HashMap插入方法的改变对性能的影响。
假设有一个HashMap,包含10000个元素,现在需要将一个新元素插入到其中。
为了测试插入操作的性能,我们分别使用Java8之前的版本和Java8及以后的版本实现插入操作,并记录每次插入的时间。
具体代码实现如下:
HashMap<Integer, String> map = new HashMap<>();
for (int i = 0; i < 10000; i++) {
map.put(i, "value" + i);
}
long startTime = System.currentTimeMillis();
map.put(10000, "new value");
long endTime = System.currentTimeMillis();
System.out.println("Time taken: " + (endTime - startTime) + "ms");
运行多次后,我们可以得到平均插入时间的数据。
假设运行10次,得到的数据如下:
Java8之前的版本 | Java8及以后的版本 |
---|---|
4ms | 3ms |
3ms | 2ms |
4ms | 3ms |
4ms | 2ms |
3ms | 3ms |
4ms | 2ms |
3ms | 3ms |
4ms | 2ms |
3ms | 3ms |
4ms | 2ms |
平均值:3.6ms | 平均值:2.6ms |
从上表可以看出,Java8及以后的版本插入操作的平均时间要比Java8之前的版本快,差距在1ms左右,
这是由于Java8将HashMap的插入方法改为了尾插法,避免了链表环形问题的发生,同时优化了哈希算法和查询效率,从而提高了HashMap的性能。
尾插法和头插法总结
在并发编程中使用HashMap可能会导致死循环,而使用线程安全的HashTable效率又低下。
HashMap 之所以在并发下的扩容造成死循环,是因为多个线程并发进行时,因为一个线程先期完成了扩容,将原 Map 的链表重新散列到自己的表中,并且链表变成了倒序,后一个线程再扩容时,又进行自己的散列,再次将倒序链表变为正序链表。
于是形成了一个环形链表,两种场景造成死循环:
-
在扩容场景后面的元素移动过程中,造成死循环。
-
由于环形链表的存在,在后面 get 表中不存在的元素时,也造成死循环。
在Java1.5中,并发编程大师Doug Lea给我们带来了concurrent包,而该包中提供的ConcurrentHashMap是线程安全并且高效的HashMap。 ConcurrentHashMap使用锁保证了扩容的独占性,于是在多线程并发处理下,解决了HashMap在扩容到时候造成链表形成环形结构的问题。
所以,如果存在并发扩容场景,需要使用 ConcurrentHashMap。 关于ConcurrentHashMap也有大量的面试难题和真题,具体请参见尼恩的 5000页+《尼恩Java面试宝典》。
说在最后
HashMap相关的面试题,是非常常见的面试题。
以上的内容,如果大家能对答如流,如数家珍,基本上 面试官会被你 震惊到、吸引到。最终,让面试官爱到 “不能自已、口水直流”。offer, 也就来了。
在面试之前,建议大家系统化的刷一波 5000页《尼恩Java面试宝典》V174,在刷题过程中,如果有啥问题,大家可以来 找 40岁老架构师尼恩交流。
另外,如果没有面试机会,可以找尼恩来帮扶、领路。
尼恩已经指导了大量的就业困难的小伙伴上岸,前段时间,帮助一个40岁+就业困难小伙伴拿到了一个年薪100W的offer,小伙伴实现了 逆天改命 。
尼恩技术圣经系列PDF
- 《NIO圣经:一次穿透NIO、Selector、Epoll底层原理》
- 《Docker圣经:大白话说Docker底层原理,6W字实现Docker自由》
- 《K8S学习圣经:大白话说K8S底层原理,14W字实现K8S自由》
- 《SpringCloud Alibaba 学习圣经,10万字实现SpringCloud 自由》
- 《大数据HBase学习圣经:一本书实现HBase学习自由》
- 《大数据Flink学习圣经:一本书实现大数据Flink自由》
- 《响应式圣经:10W字,实现Spring响应式编程自由》
- 《Go学习圣经:Go语言实现高并发CRUD业务开发》
……完整版尼恩技术圣经PDF集群,请找尼恩领取
《尼恩 架构笔记》《尼恩高并发三部曲》《尼恩Java面试宝典》PDF,请到下面公号【技术自由圈】取↓↓↓