哈希算法又称为散列算法。本文将基于Java8对哈希进行简单总结。
哈希算法的定义
将任意长度的二进制值串映射为固定长度的二进制值串,这个映射的规则就是哈希算法。
下面列举一下哈希算法的一些应用与实际场景
- 加密:散列算法 MD5计算一个唯一的id,具有不可逆性。https://github.com/pod32g/MD5/blob/master/md5.c
- 网络传输中服务端使用的是散列表存储session
- 长链通过哈希算法将长链接映射为短链
- 布隆过滤器
- LRU缓存
- redis 中散列表存储结构
- java虚拟机中java对象头的组成字段
- java序列化使用的serialVersionUID
- java hashmap/treemap/linkedhashmap/concurrentHashMap
Hash in Java
我们最熟悉的HashMap 就是哈希算法的一种应用,其底层的数据结构主要是:数组 + 链表 + 红黑树。 (1.7之前是数组+链表)
数组的主要作用是根据下标随机访问,时间复杂度是 O(1),默认大小是 16,数组的下标索引是通过 key 的 hashcode 计算出来的,数组元素叫做 Node,当多个 key 的 hashcode 一致,但 key 值不同时,发生哈希冲突通过链表法解决,单个 Node 就会转化成链表,链表的查询复杂度是 O(n),当链表的长度大于等于 8 并且数组的大小超过 64 时,链表就会转化成红黑树,红黑树的查询复杂度是 O(log(n)),简单来说,最坏的查询次数相当于红黑树的最大深度。
HashMap 本质还是取余数,但是使用位运算进行优化,获得分布更加均匀的hashCode
1)初始化时,hashmap对数组容量进行调整,一定是2的幂次。
2)index = key.hasCode() % n 当n等于2的幂次方时,可优化为index = key.hasCode() & (n-1)
取余操作的弊端:取余的计算结果对高位是无效的,只是对低位有效,当计算出来的hasCode()只有高位有变化时,取余的结果还是一样的。这样就会导致hash冲突。举例:
int hashCode1 = 88;
int hashCode2 = 72 ;
int index1 = 88 % 16 -> 8;
int index2 = 72 % 16 -> 8;
尽可能利用32位(java中的hashcode是32位),将hashCode高16位与低16位进行异或运算,避免忽略容量以上的高位,有效避免hash碰撞。与之类似的还有一种集合类HashTable
int index = (hash & 0x7FFFFFFF) % tab.length;
Redis中的哈希算法
redis使用性能更好的MurmurHash2计算哈希值,利用余数法计算数组索引值 https://github.com/aappleby/smhasher 使用链表法解决哈希冲突。redis支持散列表的动态扩容、缩容。余数哈希算法的局限性 扩容/所容时的余数哈希算法存在的问题:出现hash%x取模结果大量变更。
解决冲突策略:
1.重哈希法
jdk中hashmap/hashtable使用 rehash,redis使用渐进式rehash。
redis使用渐进式扩容、缩容策略,为了解决扩容缩容要做大量的数据搬移和哈希值的重新计算,非常耗时,采用将数据的搬移分批进行,避免大量数据一次性搬移导致的服务停顿。
2: 一致性哈希(ConsistentHash)
一致性哈希是指将存储节点和数据都映射到一个首尾相连的哈希环上,存储节点可以根据 IP 地址进行哈希,数据通常通过顺时针方向寻找的方式,来确定自己所属的存储节点,即从数据映射在环上的位置开始,顺时针方向找到的第一个存储节点。 dubbo、nginx中发生扩容/缩容时通常使用一致性哈希算法来保证负载均衡。
但这种方法存在缺陷:
缺陷1:Hash偏移造成节点分布不均
可以通过带虚拟节点的一致性哈希来解决。
构建过程:一致性hash的虚拟节点采用了TreeMap对象,针对每个provider根据url的address字段加虚拟节点个数/4的偏移量生成128的digest字段,针对128位的digest字段(16个字节)按照每4个字节进行进行计算生成4个虚拟节点放到TreeMap当中。假设有160个虚拟节点,160/4=40个待处理虚拟节点,针对40个待处理虚拟节点中的每个节点生成16个字节的digest,16个字节中digest分为4份进行保存,最终依然是160个虚拟节点。
查询过程:查询过程根据指定参数进行md5+hash()计算hash值,找到比hash值小的treeMap然后选择treeMap的根节点选择provider,如果找不到比hash小的treeMap就选择treeMap的最小根节点。
存储结构的选择
TreeMap 底层是红黑树,查询的时间复杂度是O(logN)
1.使用TreeMap实现hash环,将Invoker分布在环上,
2.java.util.NavigableMap#ceilingEntry 获取环上大于等于指定key的节点
代码见:
https://github.com/apache/dubbo/blob/master/dubbo-cluster/src/main/java/org/apache/dubbo/rpc/cluster/loadbalance/ConsistentHashLoadBalance.java
缺陷2:节点新增/删除时,造成的数据分布不均匀
解决方法:带有限负载的一致性哈希
核心原理是,给每个存储节点设置了一个存储上限值来控制存储节点添加或移除造成的数据不均匀。当数据按照一致性哈希算法找到相应的存储节点时,要先判断该存储节点是否达到了存储上限;如果已经达到了上限,则需要继续寻找该存储节点顺时针方向之后的节点进行存储。
带有限负载的一致性hash在存储时实际是分两步走:
先通过一致性哈希计算出一个命中节点,如果命中节点有足够的空间,则存储在命中节点上;否则按顺时针方向,依次遍历下一个节点,直到找到下一个节点为止。
查询的时候如果按照存储的方式去计算命中节点,然后再去顺时针依次遍历下一个节点。极端情况,如果查询数据不存在的话,需要遍历完整个哈希环上的所有节点。这种遍历的开销就太大了,因为这是网络上的遍历,可不是单机内存内的遍历。一种思路是,在哈希命中节点上额外添加一个索引表,用以记录命中到该节点上的数据存储在哈希环上的位置。这样在通过一致性哈希计算出命中的节点后,只需要再查询这个节点上的索引表就可以找到实际的存储节点了。
类比于链表法解决hash冲突。
缺陷3:节点异构性,造成的数据分布不均
解决方式:根据节点性能差异设置对应数量的虚拟节点
核心思想是根据每个节点的性能,为每个节点划分不同数量的虚拟节点,并将这些虚拟节点映射到哈希环中,然后再按照一致性哈希算法进行数据映射和存储。
假设,Node1 性能最差,Node2 性能一般,Node3 性能最好。以 Node1 的性能作为参考基准,Node2 是 Node1 的 2 倍,Node3 是 Node1 的 3 倍。
因此,Node1 对应一个虚拟节点 Node1_1,Node2 对应 2 个虚拟节点 Node2_1 和 Node2_2,Node3 对应 3 个虚拟节点 Node3_1、Node3_2 和 Node3_3。
假设,虚拟节点 Node1_1、Node2_1、Node2_2、Node3_1、Node3_2、Node3_3 的哈希值,分别为 100、200、300、400、500、600。
那么,按照带虚拟节点的哈希一致性方法, 数据 D0 和 D6 按顺时针方向的下一个虚拟存储节点为 Node 1-1,因此节点 Node1 将会存储数据 D0(id = 100)和 D6(id = 700);同理,Node2 将会存储数据 D1(id = 200)和 D2(id = 300),Node3 将会存储数据 D3(id = 400)、D4(id = 500)和 D5(id = 600)。
常用的一致性算法: