总结HashMap的知识点前,先思考以下问题:
目录
上篇:理解Hash
- Hash是什么?
- 为什么有Hash的出现?
- 常见的Hash算法有哪些?
- Hash冲突怎么解决?
- Hash的应用场景有哪些?
- 如何自己实现一个Hash算法?
下篇:HashMap
- HashMap是什么?
- eques和hashcode在HashMap中扮演什么角色?
- HashMap的hash函数怎么保证数据均衡?
- HashMap冲突怎么解决?
- HashMap的扩容问题:扩容为什么每次是2的幂?
- HashMap的线程安全问题发生在什么时候?
- HasHMap使用注意事项?
- 让你实现一个HasHMap你怎么做?
- HasHMap你演变会朝着什么样的方向发展?
上篇:理解Hash
1. 定义
Hash是一个函数,每个不同的输入,输出不同的Hash值;
Hash函数的质量评价标准有:
-
分散性:Hash值是否分散的存在于整个Hash值域空间
-
负载:尽量避免大量Key计算出相同的Hash值
-
单调性:Hash值是否能够基本上具有递增的特点
-
平滑性:当Hash值域扩容后,原有的Hash值不会变动太大
2.为什么有Hash的出现?
内存数据的第一次直观抽象
在计算机内存资源中,只要你拿到内存空间对应的地址,你就能干任何你想做的事,修改内存的值,删除值
我把数据和链表理解位内存数据的第一次直观抽象,把操作单个内存空间演进到操作一个集合
数组的特点
- 开辟一块连续的内存空间
- 初始化固定大小的空间
- 查询通过下标定位数据,效率高
- 删除数据需要移动数据,效率低
- 插入数据需要移动数据,效率低
- 数据可重复
链表的特点
- 开辟一块非连续的内存空间
- 不用初始化固定大小的空间,理论空间不限
- 查询每次只能通过遍历链表搜索数据,效率低
- 删除数据不需要移动数据,只需要修改链表指针,效率高
- 插入数据不需要移动数据,只需要修改链表指针,效率高
- 数据可重复
数组、链表和Hash有什么关系呢???
因为数组和链表的特性已经不能同时满足一些需求场景,在此基础上演进出一种新结构Map
Map存放键值对数据,也称作Key-value结构;
如果我们的数据都通过Key来操作,那么需要一个Hash函数将key和value的地址做一个映射
key的Hash作为区别两个value是否相等的关键
于是hash函数上场了,可以理解为将两种不同类型的数据做一次映射,当然你可以做多次
HashMap的特点有
-
开辟一块非连续的内存空间
-
不用初始化固定大小的空间,理论空间不限
-
查询每次key的hash计算value的位置,直接定位数据,效率高
-
删除数据不需要移动数据,通过key的hash计算value的位置,然后删除,效率高
-
插入数据不需要移动数据,通过key的hash计算value的位置,然后插入,效率高
-
数据可重复,重复key会覆盖之前的数据
-
Key的选择建议具有分散性和区别性
数组、链表和HashMap我们来做一次比较
序号 | 特点 | 数组 | 链表 | HashMap |
---|---|---|---|---|
1 | 连续的内存空间 | 是 | 否 | 否 |
2 | 初始化固定大小空间 | 是 | 否 | 否 |
3 | 增加数据效率 | 高 | 高 | 高 |
4 | 删除数据效率 | 低 | 高 | 高 |
5 | 修改数据 | 高 | 高 | 高 |
6 | 查询数据效率 | 高 | 低 | 高 |
7 | 插入固定位置非尾部数据 | 低 | 高 | 高 |
8 | 数据可以重复 | 是 | 是 | 是 |
3.常见的Hash算法有哪些
原文链接:https://blog.csdn.net/asdzheng/article/details/70226007
**1. 直接寻址法:**取keyword或keyword的某个线性函数值为散列地址。即H(key)=key或H(key) = a•key + b,当中a和b为常数(这样的散列函数叫做自身函数)
**2. 数字分析法:**分析一组数据,比方一组员工的出生年月日,这时我们发现出生年月日的前几位数字大体同样,这种话,出现冲突的几率就会非常大,可是我们发现年月日的后几位表示月份和详细日期的数字区别非常大,假设用后面的数字来构成散列地址,则冲突的几率会明显减少。因此数字分析法就是找出数字的规律,尽可能利用这些数据来构造冲突几率较低的散列地址。
**3. 平方取中法:**取keyword平方后的中间几位作为散列地址。
**4. 折叠法:**将keyword切割成位数同样的几部分,最后一部分位数能够不同,然后取这几部分的叠加和(去除进位)作为散列地址。
**5. 随机数法:**选择一随机函数,取keyword的随机值作为散列地址,通经常使用于keyword长度不同的场合。
**6. 除留余数法:**取keyword被某个不大于散列表表长m的数p除后所得的余数为散列地址。即 H(key) = key MOD p, p<=m。不仅能够对keyword直接取模,也可在折叠、平方取中等运算之后取模。对p的选择非常重要,一般取素数或m,若p选的不好,easy产生同义词。
目前流行的 Hash 算法包括 MD5、SHA-1 和 SHA-2。
-
MD4(RFC 1320)是 MIT 的 Ronald L. Rivest 在 1990 年设计的,MD 是 Message Digest 的缩写。其输出为 128 位。MD4 已证明不够安全。
-
MD5(RFC 1321)是 Rivest 于1991年对 MD4 的改进版本。它对输入仍以 512 位分组,其输出是 128 位。MD5 比 MD4 复杂,并且计算速度要慢一点,更安全一些。MD5 已被证明不具备”强抗碰撞性”。
-
SHA (Secure Hash Algorithm)是一个 Hash 函数族,由 NIST(National Institute of Standards and Technology)于 1993 年发布第一个算法。目前知名的 SHA-1 在 1995 年面世,它的输出为长度 160 位的 hash 值,因此抗穷举性更好。SHA-1 设计时基于和 MD4 相同原理,并且模仿了该算法。SHA-1 已被证明不具”强抗碰撞性”。
为了提高安全性,NIST 还设计出了 SHA-224、SHA-256、SHA-384,和 SHA-512 算法(统称为 SHA-2),跟 SHA-1 算法原理类似。SHA-3 相关算法也已被提出。
4.常用的散列冲突解决方法
常用的散列冲突解决方法有两类,开放寻址法(open addressing)和链表法(chaining)
- 开放寻址法(open addressing)
- 线性探测法
- 二次探测(Quadratic probing
- 双重散列(Double hashing)
- 链表法
参考:
5.Hash的应用场景有哪些?
- 哈希算法是密码学的主要算法
- 散列表来构造HashMap
- Hash做数据库索引
- 一致性Hash环(本质还是hash)做分库分表
- 一致性Hash环(本质还是hash)做缓存
- 负载均衡
- 很多分布式系统的分片算法
6.如何自己实现一个hash函数?
-
我需要在什么样的场景使用hash函数?
-
我的key集合的分布特点,和其它特点
-
hash函数设计
- 函数的性能损耗多少
-
hash冲突怎么解决
下篇:HashMap
1.HashMap是什么?
理解HashMap先了解以下关键点
- HashMap存储key-value的数据结构
- HashMap的key和value的地址映射关系通过一个Hash函数做转换
- HashMap采用链接地址法解决Hash冲突也就是我们常说的数组+链表的数据结构
- HashMap在Jdk8中新增了树形结构来解决大量冲突问题
- HashMap是非线程安全的版本
你如果对上面的几点都了然于心的话,基本上你对HashMap已经掌握至少70%了
2.eques,hashcode和HashMap关系
每个Java对象都是Object的子类,Object有一个hashCode()方法,等于每个Java对象都有一个hashCode()方法;
每个Java对象调用自己的hashCode()方法返回的hash值都是不一样的
两个对象通过eques方法比较是否相等,内部实现是通过比较两个对象的hashCode()方法的返回值hash值来判断
凡事有个例外:当一个对象,如studentA,他的年龄变化后,这两个对象是不是相等呢,在业务中,这两个对象可能是不想等的,我们需要将年龄作为比较维度,这个时候我们就需要重写eques()、hashcode()方法
HashMap中存的key-value结构的值,通过比较key的hashcode()方法的返回值来判断是否相等,根据不同业务场景我们可以按照需要重写eques()、hashcode()方法
通过上面几点,不知道你是不是已经明白了 eques、hashcode、HashMap三者之间的关系
3.HashMap的hashCode()方法
根据前面介绍已经知道hashCode()方法对于HashMap的作用了
接下来我们介绍一下HashMap的hashCode的实现
- Object的hashCode()方法实现:官方默认策略,通过位移动生成随机数
详见:resource\Java中hashCode是怎么来的.md - HashMap重写了hashCode()方法:(key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16)
- h >>> 16让hash值的二进制中的高位中1移动到低位,让1分布均衡,让高位影响hash值的均衡性变小
- hash值分布更均衡后,碰撞的概率就变小
- **^ 异或:**XOR函数为50%0和50%1,因此对于合并均匀概率分布非常有用
- 该实现是通过一部分系统性能损耗换取hash值的均衡性,且通过异或的方法性价比最高
- 绝大多数情况key的hash值已经分布均衡了,只有少数需要通过转换来增加均衡性
- 如果通过异或转换还是有大量冲突,我们用树形结构来解决
- 所以我理解的HashMap的hashCode()方法采用2种思路来解决冲突
- 通过异或转换提高hash的分散性
- 如果已经冲突了,采取树形结构作为补措施
- 第一堵墙是根本上解决冲突
- 第二堵墙是第一堵墙崩塌后的补救措施
- 参考
4.HashMap冲突怎么解决?
提高hash的分散性
如前面的解释,HashMap的hashCode()方法怎么解决冲突:
(key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16)
做一次16位右位移异或混合,这段代码叫“扰动函数”
链表结构+树形结构解决大量冲突
- 数据存储,链表
- 数据查询,红黑树
5.HashMap的扩容问题
扩容为什么每次是2的幂?
-
Map底层实现一般都是数组,为什么要求数组的长度是2的倍数?
-
长度是2的倍数,长度 - 1 可以得到全1的二进制数,
-
再与hash值进行"与"运算,得到该值在数组中的位置,也就是确定链表的头节点,hash桶的位置
-
tab[(n - 1) & hash 是什么意思呢?其实,他就是取模,n表示长度
-
Java之所有使用位运算(&)来代替取模运算(%),最主要的考虑就是效率。位运算(&)效率要比代替取模运算(%)高很多,主要原因是位运算直接对内存数据进行操作,不需要转成十进制,因此处理速度非常快。
-
2^n表示2的n次方,也就是说,一个数对2^n取模 == 一个数和(2^n - 1)做按位与运算
下面是HashMap的get()方法实现:
final Node<K, V> getNode(int hash, Object key) {
Node<K, V>[] tab;
Node<K, V> first, e;
int n;
K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// tab[(n - 1) & hash 确定第一个hash桶的位置
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k)))) {
// 比较key,比较key的hash值,比较key的equals()方法
return first;
}
if ((e = first.next) != null) {
if (first instanceof TreeNode) {//如果hash桶是树形结构
return ((TreeNode<K, V>) first).getTreeNode(hash, key);
}
do {
//循环遍历链表每一个节点,比较获取值
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k)))) {
return e;
}
} while ((e = e.next) != null);
}
}
return null;
}
下面是HashMap的put()方法实现:
/**
* Implements Map.put and related methods
*
* @param hash hash for key
* @param key the key
* @param value the value to put
* @param onlyIfAbsent if true, don't change existing value
* @param evict if false, the table is in creation mode.
* @return previous value, or null if none
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K, V>[] tab;
Node<K, V> p;
int n, i;
if ((tab = table) == null || (n = tab.length) == 0) {
//如果hash表,也就是tab数组为空,且长度为0
//则,扩容
n = (tab = resize()).length;
}
if ((p = tab[i = (n - 1) & hash]) == null) {
//如果hash桶的第一个节点为空,则创建一个新的节点挂载上去
tab[i] = newNode(hash, key, value, null);
} else {
Node<K, V> e;
K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k)))) {
//如果链表的第一个节点不为空,且第一个节点和传入的值相等,返回第一个节点的值
e = p;
} else if (p instanceof TreeNode) {
// 如果第一个节点判断是一棵树,则走新增树的逻辑
e = ((TreeNode<K, V>) p).putTreeVal(this, tab, hash, key, value);
} else {
// 如果第一个节点不为空,
// 且第一个节点和传入的节点不相等
// 且第一个节点判断,链表不是树形结构
// 遍历链表
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
//遍历链表,如果找不到存在的相等的值,创建一个新的节点,挂载到链表
p.next = newNode(hash, key, value, null);
//链表新增一个节点后,判断链表的长度是不是达到了转换成红黑树的阈值,
//如果达到转换阈值,则将链表转换成一棵树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
{
treeifyBin(tab, hash);
}
break;
}
// 如果遍历链表找到存在的值,直接返回找到的值
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k)))) {
break;
}
p = e;
}
}
if (e != null) { // existing mapping for key,key已经存在,返回值
V oldValue = e.value;
// onlyIfAbsent if true, don't change existing value
// put() 默认 onlyIfAbsent是false,也就是默认覆盖老的值
if (!onlyIfAbsent || oldValue == null) {
e.value = value;
}
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold) {
// 如果调用put()方法后,HashMap的大小超过阈值,进行扩容
resize();
}
afterNodeInsertion(evict);
return null;
}
下面是HashMap的resize()方法说明
/**
* Initializes or doubles table size.
* 初始化table的大小,或者扩容table的大小
*
* If null, allocates in accord with initial capacity target held in field threshold.
* 如果开始table容量为空,按照threshold字段的大小来初始化空间,
*
* Otherwise, because we are using power-of-two expansion,
* 否则,因为我们采取2的幂次方来扩容,也就是大小是2的倍数
*
* the elements from each bin must either stay at same index, or move
* with a power of two offset in the new table.
* 扩容以后,每个链表在table数组中的位置
* 要么是保持和之前一样的位置,
* 要么是移动到2的倍数个偏移量的位置
*
*
* @return the table
*/
final Node<K, V>[] resize() {}
6.HashMap线程安全发生在什么时候?
多线程对同一个HashMap做put操作可能导致两个或以上线程同时做rehash动作,就可能导致循环键表出现,这个是时候如果有get操作出现,就会出现是循环,一旦出现线程将无法终止,持续占用CPU,导致CPU使用率居高不下),就可能出现安全问题了。
线程安全发生在扩容的时候
7.HasHMap使用注意事项?
-
key的选择尽量区分度高一些
-
如果可以预测map的大小,可以初始化的时候指定大小,减少扩容
-
不能在多线程环境中使用,避免线程安全问题
-
迭代器使用注意会 fail-fast
-
可以重写key的hashCode()方法和eques()方法来提升hash质量
8.让你实现一个HasHMap你怎么做?
稳定点
- hash函数设计
- hash冲突设计
- 数据结构设计,数组+链表+树
变化点
- 初始化map
- 固定大小
- 默认大小,按照倍数动态扩容
- 默认大小,动态传入预测扩容大小,减少扩容次数,减少数据移动次数
- put()方法设计
- get()方法设计
- remove()方法设计
- resize()方法设计
- 多项程并行扩容,提高移动数据效率
9.HasHMap演变?
- 可以通过检测map的容量使用情况,根据预测经验值,动态缩容,扩容
- 线程安全的情况下,将单线程的地方优化成多线程并行执行
10.HasHMap常用属性
序号 | 属性 | 描述 |
---|---|---|
1 | DEFAULT_INITIAL_CAPACITY | 默认初始化大小16 |
2 | MAXIMUM_CAPACITY | 最大容量 2的30次幂 |
3 | DEFAULT_LOAD_FACTOR | 默认加载因子:0.75f |
4 | TREEIFY_THRESHOLD | 链表转换成树的阈值:8 |
5 | UNTREEIFY_THRESHOLD | 树转换成链表的阈值:6 |
6 | MIN_TREEIFY_CAPACITY | 树的最小容量64 |
7 | table | hase表,一个数组 |
8 | loadFactor | map的加载因子 |
9 | threshold | map的容量大小 |
10 | modCount | HashMap的结构被修改的次数,为了来限制迭代器上的修改 |
11 | size | 映射到map中元素的实际大小 |