Java基础-集合框架-5、HashMap
1. 数据结构(哈希表)
hashtable(哈希表)
1.1. 是什么
散列表(Hash table,也叫哈希表),是根据关键码值 (Key value) 而直接进行访问的 数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做 散列函数,存放记录的 数组 叫做 散列表
给定表 M,存在函数 f(key),对任意给定的关键字值 key,代入函数后若能得到包含该关键字的记录在表中的地址,则称表 M 为哈希 (Hash)表,函数 f(key) 为哈希 (Hash) 函数。
1.2. 特点
hash 表 也叫散列表;特点:快 很快 神奇的快
结构:结构有多种。最流行、最容易理解:顺序表 + 链表
主结构:顺序表,每个顺序表的节点在单独引出一个链表
在无序数组中按照内容查找,效率低下,时间复杂度是 O(n)
在有序数组中按照内容查找,可以使用折半查找,时间复杂度 O(log2n)
问题:按照内容查找,能否也不进行比较,而是通过计算得到地址,实现类似数组按照索引查询的高效率 O(1) 呢
有!!!哈希表来实现
1.3. 哈希函数
1.4. native hashcode 方法
[[20221029-Java Object.hashCode()返回的是对象内存地址? - 简书]]
OpenJDK8 默认 hashCode 的计算方法是通过和当前线程有关的一个随机数 + 三个确定值,运用 Marsaglia’s xorshift scheme 随机数算法得到的一个随机数。和对象内存地址无关。
1.5. 哈希碰撞 (哈希冲突) 解决方案
1.6. 装填因子
装填因子 a = 总键值对数 / 哈希表总长度
2. 版本变化
jdk7 和 jdk8 的区别
初始 table 容量
jdk7: 创建 HashMap 对象时,则初始 table 容量为 16
jdk8: 创建 HashMap 对象时,没有初始 table,仅仅只是初始加载因子。只有当第一次添加时才会初始 table 容量为 16.存储类型
jdk7: table 的类型为 Entry
jdk8: table 的类型为 Node存储结构
jdk7: 哈希表为数组 + 链表
,不管链表的总结点数是多少都不会变成树结构
jdk8:哈希表为数组 + 链表 + 红黑树
,当链表的节点数>=8 && 桶的总个数(table 的容量、数组的长度)>=64 时,会将链表结构变成红黑树结构
2.1. JDK7 头插法
3. 遍历方式
1 |
|
4. 实现逻辑
4.1. put 放入数据⭐️🔴
流程图请看 https://www.processon.com/view/635b8a0d637689731714be29?fromnew=1
当第一次添加时,将初始 table 容量为 16,临界值为 12
每次添加调用 putVal 方法:
①先获取 key 的二次哈希值与 (数组长度 n)-1 进行取与运算,得出存放的位置
②判断该存放位置上是否有元素,如果没有直接存放
如果该存放位置上已有元素,则进行继续判断:
如果和当前元素直接相等,则覆盖
如果不相等,则继续判断是否是链表结构还是树状结构,按照对应结构的判断方式判断相等
③将 size 更新,判断是否超过了临界值,如果超过了,则需要重新 resize() 进行 2 倍 扩容,并打乱原来的顺序,重新排列
重新排列,为了减少碰撞
④当一个桶中的链表的节点数>=8 && 桶的总个数(table 的容量)>=64 时,会将链表结构变成红黑树结构
4.1.1. 二次哈希
二次哈希指的是将哈希码的高 16 位与低 16 位进行异或运算得到的加工过的哈希码
4.1.2. 区别
4.2. hashCode() equals()
4.3. 加载因子
[[../../../../cubox/006-ChromeCapture/20221027-面试官:为什么 HashMap 的加载因子是0.75?-技术圈]]
加载因子为 0.75,链表长度超过 8 时,转为红黑树,原因?
均值取 0.5 时,根据泊松分布公式 ,可以得到以下结果:
1 |
|
在理想情况下,使用随机哈希码,在扩容阈值(加载因子)为 0.75 的情况下,节点出现在频率在 Hash 桶(表)中遵循参数平均为 0.5 的泊松分布。忽略方差,即 X = λt,P(λt = k),其中λt = 0.5 的情况,按公式:
计算结果如上述的列表所示,当一个 bin 中的链表长度达到 8 个元素的时候,概率为 0.00000006,几乎是一个不可能事件。
所以我们可以知道,其实常数 0.5 是作为参数代入泊松分布来计算的,而加载因子 0.75 是作为一个条件,当 HashMap 长度为 length/size ≥ 0.75 时就扩容,在这个条件下,冲突后的拉链长度和概率结果为:
1 |
|
4.3.1. 0.5 的来历
- λ 代表的就是单位时间或单位面积特定事件出现的平均次数,关于一个 key 是否发生碰撞的概率为 0.5。
- 网络推断
如果观察的事件在单位时间或单位面积出现的平均次数保持不变,并且不同时段或空间区域内事件的发生是相互独立的,那么单位时间或单位面积该事件出现的实际次数 X 服从 泊松分布(Poisson distribution) ,记作 X~P ( λ )。
具体地, X=k 的概率可表示为:
其中, λ >0。
可以进一步推导得到泊松分布的均值和方差均为 λ ,即:
因此, λ 代表的就是单位时间或单位面积特定事件出现的平均次数。
二项分布的极限是柏松分布,我们可以根据二项分布的期望 λ=np 求出 λ(n 是实验的次数,p 是单次的概率)。如果 n 比较大,p 比较小,所以我们才说满足泊松分布的条件。
4.3.2. 与 8 的关系
时间和空间成本的权衡 ==> 决定了扩容因子是 0.75 ==> 决定了泊松分布的参数λ是 0.5 ==> 计算出泊松分布结果为 8 时,概率为亿分之六 ==> 决定了树化节点为 8.
[[../../../../cubox/006-ChromeCapture/20221028-从泊松分布谈起HashMap为什么默认扩容因子是0.75 - 知乎]]
4.4. 树化
1 |
|
1 |
|
1 |
|
桶的个数超过 64,桶就是数组
链表长度超过 8
4.5. 链化
基本思想是当红黑树中的元素减少并小于一定数量时,会切换回链表。而元素减少有两种情况:
- 1、调用 map 的 remove 方法删除元素
hashMap 的 remove 方法,会进入到 removeNode 方法,找到要删除的节点,并判断 node 类型是否为 treeNode,然后进入删除红黑树节点逻辑的 removeTreeNode 方法中,该方法有关解除红黑树结构的分支如下:
1 |
|
可以看到,此处并没有利用到网上所说的,当节点数小于UNTREEIFY_THRESHOLD时才转换,而是通过红黑树根节点及其子节点是否为空来判断
。而满足该条件的最大红黑树结构如下: 节点数为 10,大于 UNTREEIFY_THRESHOLD(6),但是根据该方法的逻辑判断,还是需要转换为链表的。
- 2、resize 的时候,对红黑树进行了拆分
resize 的时候,判断节点类型,如果是链表,则将链表拆分,如果是 TreeNode,则执行 TreeNode 的 split 方法分割红黑树,而 split 方法中将红黑树转换为链表的分支如下:
1 |
|
这里才用到了 UNTREEIFY_THRESHOLD
的判断,当红黑树节点元素小于等于 6 时,才调用 untreeify 方法转换回链表
[[../../../../cubox/006-ChromeCapture/20221027-jdk1.8的hashmap真的是大于8就转换成红黑树,小于6就变成链表吗 - 掘金]]
4.6. resize(rehash)
当 HashMap 中的元素越来越多的时候,hash 冲突的几率也就越来越高,因为数组的
长度是固定的。所以为了提高查询的效率,就要对 HashMap 的数组进行扩容,数组扩容
这个操作也会出现在 ArrayList 中,这是一个常用的操作,而在 HashMap 数组扩容之后,
最消耗性能的点就出现了:原数组中的数据必须重新计算其在新数组中的位置,并放进去,
这就是 resize。
那么 HashMap 什么时候进行扩容呢?当 HashMap 中的元素个数超过数组大小
loadFactor 时,就会进行数组扩容,loadFactor 的默认值为 0.75,这是一个折中的取值。
也就是说,默认情况下,数组大小为 16,那么当 HashMap 中元素个数超过 16*0.75
=12
的时候,就把数组的大小扩展为 2*16
=32,即扩大一倍,然后重新计算每个元素在数组中
的位置,而这是一个非常消耗性能的操作,所以如果我们已经预知 HashMap 中元素的个
数,那么预设元素的个数能够有效的提高 HashMap 的性能。
HashMap 在进行扩容时,使用的 rehash 方式非常巧妙,因为每次扩容都是翻倍,与原来计算 (n-1)& hash
的结果相比,只是多了一个 bit 位,所以节点要么就在原来的位置,要么就被分配到“原位置 + 旧容量”这个位置。
例如,原来的容量为 32,那么应该拿 hash 跟 31(0x11111)做与操作;在扩容扩到了 64 的容量之后,应该拿 hash 跟 63(0x111111)做与操作。新容量跟原来相比只是多了一个 bit 位,假设原来的位置在 23,那么当新增的那个 bit 位的计算结果为 0 时,那么该节点还是在 23;相反,计算结果为 1 时,则该节点会被分配到 23+31 的桶上。
正是因为这样巧妙的 rehash 方式,保证了 rehash 之后每个桶上的节点数必定小于等于原来桶上的节点数,即保证了 rehash 之后不会出现更严重的冲突。
[[../../../../cubox/006-ChromeCapture/20221028-HashMap常见问题_Android_la的博客-CSDN博客]]
[[../../../../cubox/006-ChromeCapture/20221028-史上最全HashMap面试题汇总_原野的稻草人的博客-CSDN博客_hashmap面试题]]
4.7. rehash
4.8. Fail-Fast 机制-modCount
❕ ^43oza9
我们知道 java.util.HashMap 不是线程安全的,因此如果在使用迭代器的过程中有其他线程修改了 map,那么将抛出 ConcurrentModificationException,这就是所谓 fail-fast策略。
这一策略在源码中的实现是通过 modCount 域,modCount 顾名思义就是修改次数,对 HashMap 内容的修改都将增加这个值,那么在迭代器初始化过程中会将这个值赋给迭代器的 expectedModCount。
在迭代过程中,判断 modCount 跟 expectedModCount 是否相等,如果不相等就表示已经有其他线程修改了 Map:
注意到 modCount 声明为 volatile,保证线程之间修改的可见性。
这里说的 volatile 是在 JDK5 和 JDK6 的时候,也许是正确的,因为在 JDK5 和 JDK6 中变量 modCount 确实声明为 volatile。但在 JDK7 和 JDK8 中,已经没有这样声明了!!!!!
由所有 HashMap 类的“collection 视图方法”所返回的迭代器都是快速失败的:在迭代器创建之后,如果从结构上对映射进行修改,除非通过迭代器本身的 remove 方法,其他任何时间任何方式的修改,迭代器都将抛出ConcurrentModificationException。因此,面对并发的修改,迭代器很快就会完全失败,而不冒在将来不确定的时间发生任意不确定行为的风险。
注意,迭代器的快速失败行为不能得到保证,一般来说,存在非同步的并发修改时,不可能作出任何坚决的保证。快速失败迭代器尽最大努力抛出 ConcurrentModificationException。因此,编写依赖于此异常的程序的做法是错误的,正确做法是:迭代器的快速失败行为应该仅用于检测程序错误。
4.8.1. 其他作用
ConcurrentHashMap 中 size 方法中用来判断是否是稳定有效的 size,最少 2 次比较,最多 4 次,4 次后加锁
4.9. 扩容死锁与环链行程
[[20221029-JDK1.7版本HashMap的源码分析_老dubbo的博客-CSDN博客]]
https://blog.csdn.net/aidaowuqiong/article/details/103492264
https://www.bilibili.com/video/BV1mT4y177YC/?vd_source=c5b2d0d7bc377c0c35dbc251d95cf204
4.10. 扩容优化
4.10.1. 区别
4.10.2. 哪些位置需要换桶
[[../../../../cubox/006-ChromeCapture/20221104-HashMap扩容时的rehash方法中(e.hash & oldCap) == 0详解 - CodeAntenna]]
4.11. null 键 null 值
HashMap 是基于哈希表的 Map 接口的非同步实现。此实现提供所有可选的映射操作,并允许使用 null 值 (多个) 和 null 键 (1 个)。此类不保证映射的顺序,特别是它不保证该顺序恒久不变。
1 |
|
4.12. 2 的 N 次方⭐️🔴
4.12.1. 原因
新索引值=二次哈希值 &(当前容量 -1)
之所以底层数组的长度是 2 的整数次幂,是因为让按位与运算与取模运算结果相等
原因分析:
提高 hash 效率:因为这里的哈希算法是用的除留取余法,即要做取模运算,为了提高效率,转变为按位与运算,而要想结果相等,就要求容量值为 2 的整数次幂,否则除留取余无法转变成按位与运算。
运算相比于取模运算速度大幅度提升 (按照 Bruce Eckel 给出的数据,大约可以提升 5~8 倍)。充分利用空间:如果不是 2 的 n 次方,有些位置可能为 0,与任何数做与运算都是 0,导致无法获取到数组的某些角标位置,造成空间浪费。
减少 hash 碰撞:为 2 的 n 次方时,2n-1 得到的二进制数的每个位上的值都为 1,这使得在低位上&时,得到的和原 hash 的低位相同,加之 hash(int h) 方法对 key 的 hashCode 的进一步优化,加入了高位计算,就使得只有相同的 hash 值的两个值才会被放到数组中的同一个位置上形成链表。所以说,当数组长度为 2 的 n 次幂的时候,不同的 key 算得得 index 相同的几率较小,那么数据在数组上分布就比较均匀,也就是说碰撞的几率小,相对的,查询的时候就不用遍历某个位置上的链表,这样查询效率也就较高了。
扩容判断优化:1.8 中的高低位也用到了 n-1 低位都为 1 的特性,即上文所说,每次扩容都是翻倍,与原来计算
(n-1)& hash
的结果相比,只是多了一个 bit 位,所以节点要么就在原来的位置,要么就被分配到“原位置 + 旧容量”这个位置。4.12.2. 源码
5. 红黑树
红黑树是平衡二叉树的一种实现方式,另一种实现方式是最初的 AVL 树。
TreeSet TreeMap 中里都是红黑树
6. hash 索引与 B 树索引的区别
Hash 索引结构的特殊性,其检索效率非常高,索引的检索可以一次定位,不像 B-Tree 索引需要从根节点到枝节点,最后才能访问到页节点这样多次的 IO 访问,所以 Hash 索引的查询效率要远高于 B-Tree 索引。
(1)Hash 索引仅仅能满足”=”,”IN” 和”<=>” 查询,不能使用范围查询。
由于 Hash 索引比较的是进行 Hash 运算之后的 Hash 值,所以它只能用于等值的过滤,不能用于基于范围的过滤,因为经过相应的 Hash 算法处理之后的 Hash 值的大小关系,并不能保证和 Hash 运算前完全一样。
(2)Hash 索引无法被用来避免数据的排序操作。
由于 Hash 索引中存放的是经过 Hash 计算之后的 Hash 值,而且 Hash 值的大小关系并不一定和 Hash 运算前的键值完全一样,所以数据库无法利用索引的数据来避免任何排序运算;
(3)Hash 索引不能利用部分索引键查询。
对于组合索引,Hash 索引在计算 Hash 值的时候是组合索引键合并后再一起计算 Hash 值,而不是单独计算 Hash 值,所以通过组合索引的前面一个或几个索引键进行查询的时候,Hash 索引也无法被利用。
(4)Hash 索引在任何时候都不能避免表扫描。
前面已经知道,Hash 索引是将索引键通过 Hash 运算之后,将 Hash 运算结果的 Hash 值和所对应的行指针信息存放于一个 Hash 表中,由于不同索引键存在相同 Hash 值,所以即使取满足某个 Hash 键值的数据的记录条数,也无法从 Hash 索引中直接完成查询,还是要通过访问表中的实际数据进行相应的比较,并得到相应的结果。
(5)Hash 索引遇到大量 Hash 值相等的情况后性能并不一定就会比 B-Tree 索引高。
对于选择性比较低的索引键,如果创建 Hash 索引,那么将会存在大量记录指针信息存于同一个 Hash 值相关联。这样要定位某一条记录时就会非常麻烦,会浪费多次表数据的访问,而造成整体性能低下。
7. 各种比较
底层结构 版本 线程安全(同步) 允许null键null值
底层结构:哈希表
jdk7: 数组 + 链表
jdk8: 数组 + 链表 + 红黑树
源码分析:
jdk8: HashMap 中维护了 Node 类型的数组 table,当 HashMap 创建对象时,只是对 loadFactor 初始化为 0.75;table 还是保持默认值 null
当第一次添加时,将初始table容量为16,临界值为12 每次添加调用putVal方法: ①先获取key的二次哈希值并进行取与运算,得出存放的位置 ②判断该存放位置上是否有元素,如果没有直接存放 如果该存放位置上已有元素,则进行继续判断: 如果和当前元素直接相等,则覆盖 如果不相等,则继续判断是否是链表结构还是树状结构,按照对应结构的判断方式判断相等 ③将size更新,判断是否超过了临界值,如果超过了,则需要重新resize()进行2倍扩容,并打乱原来的顺序,重新排列 ④<span style="background-color:#ff00ff">当一个桶中的链表的节点数>=8 && 桶的总个数(table的容量)>=64时,会将链表结构变成红黑树结构</span>
jdk7 和 jdk8 的区别
1.jdk7: 创建 HashMap 对象时,则初始 table 容量为 16
jdk8: 创建 HashMap 对象时,没有初始 table,仅仅只是初始加载因子。只有当第一次添加时才会初始 table 容量为 16.
2.jdk7:table的类型为Entry
jdk8:table的类型为Node
3.jdk7:哈希表为数组+链表,不管链表的总结点数是多少都不会变成树结构
jdk8:哈希表为数组+链表+红黑树,当链表的节点数>=8 && 桶的总个数(table的容量)>=64时,会将链表结构变成红黑树结构
应用层面:
要求添加元素的key重写hashCode和equals方法
8. 泊松分布
9. 特点
10. 防止哈希碰撞的措施⭐️🔴
* JDK 1.7 做了9次扰动处理 = 4次位运算 + 5次异或运算
* JDK 1.8 简化了扰动函数 = 只做了2次扰动 = 1次位运算 + 1次异或运算
1 |
|
11. 参考
11.1. 黑马
11.1.1. 视频
11.1.2. 资料
1 |
|
11.2. old
Java 并发编程全套视频教程(JMM+AQS+synchronized+hashmap)
https://www.bilibili.com/video/BV17c411h7Vw/?spm_id_from=333.337.search-card.all.click&vd_source=c5b2d0d7bc377c0c35dbc251d95cf204
https://juejin.im/post/5aa5d8d26fb9a028d2079264
[[../../../../cubox/006-ChromeCapture/20221028-Java源码分析:HashMap 1.8 相对于1.7 到底更新了什么? - 掘金]]
https://www.bilibili.com/video/BV15Q4y1e7q6?p=6&vd_source=c5b2d0d7bc377c0c35dbc251d95cf204
[[../../../../cubox/006-ChromeCapture/20221104-Java 8系列之重新认识HashMap - 美团技术团队]]