HashMap源码分析 + 面试题,基于JDK1.8逐行分析,面试看这一篇就够了!
2021/4/24 12:28:16
本文主要是介绍HashMap源码分析 + 面试题,基于JDK1.8逐行分析,面试看这一篇就够了!,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!
阅读本文章之前推荐先阅读博主关于红黑树的文章,讲述了从二叉排序树 → AVL树 → 红黑树的演变,传送地址:快速理解红黑树
HashMap源码分析 + 面试题
文章目录
- HashMap源码分析 + 面试题
- 一、哈希介绍
- 二、HashMap集合简介
- 三、HashMap存储数据的过程
- 3.1 过程分析
- 3.2 常见面试题
- 四、HashMap继承关系
- 五、HashMap集合成员
- 5.1 成员变量
- 5.2 构造方法
- 5.3 成员方法
- 5.3.1 增加方法
- 5.3.2 红黑树新增节点过程
- 5.3.3 树化方法
- 5.3.4 扩容方法
- 5.3.5 红黑树的拆分过程
- 5.3.6 删除方法
- 5.3.7 查询方法
- 5.3.8 红黑树查找节点过程
- 六、HashMap时间复杂度分析
一、哈希介绍
- 核心理论
- Hash也称散列、哈希,基本原理就是把任意长度的输入,通过Hash算法变成固定长度的输出;这个映射的规则就是对应的Hash算法,而原始数据映射后的二进制串就是哈希值
- 哈希的特点
- 从hash值不可以反向推导出原始的数据
- 输入数据的微小变化会得到完全不同的hash值,相同的数据会得到相同的值
- 哈希算法的执行效率高效,长的文本也能快速地计算出哈希值
- 通过哈希算法计算出的哈希值相同会导致哈希冲突
- 由于hash的原理是将输入空间的值映射至hash空间内,而hash值的空间远小于输入的空间,根据抽屉原理,一定会存在不同的输入被映射成相同输出的情况
- 抽屉原理:桌上有十个苹果,要把这十个苹果放到九个抽屉里,无论怎样放,我们会发现至少会有一个抽屉里面放不少于两个苹果(哈希冲突)
二、HashMap集合简介
-
HashMap基于哈希表的Map接口实现,用来存放K-V键值对
-
HashMap的实现不是同步的,这意味着它不是线程安全的
-
HashMap的key、value都可以为null
-
HashMap中的映射不是有序的
-
查询、修改、添加的速度都很快,能达到O(1)的平均时间复杂度
-
JDK8之前 HashMap 由 数组+链表 组成,数组是 HashMap 的主体,链表主要是为了解决哈希冲突
- 哈希冲突是指两个key调用hashCode方法计算出的哈希值一致导致计算的数组索引值相同
-
JDK8以后 HashMap 由 数组+链表+红黑树 组成
- 当链表长度大于等于阈值(默认为 8)并且数组的容量大于64时,链表的所有数据改为使用红黑树存储
- 注意是数组的容量,而不是数组元素实际被占用的个数
- 数组的容量可以理解为数组的长度,不包括链表、红黑树的节点个数
- 当红黑树的节点个数小于等于6时,会将红黑树还原成链表
- 当链表长度大于等于阈值(默认为 8)并且数组的容量大于64时,链表的所有数据改为使用红黑树存储
-
将链表转换成红黑树前会判断,如果阈值大于等于8,但是数组长度小于64,并不会将链表变为红黑树,而是选择进行数组扩容
- 默认的扩容方式为:新创建的数组容量变成原来的2倍,对原来的数据重新计算索引值,并复制过来
-
HashMap结构图如下所示
- 图中左边竖着的是 HashMap 的数组结构,数组的元素可能是单个 Node,也可能是个链表,也可能是个红黑树,比如数组下标索引为 2 的位置就是一个链表,下标索引为 9 的位置对应的就是红黑树
三、HashMap存储数据的过程
3.1 过程分析
- 调用空参构造器
HashMap<String, Integer> map = new HashMap<>();
创建HashMap集合对象- 如果是JDK8之前,调用构造方法时创建一个长度是16的 Entry[] table 用来存储键值对
- 如果是JDK8之后,调用构造方法时不会创建数组,而是在第一次调用
put()
方法时创建长度为16的 Node[] table 用来存储键值对- Entry<K,V>和 Node<K,V> 都是实现了 Map.Entry<K,V> 接口 ,负责存储键值对
- 向HashMap中添加K-V键值对时,根据K调用所在String类重写的
hashCode()
方法计算出哈希值,对此哈希值进行修改之后,结合数组长度采用 hash值 & (数组长度 - 1) 计算出向Node数组中存储数据的索引值- 如果索引位置上为空,则将键值对插入,添加成功
- 如果索引位置上不为空,则首先需要判断插入元素的hash值与此索引位置上的元素的hash值以及key.equals方法是否返回true,如果都为true,则需要将插入元素的新value替换索引位置上的元素的旧value并返回旧value,如果不是都为true,则继续向下执行
- 如果索引位置不为空,说明此时数组位置上可能已经有链表或红黑树,如果是红黑树,则将此元素按照红黑树的规则添加到红黑树
- 如果是链表则按顺序遍历链表比较K与链表所有节点的哈希值(不同的哈希值可能产生相同的索引值)
- 如果K的哈希值与某个节点的哈希值相同,此时发生了哈希碰撞
- 调用K所在类的
equals()
方法进行key值的比较(不同的数据可能会产生相同的哈希值)- 如果equals()方法返回true,使用新的value替换旧的value,并返回旧的value
- 如果equals()方法返回fasle,继续与下一个节点比较hash值
- 调用K所在类的
- 如果哈希值都不相同,则在链表尾部上划出一个新节点,存放K-V键值对,然后判断是否需要树化
3.2 常见面试题
-
哈希表底层采用何种算法计算索引值?还有哪些方法可以计算索引值?
- 哈希表采用调用K的hashCode()方法计算出的哈希值结合 数组长度-1 进行 按位与(&) 计算索引值
- 还可以采用取余法计算索引值
- 比如哈希值为10,数组长度为8,计算 10 % 8 值为2,则索引值为2
- 取余法效率比较低,位运算效率比较高
-
当两个对象的hashCode相等时会怎么样?
- 会产生哈希碰撞,若key值内容相同则替换旧的value,不然链接到链表后面,链表长度超过阈值8且数组长度大于64时就转换为红黑树
-
何时发生哈希碰撞?什么是哈希碰撞?如何解决哈希碰撞?
- 只要两个元素的key计算的哈希值相同就会发生哈希碰撞
- jdk8之前使用链表解决哈希碰撞
- jdk8之后使用链表+红黑树解决哈希碰撞
-
如果两个键的hashcode相同,如何存储键值对?
- hashcode相同,通过
equals()
比较内容是否相同- 相同:新的value覆盖之前的value
- 不相同:新的键值对添加到哈希表中
- hashcode相同,通过
-
为什么JDK8中引入红黑树?
-
当频繁发生哈希碰撞,即一个链表中的元素较多时,会导致链表过长,通过key值依次查找的效率较低,时间复杂度为O(n)
-
而红黑树的查找速度很快,时间复杂度为O(logn),所以引入红黑树的原因是为了提高查找效率
-
添加数据的流程图如下:
-
需要注意的几点
- 如果是第一次添加元素,此时数组为空,需要先将数组扩容为16
-
向链表尾部添加元素时,首先要判断是否需要树化,若需要,则转换成红黑树后插入元素
- 三种方式(直接插入数组位置、红黑树插入、链表插入)添加元素后,都需要判断
++size
是否大于扩容阈值,如果大于,则需要将数组扩容
- 三种方式(直接插入数组位置、红黑树插入、链表插入)添加元素后,都需要判断
-
size 表示 HashMap 中 K-V 的实际数量 ,注意这个不等于数组的容量(长度) capacity
-
size不仅仅表示数组上的节点数量,链表、红黑树的节点都算作size的一部分
-
threshold (扩容临界值) = capacity (容量) * loadFactor (加载因子),当 size 超过这个临界值时就进行扩容 resize,扩容后的 HashMap 容量是之前容量的两倍
四、HashMap继承关系
HashMap继承关系如下图所示:
说明:
- Cloneable 接口,表示可以克隆,克隆指的是创建并返回HashMap对象的一个副本
- Serializable 序列化接口,HashMap对象可以被序列化写在持久设备上和反序列化
- AbstractMap 父类以最大限度地减少实现Map接口所需的工作
补充:
通过上述继承关系发现一个很奇怪的现象, 就是HashMap已经继承了AbstractMap而AbstractMap类实现了Map接口,那为什么HashMap还要实现Map接口呢?(同样在ArrayList中LinkedList中都是这种结构)
据java集合框架的创始人Josh Bloch描述,这样的写法是一个失误。在java集合框架中,类似这样的写法很多,最开始写java集合框架的时候,他认为这样写,在某些地方可能是有价值的,直到他意识到错了。显然JDK的维护者后来不认为这个小小的失误值得去修改,所以就这样存在下来了
五、HashMap集合成员
5.1 成员变量
- 序列化版本号
private static final long serialVersionUID = 362498820763181265L;
- 数组的初始化容量(数组的容量必须是2的n次幂)
//默认的初始容量是16 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; //1<<4相当于1*2的4次方
问题1: 为什么数组的容量必须是2的n次幂?
解答:
HashMap重载的构造方法还可以指定数组的初始化容量大小
HashMap(int initialCapacity) //构造一个带指定初始容量和默认加载因子(0.75)的空HashMap
根据上述讲解已经知道,当向HashMap中添加一个元素的时候,需要根据key的hash值,去确定其在数组中的具体位置
HashMap为了存取高效,要尽量较少碰撞,就是要尽量把数据分配均匀,每个链表长度大致相同
为了满足数据均匀分配,HashMap采用了一个算法,即 索引位置 = hash % length(hash暂时理解为哈希值,length表示数组的长度),计算机中直接求余效率不如位移运算,所以源码中做了优化,使用 索引位置= hash & (length-1)
而 hash%length 等于 hash&(length-1) 的前提是length是2的n次幂
问题2:为什么数组长度是2的n次幂能分配均匀、减少碰撞呢?
2的n次幂 对应的二进制是1后面有n个0,2的n次幂-1 对应的二进制是n个1
举例1:
假设数组长度length为8的时候,即2的n次幂,对应的二进制是1000 计算 length-1 如下: 1000 - 1 --------- 111 1.当哈希值为3时,计算 hash&(length-1) 如下: 00000011 3 hash & 00000111 7 length-1 ------------------------- 00000011 3 数组下标 2.当哈希值为2时,计算 hash&(length-1) 如下: 00000010 2 hash & 00000111 7 length-1 ------------------------- 00000010 2 数组下标 //结论:当数组长度为2的n次幂时,可以减少碰撞(以上两个计算的索引位置都不相同)
因此,HashMap 容量为2次幂的原因,就是为了数据的的均匀分布,减少hash冲突,毕竟hash冲突越大,代表数组中一个链的长度越大,这样的话会降低hashmap的性能
举例2:
当数组长度为9时(不是2的n次幂),9-1值为8,对应的二进制为1000,后三位都是0,由于是与运算,所以无论hash值的后三位怎样变化,计算出的值后三位一定是0,比如hash分别为2或3时,计算出的索引值都为0,发生了哈希碰撞 //结论:要求数组长度为2的n次幂,目的是为了进行减一操作之后将二进制的后几位全部变成1,由于是与运算,后几位的与运算结果取决于hash值的大小,减少了哈希碰撞
注意: 如果不考虑效率直接求余即可(就不需要要求数组长度必须是2的n次方了)
问题3:如果指定的数组初始化容量不是2的n次幂呢?比如10?
解答:
HashMap会通过 位移运算 和 或运算 将指定的容量修改为大于等于指定容量的离其最近的2次幂的数
源码分析如下:
//创建HashMap集合的对象,指定数组长度是10,不是2次幂 HashMap hashMap = new HashMap(10); //被调用的构造函数 //参数值为10 public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); //参数1值为10,参数2值为0.75 } //被调用的构造函数 //参数1为10,参数2为0.75 public HashMap(int initialCapacity, float loadFactor) { if (initialCapacity < 0) throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity); if (initialCapacity > MAXIMUM_CAPACITY) //MAXIMUM_CAPACITY为2^30 initialCapacity = MAXIMUM_CAPACITY; if (loadFactor <= 0 || Float.isNaN(loadFactor)) throw new IllegalArgumentException("Illegal load factor: " + loadFactor); //三个if语句都没有执行 this.loadFactor = loadFactor; this.threshold = tableSizeFor(initialCapacity); //调用tableSizeFor方法,参数值为10,将10修改为离10最近的大于等于10的2次幂的数,即16 } //被调用的tableSizeFor方法 //参数值为10 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; }
对 tableSizeFor()
方法的分析:
-
首先,为什么要对cap做减1操作?
int n = cap - 1;
- 如果cap已经是2的幂, 但没有执行这个减1操作,则执行完后面的几条无符号右移操作之后,容量将是这个cap的2倍
- 但是直接使用给定的2的次幂的数即可,没有必要变成2倍,造成空间浪费
-
如果经过了
cap-1
之后,n变成了0,则经过后面的几次无符号右移依然是0,最后返回的值为1(最后有个n+1的操作),但这里只讨论n不等于0的情况 -
对右移运算的分析
第一次右移:
int n = cap - 1; //cap=10 n=9 n |= n >>> 1; 00000000 00000000 00000000 00001001 //9 (整数4个字节,每个字节8位,故32位) | 00000000 00000000 00000000 00000100 //9右移之后变为4 -------------------------------------------------------- 00000000 00000000 00000000 00001101 //按位异或之后是13
由于n不等于0,则n的二进制中总会有一位bit为1,这时考虑最高位的1。通过无符号右移1位,则将最高位的1右移了1位,再做或操作,使得n的二进制中与最高位的1紧邻的右边一位也为1,如:
00000000 00000000 00000000 00001001 变成了 00000000 00000000 00000000 00001101 //就是把倒数第三位变成了1
第二次右移:
n |= n >>> 2; //n通过第一次右移变为了:n=13 00000000 00000000 00000000 00001101 //13 | 00000000 00000000 00000000 00000011 //13右移之后变为3 -------------------------------------------------------- 00000000 00000000 00000000 00001111 //按位异或之后是15
n经过无符号右移两位,会将最高位两个连续的1右移两位,然后再与原来的n做或操作,这样n的二进制的高位中会有4个连续的1,如:
00000000 00000000 00000000 00001101 变成了 00000000 00000000 00000000 00001111
第三次右移:
n |= n >>> 4; //n通过第一、二次右移变为了:n=15 00000000 00000000 00000000 00001111 //15 | 00000000 00000000 00000000 00000000 //15右移之后变为0 -------------------------------------------------------- 00000000 00000000 00000000 00001111 //按位异或之后是15
这次把已经有的高位中的连续的4个1,右移4位,再做或运算,这样n的二进制表示的高位中正常会有8个连续的1,但此时已经全部移位成功,故只有四个连续的1
15+1值为16,故返回值为16,也就是说指定的容量10被修改成了16
注意:
-
容量最大也就是32bit的正数,因此最后
n |= n >>> 16
,容量最多也就32个1(但是这已经是负数了) -
要想出现32个1,则32bit的最高位必须是1,即
initialCapacity
为2^31 -
但在执行
tableSizeFor
之前,会对initialCapacity
做判断,如果大于MAXIMUM_CAPACITY(2 ^ 30)
,则取MAXIMUM_CAPACITY
,由于是2的n次幂,则经过tableSizeFor
之后,容量还是2^30 -
tableSizeFor()
方法返回的值被赋值给了 threshold(扩容阈值)this.threshold = tableSizeFor(initialCapacity); //此时返回值是16
你可能会有疑惑,为什么tableSizeFor的结果要赋值给扩容阈值呢?稍后会解释
-
-
默认的负载因子,默认值是0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
- 数组最大容量
//数组最大容量的上限是:2的30次幂 static final int MAXIMUM_CAPACITY = 1 << 30;
- 链表转换成红黑树的阈值(1.8新增)
//当链表的节点个数超过8且数组的长度超过64时这个链表会转换成红黑树 static final int TREEIFY_THRESHOLD = 8;
问题1:为什么HashMap桶中节点个数超过8才转换成红黑树?
- 桶指的是数组的某一个位置,桶中可以是链表或红黑树
解答:
-
红黑树的节点占用空间是链表节点的两倍
-
链表长度达到8会转换成红黑树,当红黑树节点减小到6就会还原成链表
-
链表查询的时间复杂度是 O (n),红黑树的查询复杂度是 O (logn)
-
在链表数据不多的时候,使用链表进行遍历也比较快,只有当链表数据比较多的时候,才会转化成红黑树
-
不在一开始就使用红黑树的原因是为了节省空间
-
根据泊松分布公式计算,由于节点均匀分布,链表节点个数达到8的可能性非常低,所以使用8作为阈值,链表节点个数小于8既可以保证查询速度,又不会过早转换成红黑树占用更多的存储空间
-
正常写代码使用 HashMap 时,几乎不会碰到链表转化成红黑树的情况,毕竟概率只有千万分之一
- 红黑树还原成链表的阈值
//当红黑树的节点个数小于6时会还原成链表 static final int UNTREEIFY_THRESHOLD = 6;
- 转换成红黑树的数组容量的阈值
//当链表长度大于8且数组的长度大于64时,链表的所有数据改为使用红黑树存储 static final int MIN_TREEIFY_CAPACITY = 64; //为了避免进行扩容、树形化选择的冲突,这个值不能小于 4 * TREEIFY_THRESHOLD (8)
- 存储元素的数组(容量必须是2的n次幂)
transient Node<K,V>[] table;
- 存放键值对的Set集合
transient Set<Map.Entry<K,V>> entrySet;
- 数组中(包括链表、红黑树)实际存放元素的个数(不等于数组的容量)
transient int size; //可能不准(因为线程不安全,故当你拿到这个值的时候,可能又发生了变化)
- 记录HashMap的修改次数
//每次添加、扩容等修改HashMap结构时会将其加1 transient int modCount;
- 扩容临界值
//数组中实际元素的个数超过扩容临界值时,会进行扩容 int threshold; //(容量*负载因子) 默认是16*0.75=12
- 加载因子
//默认值是0.75 final float loadFactor;
-
加载因子表示HashMap的疏密程度
-
计算HashMap的实时加载因子的方法为:size / capacity
-
加载因子值为0.75f是官方给出的一个比较好的临界值
-
当数组里面容纳的元素已经达到数组长度的75%时,表示HashMap太挤了,需要扩容,而扩容这个过程涉及到rehash、复制数据等操作,非常消耗性能,所以开发中尽量减少扩容的次数,可以通过创建HashMap集合对象时指定初始容量来尽量避免扩容
//指定初始化容量和加载因子 HashMap(int initialCapacity, float loadFactor);
问题2:为什么加载因子的值设置为0.75f?
解答:
loadFactor越趋近于1,那么数组中存放的数据(entry)也就越多,也就越密,也就是会让链表的长度增加,loadFactor越小,也就是趋近于0,数组中存放的数据(entry)也就越少,也就越稀疏
举例:
加载因子是0.4, 那么16*0.4--->6 如果数组中满6个空间就扩容会造成数组利用率太低了 加载因子是0.9, 那么16*0.9--->14 那么这样就会导致链表有点多了, 导致查找元素效率低
- 所以既兼顾数组利用率又考虑链表节点个数不要太多,经过大量测试加载因子为0.75是最佳方案
- 链表的节点
static class Node<K,V> implements Map.Entry<K,V> { final int hash; final K key; V value; Node<K,V> next; //.... }
- 红黑树的节点
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> { TreeNode<K,V> parent; TreeNode<K,V> left; TreeNode<K,V> right; TreeNode<K,V> prev; boolean red; //.... }
5.2 构造方法
- 构造一个空的HashMap,默认初始容量(16)和 默认负载因子(0.75)
public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; //JDK8之后在put方法时才会创建数组 //JDK8之前构造函数中创建数组 //此处为JDK8 }
- 构造一个具有指定初始容量和默认负载因子(0.75)的HashMap
public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); //调用两个参数的构造方法 }
- 在《阿里巴巴Java开发手册》中建议,初始化容量 = 要存储的键值对个数 / 0.75F + 1.0F
- 构造一个具有指定的初始容量和负载因子的HashMap
public HashMap(int initialCapacity, float loadFactor) { //判断初始化容量initialCapacity是否小于0 if (initialCapacity < 0) //如果小于0,则抛出非法的参数异常IllegalArgumentException throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity); //如果初始数组容量超过MAXIMUM_CAPACITY,会将MAXIMUM_CAPACITY赋值给initialCapacity if (initialCapacity > MAXIMUM_CAPACITY) initialCapacity = MAXIMUM_CAPACITY; //判断负载因子loadFactor是否小于等于0或者是否是一个非数值 if (loadFactor <= 0 || Float.isNaN(loadFactor)) //如果满足上述其中之一,则抛出非法的参数异常IllegalArgumentException throw new IllegalArgumentException("Illegal load factor: " + loadFactor); //将指定的加载因子赋值给HashMap成员变量 this.loadFactor = loadFactor; //调用tableSizeFor方法将数组容量修改为2的次幂,之前已经讲过 //tableSizeFor方法返回值赋值给数组扩容阈值 this.threshold = tableSizeFor(initialCapacity); }
注意:
对于 this.threshold = tableSizeFor(initialCapacity);
将修改后的数组容量赋值给数组扩容阈值,可能你认为如果要满足扩容阈值的定义,写法应该是 this.threshold = tableSizeFor(initialCapacity) * this.loadFactor;
;但是,需要注意所有的构造器中都没有初始化 table 数组,在 put()
方法创建 table 时,会重新计算 threshold 的值
- 包含另一个 Map 的构造函数
//构造一个映射关系与指定Map相同的新HashMap public HashMap(Map<? extends K, ? extends V> m) { this.loadFactor = DEFAULT_LOAD_FACTOR; //使用默认的负载因子0.75 putMapEntries(m, false); //调用putMapEntries方法 }
对 putMapEntries()
方法的讲解:
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) { //m表示指定的参数Map集合,evict值为false //获取参数Map的实际容量 int s = m.size(); //如果参数Map的实际容量小于等于0,则什么都不做 if (s > 0) { //如果当前HashMap的数组还没有创建,则需要创建一个新的数组 if (table == null) { //得到新创建数组的容量大小,实际元素个数 / 加载因子 == 数组容量 //为什么加1,之后会讲解 float ft = ((float)s / loadFactor) + 1.0F; //判断数组容量和最大数组容量的大小 int t = ((ft < (float)MAXIMUM_CAPACITY) ? (int)ft : MAXIMUM_CAPACITY); //如果数组容量大于当前HashMap的扩容阈值,则调用tableSizeFor将数组容量修改为2的次幂 //扩容阈值为int型,默认值为0 if (t > threshold) //赋值给扩容阈值 threshold = tableSizeFor(t); } //如果参数Map的实际容量大于当前HashMap的扩容阈值,需要对HashMap进行扩容 else if (s > threshold) resize(); //能执行到这里,说明HashMap有数组,且数组容量也足够 //将参数Map中的所有键值对全部添加到HashMap的数组中 for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) { K key = e.getKey(); V value = e.getValue(); //之后会讲解putVal方法 putVal(hash(key), key, value, false, evict); } } }
问题:为什么 float ft = ((float)s / loadFactor) + 1.0F;
要执行+1操作呢?
解答:
- 是为了获取更大的数组容量,从而减少扩容的次数
举例:
- 假设参数Map的实际元素个数是6,那么 6 / 0.75 是 8,是2的n次幂,那么新的数组大小就是 8 了,但是这样会导致在存储元素的时候非常容易容量不够,还得继续扩容,那么性能就会降低
- 而如果+1之后,经过
tableSizeFor()
方法数组长度直接变为16,可以减少数组扩容的次数
5.3 成员方法
5.3.1 增加方法
put()
方法如下:
public V put(K key, V value) { return putVal(hash(key), key, value, false, true); //首先调用hash方法,将key的哈希值进行修改 //然后调用putVal方法 }
hash()
方法如下:
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); //1. key值可以为null,哈希值为0,也就是说取值为null的元素,始终位于数组索引为0的位置 //2. 如果key不等于null,首先计算出key的hashCode赋值给h, 然后h和h无符号右移16位后的二进制进行按位异或得到最后的hash值 //3. 也就是说,之前所分析的计算数组的索引值时,索引位置 = hash % length,hash并不是key的哈希值,而是经过异或运算后的哈希值 }
对 (h = key.hashCode()) ^ (h >>> 16)
及 hash % length
过程的分析:
-
key.hashCode()
计算出key的哈希值,假设此时的哈希值为1111 1111 1111 1111 1111 0000 1110 1010 -
假设此时的数组长度length值为16,则 length-1 为1111
-
运算过程如下:
-
计算出所在数组的索引值为5
-
总的来说就是高16 bit 不变,低 16 bit 和 高16 bit 做了一个异或,为什么要这样做呢?
假如直接使用哈希值与数组长度-1做与运算: hashCode()值: 1111 1111 1111 1111 1111 0000 1110 1010 & length-1,即15: 0000 0000 0000 0000 0000 0000 0000 1111 ------------------------------------------------------------------- 0000 0000 0000 0000 0000 0000 0000 1010 ----》10作为索引 //可以发现,如果数组的长度较小,进行与运算仅仅是低位进行了与运算,高位全部都是0,如果下一个key的哈希值低位没有变,仅高位发生了变化,就会导致计算出的索引值与上述一致,如下: 比如新插入的哈希值,高位的第6、7位发生了改变: 1111 1001 1111 1111 1111 0000 1110 1010 & 0000 0000 0000 0000 0000 0000 0000 1111 ---------------------------------------------------------- 0000 0000 0000 0000 0000 0000 0000 1010 ----》10作为索引,发生哈希冲突 //所以把高低位都利用起来,是为了避免哈希冲突,以防出现仅高位变化时得到的索引值相同的情况
putVal()
方法如下:
//用户调用put方法,put方法调用putVal方法 public V put(K key, V value) { return putVal(hash(key), key, value, false, true); } /** * 参数说明: * hash:hash方法中经过右移+异或操作后的哈希值 * key:键值对的K * value:键值对的V * onlyIfAbsent:取值为false,表示将要用新值替换此节点的旧值 * evict:取值为true,表示 */ final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; //判断数组是否为空 if ((tab = table) == null || (n = tab.length) == 0) //如果为空,调用resize方法创建数组,默认长度为16 n = (tab = resize()).length; //判断Key对应数组索引位置上是否为空,如果为空,则创建新的节点并赋值给对应的数组位置 //根据之前讲述 i = (n - 1) & hash 用于计算key对应数组的索引值 if ((p = tab[i = (n - 1) & hash]) == null) //newNode方法第四个参数表示next指针,指向链表的下一个节点 tab[i] = newNode(hash, key, value, null); //如果Key对应的数组位置不为空,说明位置上可能有链表或红黑树 else { Node<K,V> e; K k; //p表示此时数组位置上已经存在的节点 //比较二者的hash值,key的地址值,key的实际值 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) //执行到此,说明将要使用新值替换旧值,p表示此时数组位置上已经存在的节点 e = p; //执行到此说明要在链表或者红黑树中进行比较 //判断此时链表是否已经树化,如果树化,将要使用红黑树的方式插入节点 else if (p instanceof TreeNode) e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); //执行到此说明要向链表中插入节点 else { //循环遍历链表所有的节点 for (int binCount = 0; ; ++binCount) { //如果此时已经遍历到最后一个节点,创建新节点插入到链表尾部 if ((e = p.next) == null) { p.next = newNode(hash, key, value, null); //如果链表节点个数大于等于8,可能会进行树化 if (binCount >= TREEIFY_THRESHOLD - 1) treeifyBin(tab, hash); //树化方法之后会讲解 break; } //不管有没有执行第一个if的代码块,但第一个if的条件一定会执行 //此时e表示链表的下一个节点 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) //要使用新值替换旧值 break; p = e; //移动到下一个节点 } } //如果e!=null,将要使用新值替换旧值,并返回旧值 if (e != null) { V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) e.value = value; afterNodeAccess(e); return oldValue; } } //每次调用put都会使计数器加一 ++modCount; //如果不是替换旧值,那么就需要将size+1 if (++size > threshold) resize(); afterNodeInsertion(evict); return null; }
5.3.2 红黑树新增节点过程
putTreeVal()
方法过程如下:
-
从根节点开始查找,首先判断新增的节点在红黑树上是不是已经存在,判断手段有如下两种:
1.1 如果节点没有实现 Comparable 接口,使用 equals 进行判断
1.2 如果节点实现了 Comparable 接口,使用 compareTo 进行判断
-
新增的节点如果已经在红黑树上(hash值和key值都相同),直接返回,在
putVal()
方法中使用新值替换旧值;不在的话,判断新增节点是在当前节点的左边还是右边:2.1 如果新增节点的hash值小于当前节点的hash值,则在左边
2.2 如果新增节点的hash值大于当前节点的hash值,则在右边
2.3 如果找到了直接返回,在
putVal()
方法中使用新值替换旧值 -
自旋递归第 1 和 2 步,直到当前节点的左边或者右边的节点为空时,停止自旋,当前节点即为新增节点的父节点
-
把新增节点放到当前节点的左边或右边为空的地方,并与当前节点建立父子节点关系
-
进行着色和旋转,结束
5.3.3 树化方法
过程:首先将链表的所有节点全部转换成红黑树的节点并连接在一起,然后用红黑树替换链表在数组上的位置,最后对其做红黑树的平衡操作
/** * 参数说明: * tab: 数组 * hash:hash方法中经过右移+异或操作后的哈希值 */ final void treeifyBin(Node<K,V>[] tab, int hash) { int n, index; Node<K,V> e; //如果数组还没有创建,则调用resize()创建数组 //如果链表结点个数大于等于8但数组容量小于64,则调用resize()进行扩容 if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY) resize(); //执行到此说明满足链表转换成红黑树的两个条件 //当桶中有链表时,执行else if代码块 else if ((e = tab[index = (n - 1) & hash]) != null) { //hd表示红黑树的头节点,tl表示红黑树的尾节点 TreeNode<K,V> hd = null, tl = null; //将链表的每一个节点转换成红黑树的节点,存储的内容一致 do { //replacementTreeNode方法转换节点,之后讲解 TreeNode<K,V> p = replacementTreeNode(e, null); if (tl == null) hd = p; //转换后的第一个节点成为红黑树的头节点及尾节点 else { //将转换后的红黑树节点连接起来,双向连接,p表示新添加的节点,tl表示已存在的尾节点 p.prev = tl; tl.next = p; } tl = p; //新添加的节点成为尾节点 } while ((e = e.next) != null); //向下遍历链表的节点 //执行到此说明链表中的所有节点全部转换成了红黑树节点,并将节点连接在了一起 //但此时仅仅是将节点转换成功,还没有将红黑树连接在数组的位置上且没有平衡 //if条件中将上述创建的红黑树连接在了数组对应的位置上并平衡红黑树,替换了链表 if ((tab[index] = hd) != null) //将创建出的红黑树旋转使其平衡 hd.treeify(tab); } }
replacementTreeNode()
方法如下:
TreeNode<K,V> replacementTreeNode(Node<K,V> p, Node<K,V> next) { return new TreeNode<>(p.hash, p.key, p.value, next); }
5.3.4 扩容方法
如果想要理解扩容方法的源码,首先要明确两个知识点:
-
什么时候才需要扩容?
- size超过扩容阈值将进行扩容
- 链表节点个数大于等于8,但数组长度小于64,会进行扩容
-
扩容的过程是怎样的?
-
默认的扩容方式是翻倍,对应到二进制上,将原来最高位的1向前移动了1位,长度减一之后对应的二进制比原来向前多了一位1
-
还需要通过调用 hash & (n-1) 重新计算所有节点(包括链表上的)的新索引值,如下图所示:
-
从上图可以看出,计算新的索引值时,只需要关注数组容量向前移动的那一位(1)对应的key的哈希值的取值,因为是与操作,如果key的哈希值此位为0,那么得到的索引值仍然与未扩容时一致;如果key的哈希值此位为1,与操作之后,得到的索引值会向前多一位1,即 “原位置+旧容量” ,如下图所示:
-
这样做的好处
- 省去了重新计算hash值的时间
- 新增的 1bit 对应的key的哈希值是0还是1可以认为是随机的,可以均匀的把之前的冲突节点分散到新的桶中
-
用一张图说明新索引值的计算情况
- 可以看出,如果桶的位置是链表,会将原链表分成两个链表,分别起名为高位链表和低位链表,如果节点属于低位链表,则新索引值与原来相同;如果节点属于高位链表,则新索引值为原位置+旧容量,一个节点属于哪种链表取决于之前所述的高位的那个bit取值是1还是0
- 一个桶处理完再处理下一个桶
- 两层循环,第一层循环遍历数组的位置,第二层循环遍历数组位置的链表或红黑树
-
resize()
方法源码如下:
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) { //如果旧数组的容量大于等于最大容量,则永远不进行扩容 if (oldCap >= MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return oldTab; } //将旧数组容量乘2成为新数组的容量 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) //如果旧数组的容量小于16,则不会执行,也就是不会给newThr赋值,值为0 //满足条件时将扩容阈值翻倍成为新的扩容阈值 newThr = oldThr << 1; } //旧数组为空,但扩容阈值大于0,则将扩容阈值赋值给新数组的容量 //注意,新的扩容阈值没有被赋值 else if (oldThr > 0) newCap = oldThr; //如果旧数组、扩容阈值均为空,则新数组使用默认值 else { newCap = DEFAULT_INITIAL_CAPACITY; newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); } //当没有执行上述第一个else if,则newThr == 0 //当旧数组为空执行了第二个else if,newThr也没有赋值,值为0 //需要计算扩容阈值 if (newThr == 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; //取出旧数组的节点,赋值给e if ((e = oldTab[j]) != null) { oldTab[j] = null; //将旧数组对应位置设置为null,方便垃圾回收 //如果旧数组桶的位置只有一个节点 if (e.next == null) //将此节点插入到新数组对应的索引位置上 newTab[e.hash & (newCap - 1)] = e; //如果旧数组桶的位置已经成为了红黑树,调用split将红黑树拆开 else if (e instanceof TreeNode) ((TreeNode<K,V>)e).split(this, newTab, j, oldCap); //执行到此说明桶的位置是链表 else { //低位链表 Node<K,V> loHead = null, loTail = null; //高位链表 Node<K,V> hiHead = null, hiTail = null; //记录链表的下一个节点 Node<K,V> next; do { next = e.next; //记录链表的下一个节点 //if条件没有使用长度减一去执行与操作,此时旧容量的最高位一定是1 //这样做会提取出节点的哈希值高位是1还是0 //如果是0,此节点属于低位链表 if ((e.hash & oldCap) == 0) { if (loTail == null) loHead = e; else loTail.next = e; loTail = e; } //如果高位是1,此节点属于高位链表 else { 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; //返回新创建的并将原节点移动成功的扩容后的数组 }
5.3.5 红黑树的拆分过程
split()
方法过程如下:
处理的方式与链表类似,也是将节点分成高位链表和低位链表,如果节点属于低位链表,则新索引值与原来相同;如果节点属于高位链表,则新索引值为原位置+旧容量,然后移动至新数组的位置,不同之处在于:
- 拆分出的高低位链表需要判断链表的长度
- 如果长度小于等于6,直接将TreeNode转换成普通的Node链表,然后放到扩容后的新数组中
- 如果长度大于6,需要把链表升级成红黑树(重建红黑树)
5.3.6 删除方法
删除时首先找到元素的数组位置,如果对应的索引位置就是要删除的节点,则删除并返回;如果是链表就遍历链表找到元素之后删除并返回,如果是红黑树就遍历红黑树找到之后删除并返回,红黑树节点个数小于6的时候要转成链表
remove()
方法源码如下:
public V remove(Object key) { Node<K,V> e; //首先调用hash方法得到key对应的修改后的哈希值,与put方法时一致 //再调用removeNode方法删除并返回对应的节点 return (e = removeNode(hash(key), key, null, false, true)) == null ? null : e.value; }
removeNode()
方法源码如下:
final Node<K,V> removeNode(int hash, Object key, Object value, boolean matchValue, boolean movable) { Node<K,V>[] tab; Node<K,V> p; int n, index; //如果数组存在,且数组对应位置不为空 if ((tab = table) != null && (n = tab.length) > 0 && (p = tab[index = (n - 1) & hash]) != null) { //node表示将要被删除的节点 Node<K,V> node = null, e; K k; V v; //如果数组对应位置就是要删除的节点 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) node = p; //将被删除的节点赋值给node //如果数组对应位置不是要被删除的节点,判断是否有链表或红黑树,如果有,则遍历链表或红黑树找到要被删除的节点并赋值给node //e表示数组位置节点对应的链表或红黑树的后一个节点 else if ((e = p.next) != null) { //如果存在红黑树 if (p instanceof TreeNode) //调用getTreeNode方法遍历红黑树找到对应的节点赋值给node node = ((TreeNode<K,V>)p).getTreeNode(hash, key); //如果存在链表,遍历链表找到节点,返回给node else { do { if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) { node = e; break; } p = e; } while ((e = e.next) != null); //向下遍历链表 } } //如果存在要被删除的节点 if (node != null && (!matchValue || (v = node.value) == value || (value != null && value.equals(v)))) { //如果删除的是红黑树的节点,则调用removeTreeNode方法按照红黑树的规则删除节点 if (node instanceof TreeNode) ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable); //如果删除的是数组上的节点 else if (node == p) tab[index] = node.next; //如果删除的是链表上的节点 else p.next = node.next; ++modCount; //计数器加1 --size; //size-1 afterNodeRemoval(node); return node; //返回被删除的节点 } } //如果数组为空或者对应的数组位置为空,则返回null return null; }
5.3.7 查询方法
查询时首先找到元素的数组位置,如果对应的索引位置就是要查找的节点,则返回;如果是链表就遍历链表找到元素之后返回,如果是红黑树就遍历红黑树找到之后返回
get()
方法源码如下:
public V get(Object key) { Node<K,V> e; //首先调用hash方法得到key对应的修改后的哈希值,与put方法时一致 //再调用getNode方法得到对应的值 return (e = getNode(hash(key), key)) == null ? null : e.value; }
getNode()
方法源码如下:
final Node<K,V> getNode(int hash, Object key) { Node<K,V>[] tab; Node<K,V> first, e; int n; K k; //如果数组存在,且数组对应位置不为空 if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) { //如果对应的数组位置就是要查找的节点,返回节点 if (first.hash == hash && ((k = first.key) == key || (key != null && key.equals(k)))) return first; //如果数组对应位置不是查找的节点,判断是否有链表或红黑树,如果有,则遍历链表或红黑树找到要查找的节点并返回 //e表示数组位置节点对应的链表或红黑树的后一个节点 if ((e = first.next) != null) { //如果是红黑树 if (first instanceof TreeNode) return ((TreeNode<K,V>)first).getTreeNode(hash, key); //如果是链表 do { if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) return e; } while ((e = e.next) != null); //向下遍历链表 } } //如果不存在要找的节点,返回null return null; }
5.3.8 红黑树查找节点过程
- 从根节点开始递归查找
- 比较查找节点和当前节点的哈希值,如果哈希值相同且key相同,则找到并返回当前节点;如果哈希值不相同,则根据比较结果判断在左子树还是右子树进行查找
- 判断查找节点在第 2 步有无定位节点,有的话返回,没有的话重复 2,3 两步
- 一直自旋到找到节点位置为止,如果没有找到则返回null
六、HashMap时间复杂度分析
查询和插入的时间复杂度均为O(1)
原因:
- 首先查找数组的位置,利用数组的特性,时间复杂度为O(1)
- 桶的位置可能是链表或红黑树
- 如果是链表,个数不超过8个,认为时间复杂度也是O(1)
- 根据泊松分布,成为红黑树的概率只有千万分之一,所以认为时间复杂度仍然是O(1)
- 插入的时间复杂度同理
这篇关于HashMap源码分析 + 面试题,基于JDK1.8逐行分析,面试看这一篇就够了!的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!
- 2024-11-27本地多文件上传的简单教程
- 2024-11-27低代码开发:初学者的简单教程
- 2024-11-27如何轻松掌握拖动排序功能
- 2024-11-27JWT入门教程:从零开始理解与实现
- 2024-11-27安能物流 All in TiDB 背后的故事与成果
- 2024-11-27低代码开发入门教程:轻松上手指南
- 2024-11-27如何轻松入门低代码应用开发
- 2024-11-27ESLint开发入门教程:从零开始使用ESLint
- 2024-11-27Npm 发布和配置入门指南
- 2024-11-27低代码应用课程:新手入门指南