HashMap设计思想学习

news/2024/11/28 0:46:55/

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(log2n),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<K,V> e : table) {while(null != e) {Entry<K,V> 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<Student, Object> 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^0S031(n1)+S131(n2)+Si31(n1i)+S(n1)310
  • 31 代入公式有较好的散列特性,并且 31 * h 可以被优化为
    • 即 $32 ∗h -h $
    • 25∗h−h2^5 ∗h -h25hh
    • h≪5−hh≪5 -hh5h


http://www.ppmy.cn/news/23973.html

相关文章

【直击招聘C++】2.5 this指针

2.5 this指针一、要点归纳1.什么是this指针2.this指针的深入讨论程序1程序23.类成员函数返回对象和返回对象引用的区别二、面试真题解析面试题1面试题2一、要点归纳 1.什么是this指针 this指针是隐含于每一个类对象的特殊指针&#xff0c;该指针值是一个正在被某个成员函数操作…

C++类和对象(下)

✨个人主页&#xff1a; Yohifo &#x1f389;所属专栏&#xff1a; C修行之路 &#x1f38a;每篇一句&#xff1a; 图片来源 I do not believe in taking the right decision. I take a decision and make it right. 我不相信什么正确的决定。我都是先做决定&#xff0c;然后把…

零代码做分析报表的bi软件才是好软件

有些数据分析软件对IT的依赖比较重&#xff0c;在制作报表的过程中需要用到SQL&#xff0c;这就导致了IT人员懂技术不懂业务&#xff0c;业务人员懂业务不懂技术&#xff0c;数据分析做来做去总是差点什么的局面。要是遇到了IT部门相对较弱的情况&#xff0c;还会加重IT负担&am…

基于C#制作一个飞机大战小游戏

此文主要基于C#制作一个飞机大战游戏&#xff0c;重温经典的同时亦可学习。 实现流程1、创建项目2、界面绘制3、我方飞机4、敌方飞机5、子弹及碰撞检测实现流程 1、创建项目 打开Visual Studio&#xff0c;右侧选择创建新项目。 搜索框输入winform&#xff0c;选择windows窗体…

GPR后期功能整理

基金本子写得太困难了&#xff0c;学术水平不够&#xff0c;好的想法未想到好的科学问题&#xff0c;难以下笔。和龙工沟通后&#xff0c;得到了大量impulse radar的数据&#xff0c;后期需要进行分析&#xff0c;从而能让GPR智能识别走得更远。从数据解译角度&#xff0c;找到…

前端JavaScript获取图片文件的真实格式

常见方式判断图片格式 当我们进行前端开发&#xff0c;需要处理图片上传功能&#xff0c;针对图片格式做判断时&#xff0c;常规的方法都是使用文件后缀名来判断&#xff0c;如下代码所示&#xff1a; input.addEventListener(change, (e) > {const file e.target.files[…

函数栈帧的创建和销毁——“C”

各位CSDN的uu们你们好呀&#xff0c;今天小雅兰来为大家介绍一个知识点——函数栈帧的创建和销毁。其实这个知识点&#xff0c;我们很早之前就要讲&#xff0c;但是因为我的一系列原因&#xff0c;才一直拖到了现在&#xff0c;那么&#xff0c;话不多说&#xff0c;让我们一起…

canal使用说明:MySQL、Redis实时数据同步

1. canal简介 canal是阿里开源的数据同步工具&#xff0c;基于bin log可以将数据库同步到其他各类数据库中&#xff0c;目标数据库支持mysql,postgresql,oracle,redis,MQ,ES等 canal分成服务端deployer和客户端adapter&#xff0c;我们可以部署多个&#xff0c;同时为了方便管…