HashMap源码分析和面试准备
写在前面
本篇涉及到的知识面比较广,无论你是小白还是有经验人士,都可快速理解hashmap,目的是理解知识并能通过面试,一些过于深入和细节的东西不在这里讨论。
预备知识
-
hash(哈希)是什么
Hash,一般翻译做散列、杂凑,或音译为哈希,是把任意长度的输入(又叫做预映射pre-image)通过散列算法变换成固定长度的输出,该输出就是散列值。这种转换是一种压缩映射,也就是,散列值的空间通常远小于输入的空间,不同的输入可能会散列成相同的输出,所以不可能从散列值来确定唯一的输入值。
简单点说,hash就是把任意的内容通过计算转换成一个特殊的值。文章也好,数字也好,视频也好……不管你的原内容有多大,转换后的结果都是同一个格式的值,这个值可以是字符串,可以是数字,具体取决于调用的算法。就像不管你是电影明星,还是社会大佬,还是企业高管,不管你有多少头衔,进了监狱,你就只有代号。
哈希是音译过来的,密码学中称其为摘要算法,这个词就好理解多了,做摘要嘛,就像一本书或者一部电影的介绍一样,提取特点用简短的语言描述而已。
比如学校里有一千个学生,校长让我给他找几个学习好的同学,或者让我给他找几个会拉格朗日中值定理的同学,每个学生掌握的知识程度都不一样,我他妈上哪找去,一个一个问啊,这也太慢了,而且万一他明明不会缺骗我说他会怎么办?
考试!!!通过考试,把每个学生的学习成果由抽象概念转换成了一个真实的分数,现在校长让我找学习最好的同学,我就可以去找考试满分的同学,让我找会拉格朗日的同学,我就可以找相关题目答满分的同学。这样是不是就很方便了,第一减少了我的查询时间,第二学生不能不懂装懂。
以上例子就可以理解为一个摘要算法(哈希),你交白卷也好,你全写了也好,你填写的内容就是原文,考试的打分规则就是这里的算法,经过计算后给你得出你的分数,这就是你的哈希值。不管你原文有多长,不管你本人有多吊或者你爸是校长,你得到的分数都是0-满分之间。
总结一下hash的特点和用法:
- 任意长度的输入,得到固定长度的输出
- 不可逆,可以把原文计算成密文,但是不能倒推回去
解释:比如“我爱你”计算后为“aaa",但是你无法通过”aaa"推导出他的原文 - 算法不固定,只要满足hash的思想就是hash算法。加密领域的常见摘要算法有md5,sha256等。
- 可以用来快速检索:比如比较一篇文章和其他一万篇文章是否相同,一行一行去看太慢了,做个哈希转换成某些数字去比较会更快。
- 防篡改:密码学里的主要用途,因为只能加密不能解密,所以发送数据时会把原文加密后把原文和密文一起发给对方,对方收到后,先对原文做个加密,如果密文和收到的一样说明内容没被改过。常见的比如用迅雷下载时,一般会带一个md5文件,如果下载完成后提示文件不安全,那可能就是源文件被修改过和提供的密文不一致。
- 密码保存。注册密码都是加密后保存在数据库的,好处就是数据库维护人员无法直接看到用户的密码,并且无法倒推。用户登录时,输入密码,计算hash,和数据库里存的去比较。
-
数组
内存中的一片连续区域,同类型数据的集合,有索引,查询快,增删慢,不可扩容。
-
链表
不连续的区域,每个节点放值和指向下一个节点的指针。查询慢,增删块。
-
哈希表
可以理解为数组和链表的组合。即一个一维数组,但是数组中的每个元素是一个链表。
为什么要用哈希表?可以看这篇博客 http://m.elecfans.com/article/1194442.html
简单来说,为了在保存数据的时候更方便,达到空间和时间的一个平衡。
向哈希表里存数据时,这里以s代表数据,要先计算s的hash值,然后根据这个hash值去计算它应该对应哪个索引,也就是放在数组的哪一个单元格内。
我们知道hash是不同长度的输入对应固定长度的输出,也就是说肯定会有某些数据计算出的hash是一样的,这个就叫做hash冲突或者碰撞。
插入过程中当发现两个数据的hash一致,那么对应在数组里的索引也一致,这是就会采用链表式的插入方法,将新数据插入后,让该位置原来的数据指向新数据(这里涉及到头插法和尾插法)
查询的时候类似于查字典,我们查字典的时候都是先去找拼音,然后会找到对应的页数,这一步就相当于根据索引直接找到了数据,但是同音字有很多,所以我们还得一个一个的看是不是我们要的字,这里就相当于遍历链表了。
-
二叉树
树结构,但是呢,每个节点最多只能有两个分叉
-
红黑树
自平衡的二叉树
什么叫平衡,简单理解就是任意节点的左右两个子树高度差都小于等于1,这样便利起来会更均匀
红黑树的自平衡,就是他会通过变色和旋转,使自己可以动态的平衡,也就是说不用你来平衡我,我可以自己变化。类比的话,平衡二叉树就像学校里的女朋友,比较孩子气,一不开心就需要你哄,需要你去让她平衡。红黑树相当于比较成熟的贤妻良母,当她不开心的时候,可以自己调节。
推荐个视频,专讲红黑树
https://www.bilibili.com/video/BV11E411Y77y?from=search&seid=16749414293990551107
源码分析
jdk1.7实现
-
基本参数
- 默认初始容量:static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
- 最大容量:static final int MAXIMUM_CAPACITY = 1 << 30;
- 负载因子(扩容因子):static final float DEFAULT_LOAD_FACTOR = 0.75f;
- 键值对个数:transient int size;
- 扩容时的阈值,当前容量 * 负载因子:int threshold;
- 被修改的次数:transient int modCount;
- put,get,remove,intereator等方法中,都使用了该属性。由于HashMap不是线程安全的,所以在迭代的时候,会将modCount赋值到迭代器的expectedModCount属性中,然后进行迭代,
如果在迭代的过程中HashMap被其他线程修改了,modCount的数值就会发生变化,
这个时候expectedModCount和ModCount不相等,
迭代器就会抛出ConcurrentModificationException()异常
- put,get,remove,intereator等方法中,都使用了该属性。由于HashMap不是线程安全的,所以在迭代的时候,会将modCount赋值到迭代器的expectedModCount属性中,然后进行迭代,
-
构造方法
一般我们常用的是第一种,无参构造,这时默认初始容量就是16,默认负载因子是0.75。
这里我们只讨论上面的三个构造,通过查看源码得知,前两个构造方法内部都使用的是第三个构造
第三个构造内部首先对传进来的容量和负载因子做判断,主要是看数据是否合规,此时,让扩容阈值等于了初始容量。注意这个时候仅仅是给一些必要的参数赋值,并没有在此时初始化HashMap,没有分配空间。
- put方法
public V put(K key, V value) {
if (table == EMPTY_TABLE) { //判断是否内容为空,这里的table是一个键值对数组,默认是空的。
inflateTable(threshold); //如果内容为空,则首先初始化
}
if (key == null) //如果传入的key是null,则调用下面的方法
return putForNullKey(value);
int hash = hash(key); //如果key是存在的,那么计算key的hash
int i = indexFor(hash, table.length);//计算完哈希后,根据哈希和数组长度去计算对应的索引,也就是这个key应该在数组里哪里存储
for (Entry<K,V> e = table[i]; e != null; e = e.next) {//数组中的每个元素是一个链表,已经计算除了索引,所以遍历该索引位置上的链表,目的是为了检查传进来的key是否已经存在在表里
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {//如果发现链表上的元素和传进来的key各方面都一样(hash,地址,内容)那么就说明表里已经有了,那么就保持key不变,更新value,并返回旧的value
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++; //被修改次数加一,这个值主要是为了线程安全考虑,和本方法的业务无关
addEntry(hash, key, value, i); //如果是一个新key,则添加元素。
return null;
}
接下来一条一条分析
1.初始化具体做了哪些东西“inflateTable(threshold);”
private void inflateTable(int toSize) {
// Find a power of 2 >= toSize
int capacity = roundUpToPowerOf2(toSize);//这里可以看到,初始化时,无论传进来的初始容量是多少,都会向上取整为2的幂。也就是说,虽然构造方法中可以让用户自定义容量大小,但是进来后也会向上转成2的幂。当然,如果本身就是2的幂,那么就不会转换了,这里看源码,会发现他做了一个-1操作,目的就是为了防止把正确容量也翻一倍。比如你传进来的是15,那么向上取整为16.如果传进来的是16,向上取整就成了32,这是不合理的,所以任何数在取整时都先-1.
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);//计算扩容阈值
table = new Entry[capacity];//初始化
initHashSeedAsNeeded(capacity);
}
2.如果key为null怎么办“ return putForNullKey(value);”
这里可以看到,如果key为null,则默认放在数组的索引为0的位置,换句话说,如果遍历这个hashmap,key为null会出现在前面。首先是上面的循环,我们发现,如果发现链表里还有null,那么就替换value,也就是说,key可以为null,但是只能有一个。
如果说是第一次加入key为null的元素,那么直接保存在索引为0的位置。
插队!!!
这里插队讲一下计算hash和索引的方法
每次插入key时,都要计算对应的hash值,那么如何根据hash值去计算索引呢?从应用的角度考虑,我们肯定希望这些元素能均匀的分布在数组的不同格子里,这样做查询的时候就会快。如果分布不均匀,比如所有元素都分布到了同一个格子里,那岂不是退化成了一个链表,查询效率就低了。所以怎么才能保证把所有元素平分到每个格子里呢?
想想平时我们写代码时,遇到想分为几种情况讨论时是如何做的?聪明的你一定想到了,取余。什么?你没想到?那你写代码太少了。本篇不适合你。
看上面这段代码,i是在递增,对三取余就三种情况。对几取余就是几种情况。
对于我们计算出的hash来说,先不要管他计算出的显示形式是数字还是字符串还是其他,反正肯定可以转成二进制,能转成二进制那是否就能转成十进制,反正你是个数字,对吧,每个key的hash不一样,那么对数组长度取余是否就算是平均分了。
但是这里存在两个问题,数组的索引是从0开始的,但是我们计算出的hash有可能是个负数,同时,取余操作太慢了,既然要做计算,直接用二进制肯定是最快的。
所以,计算索引的时候我们是这样做的,hash & 数组长度(可以这样思考,任何两个数做&操作,得到的结果是否一定小于等于这两个数中的任意一个?废话肯定啊,没有概念的话可以自己画个图)
假设数组长度默认16,那么转换成二进制是10000
传进来的key也要计算hash,假设计算出来的hash是10111
那么&完后的结果是10000,我草,怎么还是16,尼玛这不索引越界了。
那么假设数组长度不是16,是17,也就是10001,传进来的hash为111,那么&完后,结果为0001,这个key会被放在所以为1的地方,但是再看,传进来的key如果是101,001,都被分配到了索引为1的地方,这显然就违背了我们的均匀分配的原则了。为什么呢?因为0和任何数&都是0,也就是说,我们只有保证数组长度的二进制里1越多越好。
基于以上,最终做求索引操作时,用的是(hash&(数组长度-1))
所以hashmap要求容量必须是2的幂,因为2的幂转换成二进制是1后面若干个0,-1后,结果为0后面若干个1.既能保证分的均匀,也能保证不越界。
再看这个,最后一行是数组长度-1,上面是我假定的两个key的hash,发现了什么?因为数组长度有限,所以会导致这样一种情况,key的hash值高位不同,低位相同,也会被分到同一个索引,这就又不均匀了。怎么办呢?
3.计算hash时都做了哪些操作?
从上图可以看出,计算hash时不仅仅是得到hash这么简单,还做了一系列复杂的运算,其目的是通过各种右移能够让高位也参与运算,最大化的避免高位相同低位不同分到同一个索引。这里的注释也说明了这样操作的原因。
4.计算索引?
5.添加元素时做了哪些操作“ addEntry(hash, key, value, i);”
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {//当当前的元素个数大于等于扩容阈值的时候,并且分配给新元素的这个位置以及有值,则扩容
resize(2 * table.length);//扩容为原来的数组长度乘2
hash = (null != key) ? hash(key) : 0;//如果key=null,则hash为0
bucketIndex = indexFor(hash, table.length);//根据新数组长度重新计算索引
}
createEntry(hash, key, value, bucketIndex);//插入该节点
}
扩容,很多人理解就是装不下了,所以要扩容。但是这里扩容的判断并不是达到数组最大值,而是数组长度 * 扩容因子,对于初始容量来说就是16 * 0.75 = 12 ,当hashmap里保存的元素个数达到12时就满足了扩容条件。为什么定0.75,看类的注释,可以发现,0.75是经过开发者测试得到的,达到这个值后出现碰撞的概率比较大啊,所以定了这个数。以下是原文,可以清楚的看到是为了达到空间和时间的一个平衡。
同理,初始容量默认是16,应该也是一个经验值,规定容量必须是2的n次方,初始太小了要频繁扩容,太大了又浪费,所以为了平衡选择16
这里需要注意,很多博客都说扩容的条件是达到元素个数达到扩容阈值,这其实不全面。通过看代码可以发现,if里的判断条件是两个,在元素个数达到阈值的同时,如果发现分配给新元素的这个索引已经被占用了,才扩容。单纯达到阈值是不扩容的。简单总结,就是元素个数达到了临界值并且新元素碰撞了,才扩容。
因为规定容量是2的幂,所以扩容时把原容量乘2.
扩容后,相当于重新开辟了一个更大的数组空间,然后要重新计算原数组中元素的哈希,并把原数组中的内容迁移过来。
原模原样搬过来不香吗,为什么要重新计算hash?
大哥,你再想想为啥要扩容,为啥达到容量乘扩容因子扩容?是放不下吗?是为了减少碰撞啊。
想一下你的原数组里,可能某一个索引位置上的链表已经很长了,这些元素可能都是高位不同低位相同的,新数组的容量更大,转换成2进制是不是相当于高位也有了值,这时重新计算哈希,不就把原来一个索引位置下链表里的内容给分散开了吗。
注意:按照源码来说,每次扩容后,会根据哈希值和新的容量去计算新的索引,但不一定每次都会重新计算哈希。是否重新计算hash,源码里写了和哈希种子还有int最大值有关系,但是具体怎么算,就不知道了。只要记住一般情况下并不会rehash就好了。
jdk1.7中插入数据采用的是头插法,也就是新来的元素会加在链表的开头,类似于栈,后来居上。因为开发者认为后加的元素可能被用到的几率更大,所以头插法可以快速查询。
当然这也带来了安全隐患,就是在多线程环境下,可能会出现死循环。想了解的话可以参考 https://coolshell.cn/articles/9606.html
为了解决这个问题,jdk8采用了尾插法。
不过HashMap本身就不是线程安全的,所以不建议在多线程下用。
- get方法
这里就补贴代码了,比较简单。
通过get传一个key进来,获取value。其实就是计算一下key的哈希,然后计算索引找到数组中对应位置,然后遍历链表,去比key和链表里保存的是否一致(包括地址内容等),如果一致返回value。
如果key是null,则直接到索引为0的位置去找。
-
jdk1.8实现
咳咳,emm,8的实现有点复杂,我这里直接白嫖别人的博客了
以下为我觉得讲的不错的几篇:
https://www.jianshu.com/p/fb282d3d2e87
https://www.jianshu.com/p/28b18dbb00d6
https://blog.csdn.net/zxt0601/article/details/77413921
总结一下:
8的实现在7的基础上,增加了红黑树,HashMap的底层数据结构为数组+链表+红黑树
为什么要加红黑树呢,因为发现不管扩容机制有多好,依然会出现大量链表导致查询效率低下,所以在插入时,依然按照链表插入,这里不同于7,8里的插入时插入在尾部。当发现链表节点数到达8时(同时满足数组长度达到64,如果数组长度没达到64会先扩容),会将此链表转成红黑树。如果扩容后发现红黑树节点个数减少到了6,那么又会转换成链表。
除了底层数据结构,8还做了很多性能上的优化,比如扩容时不再重新计算哈希值和索引,直接用高低位的一种比较可直接得出索引,速度会更快。详细点可以看上面推荐的博客。
面试常见题目
-
HashMap的底层数据结构?
-
如何存入元素
-
如何取值
-
jdk7和8的区别
-
为什么线程不安全
-
有线程安全的替代类吗
-
默认初始化大小?为什么?为什么大小是2的幂?
-
扩容方式?负载因子?为什么?
-
主要参数有哪些?
-
如何处理hash碰撞的?
-
索引是如何算出来的?
-
哈希表初始化时机?
-
我用LinkedList代替数组结构可以么
-
哈希冲突的解决方法?
- 开放地址法,链地址法,公共溢出区,再哈希法。具体参考下面博客
- https://www.cnblogs.com/higerMan/p/11907117.html
-
为什么不用Hashtable而用ConcurrentHashMap?
-
8中对HashMap做了哪些修改?
- 由数组+链表的结构改为数组+链表+红黑树。
- 优化了高位运算的hash算法:h^(h>>>16)
- 扩容后,元素要么是在原位置,要么是在原位置再移动2次幂的位置,且链表顺序不变。
- 不会在出现死循环问题。
-
为什么在解决hash冲突的时候,不直接用红黑树?而选择先用链表,再转红黑树?
- 因为红黑树需要进行左旋,右旋,变色这些操作来保持平衡,而单链表不需要。 当元素小于8个当时候,此时做查询操作,链表结构已经能保证查询性能。当元素大于8个的时候,此时需要红黑树来加快查询速度,但是新增节点的效率变慢了。
-
不用红黑树,用二叉树可以吗?
- 可以,但是在一些极端情况下,会退化成一条线性结构
-
一般用什么作为key?
- 一般用Integer、String这种不可变类当HashMap当key,而且String最为常用。
- 因为字符串是不可变的,所以在它创建的时候hashcode就被缓存了,不需要重新计算。这就使得字符串很适合作为Map中的键,字符串的处理速度要快过其它的键对象。这就是HashMap中的键往往都使用字符串。
- 因为获取对象的时候要用到equals()和hashCode()方法,那么键对象正确的重写这两个方法是非常重要的,这些类已经很规范的覆写了hashCode()以及equals()方法。
-
可变类作为key可能发生什么问题
- 取不出来值
-
如何实现自定义类作为key?
- 只要搞定两个问题即可:重写hashcode和equals,类不可变
linkedhashmap:https://www.jianshu.com/p/a2c5397e9b22
参考面试题: https://zhuanlan.zhihu.com/p/76735726
https://zhuanlan.zhihu.com/p/87929020