作者:mobiledu2502923573 | 来源:互联网 | 2024-11-10 14:10
本文深入解析了JDK8中HashMap的源代码,重点探讨了put方法的工作机制及其内部参数的设定原理。HashMap允许键和值为null,但键为null的情况只能出现一次,因为null键在内部通过索引0进行存储。文章详细分析了capacity(容量)、size(大小)、loadFactor(加载因子)以及红黑树转换阈值的设定原则,帮助读者更好地理解HashMap的高效实现和性能优化策略。
HashMap
一、HashMap允许key为null,value为null,但是key为null只可以为一个,因为放进去key = null的新键值对,再放进去的时候会更新key = null的value值
二、put(key, value)的过程
return putVal(hash(key), key, value, false, true);
0、创建了HashMap以后,其capacity为16(仅仅是DEFAULT_INITIAL_CAPACITY值为16但是容量并不为16,目前里面实际存储键值对的Node数组仍然为空)、loadFactor为0.75;size(为键值对的个数而不是桶被占用的个数)、threshold为0,在put第一个值的时候才new Node[16],threshold=16 * 0.75;
HashMap底层存储数据其实是一个其内部类Node implements Map.Entry的数组
1、key先进行hash运算
(1)如果key为null,返回0;
(2)如果不为null,执行hashcode运算
Ⅰ.如果为普通数据(字符串、数字),那么它的hashCode运算为:
(数字会自动装箱转为包装类,使用其重写的hashcode运算)
* 字符串对象内部存储为数组;* int h = 0* 循环计算:h = h*31 + 字符数组中的字符ASCII值
Ⅱ.如果为Java对象,一般需要重写hashCode方法,不重写的话会调用本地方法,这个方法是C语言实现的,但是返回值绝对不是对象的内存地址;
public native int hashCode();
(3)hashCode的返回值hash和hash自己做无符号右移16位后的值做&运算(高位补0)
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
2、将hash值,key和value传入putVal()方法中,开始做散列
【扩容时如何复制节点到新的Node[]数组:(暂不考虑红黑树情况)
循环遍历每一个桶
- 如果只有一个节点,直接hash & newCapacity - 1确定新的桶位置(新的位置要不就是新数组的原下标号桶里,要不就是在oldCapacity + 原下标号的桶里,这个下面代码块部分会解释);
- 如果不止一个节点,循环遍历每一个节点,先hash & oldCapacity(老数组的容量),这样是为了判断你的下标需不需要挪到新扩出来的桶去,举个例子:
容量16(16-1 = 1111),hash1 = 10001和hash2 = 00001的桶位置都在1里面,但是扩容以后,容量32(32-1 = 11111)因为首位数字不同所以就不会在一个桶了,这样hash & oldCapacity是为了判断你的最高那一位是不是1,如果是0那你不用挪位置,如果是1,那就要挪桶的位置,挪到oldCapacity + 当前下标的位置(最高位为1,做hash运算的时候肯定会变为1XXXX,就等于oldCap + 当前下标)
- 挪的过程中就会进行分表,即把这个老的链表拆开,一部分留在新数组原下标号的桶里,一部分放到(oldCapacity + 当前下标)的桶里
】
三、hashmap一些值设定的原因
1、为什么数值要为2^n个
(1)当哈希表的桶的个数为2^n 的时候,它的下标最大为 2^n - 1 ,这个时候转为二进制就是111…11的结构,可以保证与key的hash值做&运算得到的值可以完美投影到其中一个桶上,这样就不担心是否会投影到桶之外再做另外的处理。
例如当前容量为16,key的hash值为10001,我们直接使用16 = 10000 & 10001 = 10000 = 16 >桶的最大下标15,这个时候就还需要再做其他处理甚至可能需要做二次hash,那么是很麻烦和浪费时间的;
(2)可以尽可能的减少碰撞次数,因为只要两个key的hash值不同,与111…111的&运算就一定不同,可以尽可能的避免碰撞
2、为什么加载因子是0.75
这是考虑了时间和空间效率得出的折中方案,如果为1的时候在扩容那么会增大碰撞率,如果考虑一下中间值0.5的时候再扩容那么会浪费很多空间,因此要取一个折中方案。那么是因为0.5 + 1/ 2 = 0.75吗,实际维基百科有一个介绍说:
对于开放定址法,加载因子是特别重要因素,应严格限制在0.7-0.8以下。超过0.8,查表时的CPU缓存不命中(cache missing)按照指数曲线上升。因此,一些采用开放定址法的hash库,如Java的系统库限制了加载因子为0.75,超过此值将resize散列表。
因此折衷方案来自于这里!
有的博客说是因为那个泊松分布,在我看来那个泊松分布是说在加载因子为0.75的前提下,可以保证我们的桶上链表节点的个数大于等于8的可能性几乎为0,这个更像是说明链表转红黑树的设定阈值为8的原因。
参考原文:https://blog.csdn.net/NYfor2017/article/details/105454097/?utm_medium=distribute.pc_relevant.none-task-blog-baidujs-2
3、为什么链表长度大于等于8的时候转为红黑树,小于等于6的时候转为链表
(1)依据泊松分布,加载因子为0.75的时候,链表中的个数为8的可能性为亿分之6(代码中注释所描述),所以正常情况下是不会出现这种状况的,但是如果出现了就说明是非正常情况,这个时候可能有很多的key碰撞在同一个桶中,这个时候继续用链表的话查询时间复杂度O(n)效率是很低的,而红黑树的O(log(n))则要远远优于链表,举个例子,1024的链表查询平均时间为512,而红黑树则仅仅为10。节点数小于8的时候链表的查询时间是很小的,没必要用红黑树,而且红黑树调整树结构也需要时间,节点数也为链表节点数的2倍,所以没必要使用。
总结:Ⅰ桶上节点数出现8的概率很低,出现了说明为非正常情况,因此要额外处理;Ⅱ而当大于等于8的时候继续使用链表查询效率会比较低,改用红黑树会更快(这是以空间换取时间的策略);Ⅲ节点数小于8的时候考虑时间和空间效率没必要使用红黑树;
参考原文:https://blog.csdn.net/qq_43519310/article/details/102887039
(2)那为什么6是树转链表的阈值:因为达到了8的时候如果一个桶上频繁的增删节点可能节点数会不停的在8和7变动,那么阈值设定为7就要不停的链表和树互转,这个时候耗费时间空间,所以给它一个缓冲空间设定阈值为6的时候再转链表,达到6的时候说明节点数在向减少的趋势发展,所以设定为6了;