HashMap
基本结构
-
1.7 数组 + 链表
-
1.8 数组 + (链表 | 红黑树)引入红黑树以解决链表线性长度过长查找的效率问题
储存 计算出数据的原始哈希值,和二次计算哈希值 (函数扰动),使用二次哈希值对当前数组长度进行按位与计算,计算出下标
查找 计算数据的值后,使用二次哈希值 (函数扰动) 对当前数组哈希容量进行按位与运算,计算出下标,在下标或链表中查找
扩容 数据超过长度的4/3 0.75 或者链表长度超过8
链表的问题 二个数据可能有相同的哈希 在过多的数据下,计算的相同的下标过多,需要储存在同一个链表,链表的数据过多需要多次比对,效率过低。解决办法可以有缩减链表长度——扩容长度,因为需要重新与长度计算下表。但仍然有相同下标的可能。或者使用红黑树
1.8优化
不同 | JDK 1.7 | JDK 1.8 |
---|---|---|
存储结构 | 数组 + 链表 | 数组 + 链表 / 红黑树 |
初始化方 式 | 单独函数: inflateTable() | 直接集成到了扩容函数 resize() 中 |
hash值 计算方式 | 扰动处理 = 9次扰动 = 4次位运 算 + 5次异或运算 | 扰动处理 = 2次扰动 = 1次位运算 + 1次异 或运算 |
存放数据 的规则 | 无冲突时,存放数组;冲突 时,存放链表 | 无冲突时,存放数组;冲突 & 链表长度 < 8:存放单链表;冲突 & 链表长度 > 8: 树化并存放红黑树 |
插入数据 方式 | 头插法(先讲原位置的数据移 到后1位,再插入数据到该位 置) | 尾插法(直接插入到链表尾部/红黑树) |
扩容后存 储位置的 计算方式 | 全部按照原来方法进行计算 (即hashCode ->> 扰动函数 - >> (h&length-1)) | 按照扩容后的规律计算(即扩容后的位置 =原位置 or 原位置 + 旧容量) |
树化意义
树化 在链表长度超过8并且数组长度超过64时会进行红黑树转化。实际上来链表长度超过8的可能非常低, 如数组长度不超过64但链表长度已经为8,此时添加元素只会扩容数组。
-
树化应当是偶然情况,是保底策略
-
hash 表的查找,更新的时间复杂度是
O(1)
,而红黑树的查找,更新的时间复杂度是O(log_2n)
,TreeNode 占用空间也比普通 Node 的大,如非必要,尽量还是使用链表 -
hash 值如果足够随机,则在 hash 表内按泊松分布,在负载因子 0.75 的情况下,长度超过 8 的链表出现概率是 0.00000006,树化阈值选择 8 就是为了让树化几率足够小
TreeNode和Node
-
TreeNode是Node的子类,也就是说TreeNode含有Node的所有属性;
-
Node是一个含有next属性的单向列表,TreeNode是一个含有next(继承自Node)、prev属性的双向链表,同时TreeNode还是一个含有left、right、parent属性的二叉树
-
同时TreeNode还是一个含有boolean red属性的红黑树
树化阈值 链表使用的Node,红黑树使用的时TreeNode,它比Node更加消耗资源。在链表过短的情况下进行树化性能反而不如链表
退化规则
-
情况1:在扩容时如果拆分树时,树元素个数 <= 6 则会退化链表
-
情况2:remove 树节点时,在移除之前检查、若 root、root.left、root.right、root.left.left 有一个为 null ,也会退化为链表
索引计算方法
-
首先,计算对象的 hashCode()、再进行调用 HashMap 的 hash() 方法进行二次哈希、最后 & (capacity – 1) 得到索引
二次哈希
不同的哈希码算出相同的下标Index,就会导致哈希碰撞,一旦发生哈希碰撞,HashMap的查找效率就会从O(1)退化成O(n)或者O(logn)。所以,一个好的哈希函数应该要尽可能的分散,否则就会影响到HashMap的效率。
二次哈希就时为了提高HashMap的效率,综合高位数据。让哈希分布更为均匀,减小链表的长度。可能有些值的哈希计算出的下标集在某一个字段范围,可使用二次哈希进行一个扰动分散
1.8的二次哈希为(key.hashCode()) ^ (h >>> 16)、即先拿取原始哈希再右移16位,也就是高16位在与原始哈希进行异或运算
数组容量为何是 2 的 n 次幂
-
计算索引时效率更高:如果是 2 的 n 次幂可以使用位与运算代替取模,因为求模类似与除法,按位与运算效率更高。
-
扩容时重新计算索引效率更高: hash & oldCap == 0 的元素留在原来位置 ,否则新位置 = 旧位置 + oldCap
-
二次 hash 是为了配合 容量是 2 的 n 次幂 这一设计前提,如果 hash 表的容量不是 2 的 n 次幂,则不必二次 hash
-
容量是 2 的 n 次幂这一设计计算索引效率更好,但 hash 的分散性就不好,需要二次 hash 来作为补偿,没有采用这一设计的典型例子是 Hashtable,
-
在指定数组长度时传入指定大小最好为2的n次方,反之HashMap也会转化多了一部操作;
二次哈希是为了弥补2的n次幂的分布不均匀,2的n次幂是为了提高HashMap的效率,每次放取都需计算下标,所以计算效率也尤为重要
put 流程
-
HashMap 是懒惰创建数组的,首次使用才创建数组
-
计算索引(桶下标)
-
如果桶下标还没人占用,创建 Node 占位返回
-
如果桶下标已经有人占用
-
已经是 TreeNode 走红黑树的添加或更新逻辑
-
是普通 Node,走链表的添加或更新逻辑,如果链表长度超过树化阈值,走树化逻辑
-
-
返回前检查容量是否超过阈值,一旦超过进行扩容,添加数据后在进行扩容
1.7 与 1.8 的区别
-
链表插入节点时,1.7 是头插法,1.8 是尾插法
-
1.7 是大于等于阈值且没有空位时才扩容,而 1.8 是大于阈值就扩容
-
1.8 在扩容计算 Node 索引时,会优化 把哈希值与旧的数组容量做一个按位与,如是0不用动位置,新的位置为旧的索引加旧容量
扩容(加载)因子为何默认是 0.75f
-
在空间占用与查询时间之间取得较好的权衡
-
大于这个值,空间节省了,但链表就会比较长影响性能
-
小于这个值,冲突减少了,但扩容就会更频繁,空间占用也更多
并发问题
在并发的情况下可能会出现数据错乱的问题,即t2线程覆盖t1的数据
扩容死链(存在d1.7 )即在多线程数组扩容移动链表的情况下发送链表死循环
-
e 和 next 都是局部变量,用来指向当前节点和下一个节点
-
线程1的临时变量 e 和 next 刚引用了这俩节点,还未来得及移动节点,发生了线程切换,由线程2 完成扩容和迁移
-
线程2 扩容完成,由于头插法,链表顺序颠倒。但线程1 的临时变量 e 和 next 还引用了这俩节点,还要再来一遍迁移
第一次循环
-
循环接着线程切换前运行,注意此时 e 指向的是节点 a,next 指向的是节点 b
-
e 头插 a 节点
第二次循环
-
next 指向了节点 a
-
e 头插节点 b
-
当循环结束时,e 指向 next 也就是节点 a
第三次循环
-
next 指向了 null
-
e 头插节点 a,a 的 next 指向了 b(之前 a.next 一直是 null),b 的 next 指向 a,死链已成
-
当循环结束时,e 指向 next 也就是 null,因此第四次循环时会正常退出
Key的设计
-
HashMap 的 key 可以为 null,但 Map 的其他实现则不然
-
作为 key 的对象,必须实现 hashCode 和 equals,并且 key 的内容不能修改(不可变)
-
重写hashCode是为了key更好的分布性,重写equals 是为了防止在相同的hash码需要进一步进行比对
-
hash相同但equals不一定相同,equals相同那么hash一定相同
-
key 的 hashCode 应该有良好的散列性 即分布在不同的数组下标
如果 key 可变,例如修改了 age 会导致再次查询时查询不到
String 对象的 hashCode() 设计
-
目标是达到较为均匀的散列效果,每个字符串的 hashCode 足够独特
-
字符串中的每个字符都可以表现为一个数字,称为 S_i,其中 i 的范围是 0 ~ n - 1
-
散列公式为: S_0∗31^{(n-1)}+ S_1∗31^{(n-2)}+ … S_i ∗ 31^{(n-1-i)}+ …S_{(n-1)}∗31^0
-
为什么每一次乘以31,因为31 代入公式有较好的散列特性,并且 31 * h 可以被优化为
-
即 32 ∗h -h
-
即 2^5 ∗h -h
-
即 h≪5 -h
-
总结
HashMap底层采用数组+链表/红黑树来存储键值对,会根据Key的哈希码来计算键值对落在数组的哪个下标。如果不同的哈希码算出相同的下标,就会导致哈希碰撞,影响HashMap的性能。HashMap要做的,就是尽量避免哈希碰撞,所以加入了扰动函数。扰动函数会将哈希码的高16位与低16位做异或运算,让高位也参与到下标的计算过程中来,从而影响最终下标的计算结果,减少哈希碰撞的概率。至于为啥是16位,这是因为哪些位会参与到下标的计算,取决于HashMap数组的长度,在绝大部分情况下,数组的长度都不会超过65536,16位是一个折中的数字。