HashMap设计思想学习
创始人
2024-05-25 09:46:33
0

HashMap设计思想学习

  • 引言
  • 树化与退化
  • 红黑树的优势
  • 索引计算
  • put流程
  • 扩容(加载)因子为何默认是 0.75f
  • hashMap并发丢失数据问题
  • jdk 1.7并发死链问题
  • key 的设计


引言

hashmap在jdk 1.7之前是数组+链表结构,而jdk1.8之后变为是数组+(链表|红黑树)


树化与退化

树化意义:

  • 红黑树用来避免 DoS 攻击,防止链表超长时性能下降,树化应当是偶然情况,是保底策略
  • hash 表的查找,更新的时间复杂度是 O(1)O(1)O(1),而红黑树的查找,更新的时间复杂度是 O(log2⁡n)O(log_2⁡n )O(log2​⁡n),TreeNode 占用空间也比普通 Node 的大,如非必要,尽量还是使用链表
  • hash 值如果足够随机,则在 hash 表内按泊松分布,在负载因子 0.75 的情况下,长度超过 8 的链表出现概率是 0.00000006,树化阈值选择 8 就是为了让树化几率足够小

树化规则

  • 当链表长度超过树化阈值 8 时,先尝试扩容来减少链表长度,如果数组容量已经 >=64,才会进行树化

退化规则

  • 情况1:在扩容时如果拆分树时,树元素个数 <= 6 则会退化链表
  • 情况2:remove 树节点时,若 root、root.left、root.right、root.left.left (根节点的左右孩子和左孙子)有一个为 null ,也会退化为链表

总结:

通常情况下,除非是恶意伪造大量hash相同的元素,否则一般情况下链表长度最长也就是6左右,但是为了应对恶意伪造数据进行攻击的情况,引入了红黑树在链表长度达到指定阈值8时,进行替换;当然,如果此时数组长度没有大于等于64,那么会先尝试通过扩容数组大小来减少链表长度。扩容时,如果某个树的元素个数小于了6,那么红黑树会退化为链表,或者红黑树根节点的左右孩子或者左孙子中有一个为null,也会退化为链表。


红黑树的优势

传统的BST二叉搜索树需要满足根节点大于左子树小于右子树的条件,并且查询和插入复杂度为0(logn),但是极端情况下二叉搜索树会退化为线性结构,此时查询和插入复杂度变为o(n)。

AVL自平衡二叉树在二叉搜索树的基础上进行了优化,需要满足左右子树的高度差小于等于1,AVL树的最差查询和插入复杂度也为O(logn)。

AVL自平衡二叉树对"平衡"的定义非常严格,在插入和删除非常频繁的场景下,会产生大量的旋转操作,性能会受到很大影响。

红黑树:

1.每个节点非红即黑

2.root节点必须是黑节点

3.null节点会被视为叶子节点,叶子节点必须是黑节点

4.红节点的子节点必须是黑节点

5.新插入的节点是红节点

6.从任意一个节点出发到叶子节点的任意路径上,黑色节点的个数都是相等的

红黑树和AVL树所要求的严格的"平衡"条件不同,红黑树最大允许左右节点数相差一倍(左子树全部都是黑色节点,右子树一红一黑), 红黑树对"平衡"的条件要求不是那么苛刻,因此红黑树在插入和删除节点时,产生的旋转变换操作会更少,性能更高,并且红黑树整体复杂度依然为0(logn);


索引计算

索引计算方法

  • 首先,计算对象的 hashCode()
  • 再进行调用 HashMap 的 hash() 方法进行二次哈希
    • 二次 hash() 是为了综合高位数据,让哈希分布更为均匀
  • 最后 & (capacity – 1) 得到索引

hashmap的二次哈希方法中会对key的hashcode进行扰动计算,防止不同的hashcode的高位不同但低位相同导致hash冲突。简单来说,就是为了把高位的特征和低位的特征组合起来,降低哈希冲突的概率,也就是说,尽量做到任何一位的变化都能对最终得到的结果产生影响。

数组容量为何是 2 的 n 次幂

  1. 计算索引时效率更高:如果是 2 的 n 次幂可以使用位与运算代替取模
  2. 扩容时重新计算索引效率更高: hash & oldCap == 0 的元素留在原来位置 ,否则新位置 = 旧位置 + oldCap

注意

  • 二次 hash 是为了配合 容量是 2 的 n 次幂 这一设计前提,如果 hash 表的容量不是 2 的 n 次幂,则不必二次 hash
  • 容量是 2 的 n 次幂 这一设计计算索引效率更好,但 hash 的分散性就不好,需要二次 hash 来作为补偿,没有采用这一设计的典型例子是 Hashtable

偶数对偶数进行取模得到的结果还是偶数,因此容量为2的n次幂最大的缺点就是hash分布不均匀,因此,如果追求的是更好的hash分散性,应该采用质数作为数组容量; 但是2的n次幂可以提供很多优化特性,追求性能,还是可以考虑的。


put流程

1.hashMap是懒惰创建数组的,首次使用时才会创建
2.计算索引(桶下标)–>hashcode–>二次哈希–>与运算
3.如果桶下标没人占用,创建Node占位返回
4.如果桶下标已经被占用了
4.1 已经是TreeNode走红黑树添加或者更新逻辑
4.2 是普通的Node,走连接的添加或更新逻辑
4.2.1 如果链表长度超过树化阈值8,并且当前数组容量是小于64,那么会首先通过扩容,减少链表长度
4.2.2 如果链表长度超过树化阈值8,并且当前数组容器是大于等于64,那么会将链表转换为红黑树,走树化逻辑
5.返回前检查容量是否超过扩容阈值,一旦超过进行扩容

jdk 1.7和jdk1.8的不同:

1.插入节点时,jdk1.7采用头插法,1.8采用尾插法
2…1.7是大于等于阈值并且计算出的索引不是空位的情况下才进行扩容,而1.8是大于阈值就扩容
3. 1.7和1.8在在扩容计算Node索引时进行优化,会通过 hash & oldCap == 0 的元素留在原来位置 ,否则新位置 = 旧位置 + oldCap


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

  1. 在空间占用与查询时间之间取得较好的权衡
  2. 大于这个值,空间节省了,但链表就会比较长影响性能
  3. 小于这个值,冲突减少了,但扩容就会更频繁,空间占用也更多

hashMap并发丢失数据问题

hashMap本身并不是线程安全的,所以无论是jdk 1.7 还是 jdk 1.8,都存在并发丢失数据的风险:
在这里插入图片描述
如果存在两个并发线程1和2都同时向hashmap中put一对键值对,并且key计算出来的hash值都是相同的,那么线程1和线程2同时来到判断索引位是否为空的逻辑,发现为空,填充数据,此时就存在线程2覆盖线程1的数据导致丢失数据的风险。


jdk 1.7并发死链问题

1.7 源码如下:

void transfer(Entry[] newTable, boolean rehash) {int newCapacity = newTable.length;for (Entry e : table) {while(null != e) {Entry next = e.next;if (rehash) {e.hash = null == e.key ? 0 : hash(e.key);}int i = indexFor(e.hash, newCapacity);e.next = newTable[i];newTable[i] = e;e = next;}}
}
  • e 和 next 都是局部变量,用来指向当前节点和下一个节点
  • 线程1(绿色)的临时变量 e 和 next 刚引用了这俩节点,还未来得及移动节点,发生了线程切换,由线程2(蓝色)完成扩容和迁移

在这里插入图片描述

  • 线程2 扩容完成,由于头插法,链表顺序颠倒。但线程1 的临时变量 e 和 next 还引用了这俩节点,还要再来一遍迁移

在这里插入图片描述
第一次循环

  • 循环接着线程切换前运行,注意此时 e 指向的是节点 a,next 指向的是节点 b
  • e 头插 a 节点,注意图中画了两份 a 节点,但事实上只有一个(为了不让箭头特别乱画了两份)
  • 当循环结束是 e 会指向 next 也就是 b 节点

在这里插入图片描述
第二次循环

  • next 指向了节点 a
  • e 头插节点 b
  • 当循环结束时,e 指向 next 也就是节点 a

在这里插入图片描述
第三次循环

  • next 指向了 null
  • e 头插节点 a,a 的 next 指向了 b(之前 a.next 一直是 null),b 的 next 指向 a,死链已成
  • 当循环结束时,e 指向 next 也就是 null,因此第四次循环时会正常退出

在这里插入图片描述
总结: 由于jdk 1.7的hashmap采用的是头插法,所以当存在两个并发线程同时尝试对hash数组进行扩容时,会出现线程1先扩容完毕,将原本的元素A指向B顺序,颠倒为B指向A。而由于线程2无法感知该变化,因此依然按照A指向B的顺序迁移元素到新数组,最终会产生a指向b,b指向a的死链。


key 的设计

key 的设计要求

  1. HashMap 的 key 可以为 null,但 Map 的其他实现则不然
  2. 作为 key 的对象,必须实现 hashCode 和 equals,并且 key 的内容不能修改(不可变)
  3. key 的 hashCode 应该有良好的散列性

如果 key 可变,例如修改了 age 会导致再次查询时查询不到

public class HashMapMutableKey {public static void main(String[] args) {HashMap map = new HashMap<>();Student stu = new Student("张三", 18);map.put(stu, new Object());System.out.println(map.get(stu));stu.age = 19;System.out.println(map.get(stu));}static class Student {String name;int age;public Student(String name, int age) {this.name = name;this.age = age;}public String getName() {return name;}public void setName(String name) {this.name = name;}public int getAge() {return age;}public void setAge(int age) {this.age = age;}@Overridepublic boolean equals(Object o) {if (this == o) return true;if (o == null || getClass() != o.getClass()) return false;Student student = (Student) o;return age == student.age && Objects.equals(name, student.name);}@Overridepublic int hashCode() {return Objects.hash(name, age);}}
}

String 对象的 hashCode() 设计

  • 目标是达到较为均匀的散列效果,每个字符串的 hashCode 足够独特
  • 字符串中的每个字符都可以表现为一个数字,称为 SiS_iSi​,其中 i 的范围是 0 ~ n - 1
  • 散列公式为: S0∗31(n−1)+S1∗31(n−2)+…Si∗31(n−1−i)+…S(n−1)∗310S_0∗31^{(n-1)}+ S_1∗31^{(n-2)}+ … S_i ∗ 31^{(n-1-i)}+ …S_{(n-1)}∗31^0S0​∗31(n−1)+S1​∗31(n−2)+…Si​∗31(n−1−i)+…S(n−1)​∗310
  • 31 代入公式有较好的散列特性,并且 31 * h 可以被优化为
    • 即 $32 ∗h -h $
    • 即 25∗h−h2^5 ∗h -h25∗h−h
    • 即 h≪5−hh≪5 -hh≪5−h

相关内容

热门资讯

监控摄像头接入GB28181平... 流程简介将监控摄像头的视频在网站和APP中直播,要解决的几个问题是:1&...
Windows10添加群晖磁盘... 在使用群晖NAS时,我们需要通过本地映射的方式把NAS映射成本地的一块磁盘使用。 通过...
protocol buffer... 目录 目录 什么是protocol buffer 1.protobuf 1.1安装  1.2使用...
在Word、WPS中插入AxM... 引言 我最近需要写一些文章,在排版时发现AxMath插入的公式竟然会导致行间距异常&#...
修复 爱普生 EPSON L4... L4151 L4153 L4156 L4158 L4163 L4165 L4166 L4168 L4...
【PdgCntEditor】解... 一、问题背景 大部分的图书对应的PDF,目录中的页码并非PDF中直接索引的页码...
Fluent中创建监测点 1 概述某些仿真问题,需要创建监测点,用于获取空间定点的数据࿰...
educoder数据结构与算法...                                                   ...
MySQL下载和安装(Wind... 前言:刚换了一台电脑,里面所有东西都需要重新配置,习惯了所...
MFC文件操作  MFC提供了一个文件操作的基类CFile,这个类提供了一个没有缓存的二进制格式的磁盘...