开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 1 天,点击查看活动详情
散列表
散列表也指Hash表。我们都知道Hash结构是Key-Value结构。但是Hash结构本质还是源自于数组。
Hash为什么可以做到O1级别的查询,其实就是数组的下标随机访问一样的。
这里可能就会问题,数组的下标都是Int类型,Hash结构的Key可以为任何类型。这个要如何处理呢?
此时就用到了Hash函数,Hash函数本质就是通过算法转换为int类型,以便将数据存储在数组空间,下次查询时也是通过Hash函数获取下标,快速获取数据。
Hash函数理解。
一份数据如果需要用散列表存储,那么一定离不开Hash函数。
Hash函数无法做到不重复,所以总会有一些Hash碰撞,Hash冲突的情况。解决Hash冲突的方法包括线性探测,链表。
Java hashCode()方法
Java在Object类中有一个hashCode方法,此方法与equals方法一起用来判断对象是否一致。
- HashCode不一致 两个对象一定不相同。
- HashCode相同两个对象可能相同。
通常我们会说,重写了equals方法后一定要重写hashCode。为了避免,equals认为对象一样,但是hashCode确不同情况时出现一些问题。
比如,在一些Hash结构使用过程中,如果遇到上述情况,则无法正常使用。以HashMap为例:
先使用hashCode(Key) 判断是否存在,存在则比较Key(equals),不存在就新增。
假如我们修改了equals方法认为这两个Key表示同一个对象,那么在hashCode时候产生了两个不同的值,势必会影响我们正常的使用(当然,此处的hashCode方法是在Java实现的基础上,HashMap自己又做了高低位的转换,本质上还是Java自己实现的。
public V put(K key, V value) {
// hash(Key)
return putVal(hash(key), key, value, false, true);
}
static final int hash(Object key) {
int h;
// 调用 hashCode() 方法
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
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)
n = (tab = resize()).length;
// hash后的值作为下标 判断是否存在数据 并赋值给 p
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
// 如果存在数据 需要比对Key是否相同, hash值必须一致, == 或者 equals 需要一致
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
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
HashCode 的目标,以最少的耗时,生成重复率最小的HashCode;
HashCode由于存在Hash冲突的情况,因此并非一直都能达到O1的复杂度。
如何设计工业级的散列表
- 合适的Hash函数(快速、冲突少、分布均匀)。
- 合适的Hash冲突策略。
- 开放寻址法
- 链表法
- 合适的装载因子,扩容方式。
Hash函数
结合不同的场景我们可以选择不同的Hash函数。比如手机号,大部分手机号前三位都一致。151/130/185而后几位则具备随机的特性,因此我们可以仅使用后几位作为Hash值。
Hash函数的设计还是尽可能的快,尽可能的随机(均匀分布)。
装载因子过大
如何高效的扩容、缩容。
对于扩容来说,8个 -> 16 个很快,1G -> 2G 又该如何处理呢?
正常来讲,我们可以采取一些其他的策略,避免单次迁移大量数据。这方面Redis的设计就很巧妙。
HASH冲突
Hash冲突后的寻址方式,链表方式。
开放寻址方式
优点:所有数据都存储在数组中,没有额外的其他数据结构。序列化比较简单。链表方式序列化就比较麻烦。
缺点:由于所有数据都存在数组中,因此冲突的概率也随之提升。此外,删除时还需要特殊的标记。
适用场景:数据较少(装载因子较小)的场景可以使用开放寻址方式。ThreadLocalMap
链表方式
优点: 链表对于装载因子比较友好。装载因子接近于1时开放寻址的效率就会变的很差。而链表则无此问题,即使到10,只要分配的足够均匀,也要比正常的查询要快很多。
缺点: 链表结构也有很明显的缺点就是需要存储指针,在一些数据较小的情况下,可能就是成倍的消耗。另外由于存储的不连续性,对于CPU缓存来说也不是很友好,无法预加载。
备注: 另外我们再装载因子比较大的情况下,也可以使用 红黑树来提高查询的速度(参考二分查找对效率的提升)。这里思考一下:HashMap的扩容条件是什么?
HashMap
扩容
HashMap作为一个工业级的散列表,除了缺少并发场景设计外,其他方面还是很OK的。
Hash函数方面:采用高低位
装载因子方面:设计的状态因子为 0.75
Hash冲突方面: 采用 链表 以及 红黑树 两种形式来提升查询效率。
此外,在扩容方面: HashMap比较粗暴,直接全量迁移,因此单次扩容成本也较高。
扩容的过程:
- 在完成数据新增后。会判断当前数据量 size 是否大于 阈值 (数组当前大小 * 装载因子 0.75 )
- 如果大于,则开始初始化新的数组空间。
- 挨个遍历旧数组。
- 将旧数组数据只有一个值,直接 & 新的数组长度 - 1(相当于取模)。结束
- 如果还有其他值,则判断类型是链表还是红黑树。
- 如果是链表
- 然后挨个遍历 & 旧数组长度 (实际仍然是 hash % 新长度),对原数据进行拆分。
- == 0 的不动,大于 0 的放在 原下标 + 旧数组长度 位置。
- 如果是红黑树
- 暂略
- 如果是链表
思考:为什么要 & 旧数组的长度?
这里本质其实是想将原数据进行拆分,部分移动到扩容后的位置。移动的算法有很多,感觉 & 运算并不能够均匀的拆分。这里应该用新数组长度取模来拆分比较好。
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
实际次数 e.hash & oldCap == 0 等价于 e.hash * (newCap - 1) == 0 等价于 e.hash % newCap == 0
如果为0 不动,不为 0 肯定为为 oldCap,直接放在 j + oldCap 位置刚好。
比如:原数组长度为16时,假如 下标为 1 处防止数据 hashCode 如下,
| 长度16时,下标为1处 HashCode ( hash % 16 = 1) | hash & 旧长度 16 | 长度32时,下标为17处 HashCode ( hash % 32 = 17) |
|---|---|---|
| 1 | 0 | 否 |
| 17 | 16 | 是 |
| 33 | 0 | 否 |
| 49 | 16 | 是 |
如上所示,判断 hash & 旧长度 是否为 0 就等价于, hash % 新长度 是否为 原下标 + 原长度,是否要移动。
HashTable区别
最大的区别:HashTable是线程安全的,使用了synchronized 关键字。相对应的,如果作为局部变量使用的话(单线程情况)肯定效率要低于HashMap。
- 线程安全(synchronized )。
- 数据结构。1.7以前都是数组 + 链表。 1.8 以后 HashMap 加入了红黑树。
链表为什么总是和散列表配合使用
链表在新增,删除时的效率其实是比较低的。而Hash表正好弥补了这一缺陷。
Hash表虽然新增,查询效率很高,但是都是无序的,而链表很好的弥补了这一缺陷。
因此在很多时候,我们总能看到两者配合一起去使用。
比如:
- LRU算法,既需要有序性来做淘汰,又需要快速访问来保证读/写效率(链表 + hash 表)。
- Redis Sort Set,需要访问速度,需要排序(跳表),而Redis本质也是为了高效查询的Hash结构。
Hash算法
Hash算法其实就是将任意长度的二进制值(Object)映射为固定长度的二进制值。
Hash算法基本要求:
- 运算速度要快。毫秒级别
- 敏感度要高,任何微小的改变哪怕一个字节,也要保证最后得到的Hash值不相同。
- 冲突概率要小。
- 不能轻易的计算出原数据。
Hash基本使用场景:
- 加密
- 加密的重点在于不能轻易的计算出原数据。在这方面,越复杂的算法越难还原,相应的耗时也较高。
- 唯一表示
- hash冲突概率较小
- 数据检验
- 散列函数
- 要求计算要快,并且尽可能的分散。不强制要求冲突概率,冲突可以采用开放寻址法,链表法解决。
- 负载均衡
- 负载均衡需要保证同一用户的请求落在同一台服务。基于请求用户ip做Hash取模即可保证。
- 数据分片
- 一致性Hash算法。数据分片存储离不开对数据的扩缩容,传统的扩缩容机制不适用与互联网大数据量的场景。比如HashMap的扩容,每次扩充两倍,重新进行Hash计算迁移数据。这样的过程涉及数据太多。所以出现了一致性Hash算法,将Hash范围作为一个环,每一个分片负责一部分环,当发生扩容时,只需要重新分配服务负责的范围,仅移动范围内的数据即可,通常范围也不是连续的。比如A服务可以负责:1
10,100200,1000~1100,只需要保证分配均匀即可。
- 一致性Hash算法。数据分片存储离不开对数据的扩缩容,传统的扩缩容机制不适用与互联网大数据量的场景。比如HashMap的扩容,每次扩充两倍,重新进行Hash计算迁移数据。这样的过程涉及数据太多。所以出现了一致性Hash算法,将Hash范围作为一个环,每一个分片负责一部分环,当发生扩容时,只需要重新分配服务负责的范围,仅移动范围内的数据即可,通常范围也不是连续的。比如A服务可以负责:1
思考:
Hash算法为什么无法做到0冲突。-- 鸽巢原理