HsahMap的面试详解及源码分析
本次接着上篇说jdk 中的HashMap,这个是一个比较复杂的重点,因为看懂了这个,后面Redis 的数据结构基本上就懂一半,而且之后所有涉及hash 表的逻辑都可以套用,最主要的就是这玩意儿面试问jdk 基本都要被问到。本次我会和结合hash表、hash碰撞的解决来聊一下hashMap 的源码,同时会将jdk1.7 和1.8 之间hashMap 区别更新说一下,至于1.8 扩容出现的红黑树这里会简单聊一下,具体的后面算法文章再详谈。
HashMap的存储结构哈希表
在聊hashMap 之前,还是需要先简单聊一下什么是哈希表,因为hashMap 的数据结构就是哈希表。
Hash表也称为散列表,也有直接译作哈希表,Hash表是一种根据关键字值(key - value)而直接进行访问的数据结构(这里可以看出Redis 也是这种结构)。它是通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。其数据结构图如下:
hash碰撞
那么如果多个key 通过散列函数得到同一个hash 值,这样就形成了hash 碰撞,也叫hash 冲突。这里提供两种解决方式:开放寻址法(也就叫开放定址法)、拉链法(也叫链地址法),前者是去寻找别的地址来存储数据,后置是在原地址上拉出一个链表,用链表来分别存储数据。
jdk1.7和jdk1.8之间hashMap存储结构的不同
1.8 之前hashMap 是采用数组 + 链表的形式来存储的,首先是后去key 的hash值,然后用hash 值来计算出下标索引(这里一般称为:哈希桶数组索引位置,我简称:桶的位置,后面文章也就是怎么说),在桶的位置来存储value 到数组,如果计算出相同桶的位置,就会在原本的数据上拉出一个链表,来分别存储不同的数据。注意:这里就意味着不管要存储value 还要存储hash值和key值,也就是说这里存储的是节点对象。
1.8 之后hashMap 做出了一个变化就是在原有基础上增加红黑树,因为就算有链表还是会存在链表过长,查询过慢的问题,所以加上红黑树的数据结构就是为了增加查询效率,当链表和数组过长时,就会在原本的增加节点上改为红黑树存储方式,其转换界限就是:链表长度为8同时数组长度为64,红黑树转回链表界限就是:链表长度为6。
HashMap的源码分析
简单介绍完hash 表的结构和hashMap 存储结构1.7和1.8之前的区别后,我们再详细看下hashMap 的源码。这里我们分析还是跟之前ArrayList 和LinkedList 的分析一样,从无参构造 -> 增删改查(中间会穿插扩容机制)-> 循环迭代的顺序来分析。那么现在就开始发车了,没上车的小伙伴跑起来,老司机开车,车门已焊死。
类构造器-无参构造和有参构造
还是老套路,我们先看最常用的构建方式,new HashMap<>()。
这里就调用有两个入参的有参构造方法,第一个参数是数组的初始长度为16,第二个是加载因子为0.75。
1 | static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;// aka 16 //默认 HashMap 集合初始容量为16(必须是 2 的倍数) |
那么什么是加载因子呢,我们要先记住一个公式:加载因子 * 数组长度 = 扩容界限,加载因子也就是对于扩容的一个界限定义而已。
至于为什么是0.75,很简答因为java 的规定啊,哈哈哈,不过一定要较真的话,可以想一下,如果加载因子是1的话,那么扩容的界限就是数组长度,换句话说只有当数组填满的时候,才能扩容,这样就会很影响查询效率,因为有hash冲突的情况下,同一个下标还会拉出一个链表,这样就导致了填满数组需要的数据可能要大于数组本身的大小,这样一来查询的是就会大打折扣。但是如果加载因子是0.5的话,那就是数组不过半就开始扩容了,频繁的扩容会消耗资源。加载因子是0.75是反复衡量的结果,不多不少。
说了无参构造,那么说说有参构造。
有参构造直接看两个入参的,这两个入参的是传入了数组初始长度和加载因子,上面的无参构造也是调用的这个。这个构造方法主要做的就是为加载因子loadFactor 属性和threshold 数组的初始长度赋值。
1 | public HashMap(int initialCapacity, float loadFactor) { |
这里有一点需要注意:数组的初始长度一定是2的倍数,如果传入的不是,那么会自动找到其入参最近的2N次方赋值(不是2的倍数,之前我就搞错了这一点),比如入参的初始长度为9,真正的数组长度赋值为16,初始长度为15,赋值还是16,因为其最近2的N次方都是16。
添加元素方法-put方法
put 方法基本上是我们集合使用最常见的方法,我们就直接看代码。注意下面我们的代码都是看1.7版本的代码,1.8版本的代码没有1.7版本的可读性高。
这里首先判断hash 表有没有初始化,如果没有则需要先初始化hash 表,调用inflateTable 方法进行初始化,然后就是判断当前key 是否为null,记住面试的时候,有会问key 值为null,可不可以存入hashMap?答案是:可以。这里调用的是putForNullKey 方法,然后就是通过hash 方法获取key 的hash值,再然后就是通过hash值计算出下标索引,最后通过下标索引获取当前位置的链表,如果没有则走下面的调用addEntry 方法,如果有,则获取到最后一个节点,其中还要判断key 值是否已经存在,存在则覆盖原值,反之还是走下面的addEntry 方法调用。
1 | transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;//HashMap底层数组,数组存储元素是Entry类型 |
总结一下上面提到的方法,我们这里一个个看,首先是inflateTable 方法初始化hash 表,putForNullKey 方法添加key 值为null 的数据,hash 方法获取key 的hash 值,indexFor 方法计算key 的hash值对应的下标索引(也就是桶的位置),然后是recordAccess 方法覆盖,也就是下面要说的修改方法,其实就是想原本节点对象中的value 属性进行覆盖,这个方法就不看了太简单了,最后就是addEntry 方法来添加元素。一共是6个方法,我们具体看其中的5个。
初始化hash表的方法-inflateTable方法
直接看代码。
这里很简单就是获取当前数组的长度,因为存在有参构造,是可以自己定义长度的,所以要判断当前入参的长度不能大于最大长度,一般情况下不可能,最大长度是一个很大的数字,正常来讲都是取当前长度。
注意这里的数组长度不再是threshold 属性的值了,虽然入参toSize 属性是threshold 的属性值,但是这里代表数组长度的是capacity 属性,roundUpToPowerOf2 方法会计算出不是2 的倍数的数据,然后强制修改为2 的倍数,所以才是上面说的数组长度一定是2 的倍数。
至于threshold 属性,这里代表的是扩容阀值,也就是之前说的数组长度 * 加载因子。
然后就是new 当前长度的Entry 对象数组。最后的initHashSeedAsNeeded 不用管。
1 | private void inflateTable(int toSize) { |
key值为null的数据添加方法-putForNullKey方法
还是直接看代码。
还是跟上面一样的套路,取hash 表中的第一个节点对象,如果不为null,则获取其链表,然后判断是否存在key 值为null 的数据,有则覆盖,无则调用addEntry 方法,注意对比一下上面的addEntry 方法的调用,我们按照入参顺序对比,其中hash 值为0,key 值为null,只有value 值有真正传参,最后就是下标索引为0。
这也就说,key 值为null 的情况,是将value 存入了hash 表的第一个节点,而且不能重复存入,只能有一个key 值为null 的情况,如果已经存在,那么就会将原本的值进行覆盖。
1 | private V putForNullKey(V value) { |
获取key的hash值方法-hash方法
这里我们先看1.7 版本的代码。
这里不管其中的计算方式,结论是一共扰动9次(5次异或计算、4次右移计算),其目的就是为了增加hash 值的离散性,充分利用数组空间,避免数组中的链表过长,而数组长度过小。
1 | final int hash(Object k) { |
1.8 版本代码是由区别的。
这里只进过了两次扰动(一次异或,一次右移),那么这里为什么不跟1.7 一样呢,因为1.8 加入了红黑树的数据结构,不再拘束于链表,对于数组空间的利用不再那么硬性要求。
1 | static final int hash(Object key) { |
计算key的hash值对应的下标索引方法-indexFor方法
这个方法的内容在1.7 版本和1.8 版本的代码都是一样的。
入参h 是key 的hash值,length 是当前数组的长度,然后两个进行 & 运算,也就意味着索引下标的计算跟数组长度是有直接关系的,其实我这里想表达的是后面扩容的时候,当数组长度变大后,如果将原本的数据再次计算下标索引的话,所对应的值会存在不同,也就是意味着扩容就是链表缩小,数组增长。
1 | static int indexFor(int h, int length) { |
添加元素-addEntry方法
再看最后一个方法,添加元素的addEntry 方法。
这里首先就是要判断当前数组是否需要扩容,其界限就是集合大小 >= 扩容阀值,并且当前,也就是当前添加操作是向链表添加数据。扩容这里我们后面说,我们先看createEntry 方法的调用。
1 | void addEntry(int hash, K key, V value, int bucketIndex) { |
这个方法也很简单。
通过下标索引获取数组中的节点元素,然后在根据节点元素创建出新的节点元素对象,然后放到数组中,集合长度累加即可。
1 | void createEntry(int hash, K key, V value, int bucketIndex) { |
我们这里要关注的就是节点元素的数据结构。
其中有四个属性当前的value、key、key 的hash 值,最后就是上一个元素对象。可以得出,当前元素是包裹着上个元素的,就像是套娃一样,和之前说的linkedList 不一样的是这里可以看出是一个单向链表,只有对应的上一个元素。比如说现在有3层套娃,数据查询应该就是3 -> 2 -> 1。
1 | final K key; |
对了这里1.8 版本的代码其实区别不大,就是换了一个名字而已,不再是Entry 对象,而是Node 对象,其余的不变。
HashMap1.7版本的数组扩容
接着上面的addEntry 方法说,当集合大小 >= 扩容阀值,并且当前下标索引指向数组位置存在数据时,那么就会将数组扩容。
其具体操作是先调用resize 方法新增一个数组,其大小为当前数组大小的2倍,然后再次计算key 的hash 值,最后再根据hash 值计算下标索引,这里的hash 方法和indexFor 方法都是跟上面一样的套路。这个判断走完之后,还是继续调用createEntry 方法。
1 | if ((size >= threshold) && (null != table[bucketIndex])) { |
注意:这里1.7 的版本代码,也是意味着1.7 是先扩容,然后再添加数据的。
再看resize 方法。
这里就是先获取当数组和其大小,然后判断当前数组大小是否已经到了最大值,如果是则将扩容阀值设置为最大值,意味着这里不能在扩容了。
如果当前数组没有到最大值,那么就再new 一个长度为当前数组大小两倍的新数组,然后调用transfer 方法将原数组的数据转移到新数组上面,这里会重新计算所有元素的下标索引。
下一步就是将新数组赋值给当前数组对象,并且将扩容阀值修改为新数组的长度 * 加载因子的结果。
1 | void resize(int newCapacity) { |
上面的方法我们还需要再看下transfer 方法,这里的目的是将原数组的数据转移到新数组上面。
这里就是循环当前数组,然后获取到每一个数组元素对象,如果存在链表,还需要将链表的每一个元素解析出来,注意这里是会调用indexFor 方法重新计算每一个元素的新下标索引,然后再存入新数组,同时当前元素的next 属性指向新数组的当前索引下的对象,也就是完成链表指向。
1 | void transfer(Entry[] newTable, boolean rehash) { |
文字描述可能不是很清楚,用数字走一遍,原数组链表目前3 -> 2 ->1 的顺序(如果下标索引没有变化的话,当然这个是有可能变化的,因为之前是根据原数组长度计算的,现在是通过新数组长度计算),那么首先取出来的就是3 对象,然后3 对象的next 属性指向null,然后是2 对象,其next 属性指向3,然后是1 对象,其next 属性指向2。
可以看到目前这种插入方式是将原本顺序进行了倒叙,而方式也被称为头插法,并发情况下,同时扩容时会出现死循环的,因为有可能两条线程导致1 对象指向2 对象,2 对象由反过来指向1 对象,形成一个闭环。当然hashMap 本身就不支持高并发情况,也不建议高并发场景使用。
1.7 的版本代码中只有数组加链表,会好理解很多,1.8 之后的版本是增加了红黑树的存储结构,相对来说比较复杂,红黑树部分的内容后面算法的文章会详细说,这里只会简单概述一下,主要还是说流程。
HashMap1.8版本的数组扩容
扩容的方法名还是跟1.7的一致,也就是resize 方法,不过其中的第一区别就是之前说过的Entry 对象改为了node 对象,不管其内容结果还是一样的,不过1.8 版本数组中的对象,不再称之为元素对象,而是节点对象,我的个人习惯吧。
扩容和初始化的准备工作
首先就是做一些准备工作,获取一些目前的数值,当前表对象这里改为了node 对象数组,然后是获取当前数组长度,然后是扩容阀值,然后声明两个变量一个是新数组长度,一个是新扩容阀值。
1 | final Node<K,V>[] resize() { |
接着走就是判断当前数组长度是否大于0,只有一种情况这个判断是false,那就是数组为未初始化的时候。这里就可以看出resize 方法不仅是数组扩容的方法,还是数组初始化的方法。具体步骤看我的代码注释吧。
1 | // 当数组有值时,也就是完成了初始化 |
上面基本都是准备工作,扩容阀值和数组长度的一个计算。
首先是构建出新的数组,初始化的话,这里就是16,扩容操作的话,这里就是原本数组的两倍大小。这里如果不是扩容操作的话,就直接返回了,扩容内容我们下面看详细。这里真正去读的话是一堆代码,还是要自己分步骤一点一点走才能清楚。
1 | Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; |
扩容明细
在1.7 版本的扩容思路中,我们就已经知道了,扩容就是创建一个比原来数组大两倍的新数组,然后将原本数组中的数据进行重新计算下标索引,然后存入新数据中不同的位置。也就意味着有两个情况,一是:当前桶的位置只有一个元素对象,二是:当前桶的位置存在链表。1.8 版本加上了红黑树的结构,所以有了第三种情况,当前桶的位置存在红黑树结构。
先看判断,然后再逐个分析判断中的内容。详细步骤看注释即可,这个不是很重要,只要记住就是分三步处理即可。
1 | // 确认原本数组中存在数据,也就是当前操作为扩容操作 |
单个节点对象处理
这个简单,还是老套路,用当前节点对象的hash 值和数组大小 - 1来计算桶的位置(也就是下标索引),然后存入新的数组中即可。
1 | newTab[e.hash & (newCap - 1)] = e; |
红黑树节点处理
这里的话就是调用TreeNode 对象的split 方法,具体代码的话我们就不看了,我们看下其流程图,这里的主要作用还是跟上面一样,查询出每一个元素,然后重新计算下标索引,存入数组中,这里注意存入方式区别于1.7 版本的头插法,这里使用的是尾差法,也就说将每一个节点当做最后一个节点,比如:原数组的顺序是 3 -> 2 -> 1,那么新数组的顺序还是 3 -> 2 -> 1。
1 | ((TreeNode<K,V>)e).split(this, newTab, j, oldCap); |
对于下面这个图,红黑树部分的解析中我们要注意的是链表和红黑树的相互转换,红黑树转链表的界限是:长度为6,链表转红黑树的界限是:数组长度是64、链表长度是8,这里既然存在了红黑树,那么这里的数组长度一定是大于64的。红黑树的具体查询方式还是后面算法文章再详谈。
链表节点的处理
链表节点的处理跟红黑树的处理差不多,区别就是少了红黑树转链表的过程,具体流程的话还是看上面的流程图。
小结
其实这块的话只要记住1.8 版本和1.7 之前的区别即可,它们之前的存储没有什么太大的差入,都是用hash 值和当前数组长度计算桶的位置,然后进行存储,就是1.8 多了红黑树的数据结构。
还有一点扩容方法调用的时机,1.7 代码是先扩容然后在存储数据,1.8 版本是先存储数据,再扩容。而1.8 版本的添加节点内容中,就是多了一个红黑树结构的判断和存储,还有就是链表部分需要判断当前链表是否需要转换为红黑树数据结构。
其次1.8 的存储数据的尾插法也解决了并发情况下扩容时的死循环问题,但是任然不支持并发场景,因为会出现数据错乱等一系列问题。
删除元素方法-remove方法
结束了最复杂的扩容内容,后面的内容就简单了。remove 的调用就是根据提供的入参,也就是key 值来进行删除。
这里就是继续调用removeEntryForKey 方法。
1 | public V remove(Object key) { |
这里代码很多,但是简单来说就是获取入参key 的hash 值,然后计算下表索引,找到具体桶的位置,然后循环找到真正的节点对象,判断依据就是key 值相等、key 的hash 值相等,然后集合大小减一,再判断节点对象是否是桶的位置的第一个节点,如果是则将桶位置的next 属性指向当前节点的一下个节点,反之则将上个节点的next 属性指向当前节点的一下个节点。
1 | final Entry<K,V> removeEntryForKey(Object key) { |
注意:1.8 后版本因为加了红黑树的原因,还需要考虑删除该节点元素后,红黑树的长度是否 <= 6,因为这样红黑树需要转回链表。
修改元素方法-还是put方法
修改元素,这个是最简单的,就是用原本的key 在put 一次,这样就会覆盖原本节点对象的value 属性。
查询元素的方法-get方法
1.7 的话还是跟上面的删除时的查询一样,先将入参的key 值转为hash 值,然后计算出下标索引,用于找到数组中桶的位置。但是1.8 版本的代码需有红黑树的不同取值方式,简单讲就是调用TreeNode 对象的getTreeNode 方法。这里具体代码就不看了,逻辑还是比较简单的,红黑树那块的话,一时半会儿也讲不清楚,还是等后面算法文章再一起讲。
总结
HashMap 本身逻辑还是比较简单的,就是1.7 和1.8 之前的区别较多,1.8 不光是添加了红黑树的数据结构,还对1.7 之前的代码有一定的优化,比如存储数据的方式由之前的头插法改为了尾插法,虽然任然不支持并发的情况,但是也改善了许多。
本次的重点其实还是hash 表和hashMap 本身解决hash 冲突的思路,后面Redis 也是使用相似的思路来解决这类问题。至于hashMap 的面试题我在文章最后整理一下。
HashMap常见面试题
面试题:介绍下 HashMap 的底层数据结构
答:
JDK1.8之前的HashMap数据结构:数组 + 链表;JDK1.8之后的HashMap数据结构:数组 + 链表 / 红黑树
面试题:1.8 为什么要改成“数组+链表/红黑树”?
答:
主要是为了提升在hash 冲突严重时(链表过长)的查找性能,使用链表的查找性能是 O(n),而使用红黑树是 O(logn)。
面试题:那在什么时候用链表?什么时候用红黑树?
答:
插入:默认情况下是使用链表节点。当同一个索引位置的节点在新增后达到9个(阈值8):如果此时数组长度大于等于 64,则会触发链表节点转红黑树节点;而如果数组长度小于64,则不会触发链表转红黑树,而是会进行扩容,因为此时的数据量还比较小。
移除:当同一个索引位置的节点在移除后达到 6 个,并且该索引位置的节点为红黑树节点,会触发红黑树节点转链表节点。
面试题:为什么链表转红黑树的阈值是8?
答:
阈值为8是在时间和空间上权衡的结果红黑树节点大小约为链表节点的2倍,在节点太少时,红黑树的查找性能优势并不明显,付出2倍空间的代价不值得。
理想情况下,使用随机的哈希码,节点分布在 hash 桶中的频率遵循泊松分布,按照泊松分布的公式计算,链表中节点个数为8时的概率为 0.00000006,这个概率足够低了,并且到8个节点时,红黑树的性能优势也会开始展现出来,因此8是一个较合理的数字。
面试题:为什么转回链表节点是用的6而不是复用8?
答:
如果我们设置节点多于8个转红黑树,少于8个就马上转链表,当节点个数在8徘徊时,就会频繁进行红黑树和链表的转换,造成性能的损耗。
面试题:HashMap 的默认初始容量是多少?HashMap 的容量有什么限制吗?
答:
默认初始容量是16。HashMap 的容量必须是2的N次方,HashMap 会根据我们传入的容量计算一个大于等于该容量的最小的2的N次方,例如传 9,容量为16。
面试题:HashMap 的容量必须是 2 的 N 次方,这是为什么?
答:
原因 1:降低发生碰撞的概率,使散列更均匀。根据 key 的 hash 值计算 bucket 的下标位置时,使用“与”运算公式:h & (length-1),当哈希表长度为 2 的次幂时,等同于使用表长度对 hash 值取模(不信大家可以自己演算一下),散列更均匀;
原因 2:表的长度为 2 的次幂,那么(length-1)的二进制最后一位一定是 1,在对 hash 值做“与”运算时,最后一位就可能为 1,也可能为 0,换句话说,取模的结果既有偶数,又有奇数。设想若(length-1)为偶数,那么“与”运算后的值只能是 0,奇数下标的 bucket 就永远散列不到,会浪费一半的空间。
面试题:负载因子为什么默认值是0.75。
答:
在时间和空间上权衡的结果。如果值较高,例如1,此时会减少空间开销,但是 hash 冲突的概率会增大,增加查找成本;而如果值较低,例如 0.5 ,此时 hash 冲突会降低,但是有一半的空间会被浪费,所以折衷考虑 0.75 似乎是一个合理的值。
面试题:HashMap 的插入流程是怎么样的?
答:
1.7 版本先扩容,在添加。1.8 版本先添加,再扩容。
1.7 版本的添加比较简单用头插法添加,构建出一个Entry 对象,然后添加到由key 的hash 值和当前数组长度计算出的下标索引找到的桶的位置,如果是链表,则构建Entry 对象的时候其next 属性会将上个Entry 对象传入构建,简单来说就是套娃。
1.8 版本的添加,还是通过key 值计算下标,找桶的位置,但是这里会判断数据结构是单个节点,还是红黑树,还是链表,如果是单个节点就直接添加;如果是红黑树则需要调用红黑树的添加方法再进行添加;如果是链表这需要将上一个节点对象的next 属性指向当前添加的节点,这就是尾插法,然后还要判断如果当前添加的节点对象是第9个的话 ,还需要将链表转为红黑树的数据结构。
面试题: key 的 hash 值,是怎么设计的?
答:
拿到 key 的 hashCode,并将 hashCode 的高16位和 hashCode 进行异或(XOR)运算,得到最终的 hash 值。
面试题:为什么要将 hashCode 的高16位参与运算?
答:
减少哈希碰撞。
面试题:扩容(resize)流程介绍下?
答:
这个看上面hashMap 扩容的流程小结吧。
面试题:对比JDK1.7 JDK 1.8 主要进行了哪些优化?
答:
- 底层数据结构从“数组+链表”改成“数组+链表+红黑树”,主要是优化了 hash 冲突较严重时,链表过长的查找性能:O(n) -> O(logn)。
- 优化了 hash 值的计算方式,新的只是简单的让高16位参与了运算。
- 扩容时插入方式从“头插法”改成“尾插法”,避免了并发下的死循环。
- 扩容时计算节点在新表的索引位置方式从“h & (length-1)”改成“hash & oldCap”,性能可能提升不大,但设计更巧妙、更优雅。
面试题大体就是这些,如果还有被问到其它的问题,我会在往这个文章中进行补充,先这样,本次站点到站,下车。
附录JDK 源码分析系列文章
时间 | 文章 |
---|---|
2022-04-25 | Object、ArrayList以及LinkedList源码分析 |
2022-04-27 | HsahMap的面试详解及源码分析 |