详解java的HashMap底层原理

HashMap

基本结构

  • 1.7 数组 + 链表

  • 1.8 数组 + (链表 | 红黑树)引入红黑树以解决链表线性长度过长查找的效率问题

储存 计算出数据的原始哈希值,和二次计算哈希值 (函数扰动),使用二次哈希值对当前数组长度进行按位与计算,计算出下标

查找 计算数据的值后,使用二次哈希值 (函数扰动) 对当前数组哈希容量进行按位与运算,计算出下标,在下标或链表中查找

扩容 数据超过长度的4/3 0.75 或者链表长度超过8

链表的问题 二个数据可能有相同的哈希 在过多的数据下,计算的相同的下标过多,需要储存在同一个链表,链表的数据过多需要多次比对,效率过低。解决办法可以有缩减链表长度——扩容长度,因为需要重新与长度计算下表。但仍然有相同下标的可能。或者使用红黑树

1.8优化

不同JDK 1.7JDK 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_2⁡n),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 次幂

  1. 计算索引时效率更高:如果是 2 的 n 次幂可以使用位与运算代替取模,因为求模类似与除法,按位与运算效率更高。

  2. 扩容时重新计算索引效率更高: hash & oldCap == 0 的元素留在原来位置 ,否则新位置 = 旧位置 + oldCap

  3. 二次 hash 是为了配合 容量是 2 的 n 次幂 这一设计前提,如果 hash 表的容量不是 2 的 n 次幂,则不必二次 hash

  4. 容量是 2 的 n 次幂这一设计计算索引效率更好,但 hash 的分散性就不好,需要二次 hash 来作为补偿,没有采用这一设计的典型例子是 Hashtable,

  5. 在指定数组长度时传入指定大小最好为2的n次方,反之HashMap也会转化多了一部操作;

二次哈希是为了弥补2的n次幂的分布不均匀,2的n次幂是为了提高HashMap的效率,每次放取都需计算下标,所以计算效率也尤为重要

put 流程

  1. HashMap 是懒惰创建数组的,首次使用才创建数组

  2. 计算索引(桶下标)

  3. 如果桶下标还没人占用,创建 Node 占位返回

  4. 如果桶下标已经有人占用

    1. 已经是 TreeNode 走红黑树的添加或更新逻辑

    2. 是普通 Node,走链表的添加或更新逻辑,如果链表长度超过树化阈值,走树化逻辑

  5. 返回前检查容量是否超过阈值,一旦超过进行扩容,添加数据后在进行扩容

1.7 与 1.8 的区别

  1. 链表插入节点时,1.7 是头插法,1.8 是尾插法

  2. 1.7 是大于等于阈值且没有空位时才扩容,而 1.8 是大于阈值就扩容

  3. 1.8 在扩容计算 Node 索引时,会优化 把哈希值与旧的数组容量做一个按位与,如是0不用动位置,新的位置为旧的索引加旧容量

扩容(加载)因子为何默认是 0.75f

  1. 在空间占用与查询时间之间取得较好的权衡

  2. 大于这个值,空间节省了,但链表就会比较长影响性能

  3. 小于这个值,冲突减少了,但扩容就会更频繁,空间占用也更多

并发问题

在并发的情况下可能会出现数据错乱的问题,即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的设计

  1. HashMap 的 key 可以为 null,但 Map 的其他实现则不然

  2. 作为 key 的对象,必须实现 hashCode 和 equals,并且 key 的内容不能修改(不可变)

  3. 重写hashCode是为了key更好的分布性,重写equals 是为了防止在相同的hash码需要进一步进行比对

  4. hash相同但equals不一定相同,equals相同那么hash一定相同

  5. 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位是一个折中的数字。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值