关于hash hashcode等问题
什么是hash
学过编程的人都听说过hash,哈希,哈希列表,哈希等等,但是hash到底是个啥?
摘自百度百科的解释:
Hash,一般翻译做“散列”,也有直接音译为“哈希”的,就是把任意长度的输入(又叫做预映射pre-image)通过散列算法变换成固定长度的输出,该输出就是散列值。这种转换是一种压缩映射,也就是,散列值的空间通常远小于输入的空间,不同的输入可能会散列成相同的输出,所以不可能从散列值来确定唯一的输入值。简单的说就是一种将任意长度的消息压缩到某一固定长度的消息摘要的函数。
什么是hashcode
我们可以简单的把哈希值理解成一段数据(不管是啥)的唯一标识,就和我们的身份证一样.通过身份证就能定位到某个人,哈希值也是一样的,通过哈希值来确定一段数据(这个数据可能很大,但我们需要确定他的唯一性).相信很多人都玩过游戏吧,在我们下载客户端的时候,下面都会有一串MD5校验码.小时候一直不知道这是个啥,现在知道了,md5校验码就是你下载的这个客户端的哈希值.他的作用就是为了确保你下载到的客户端与原来的客户端一致.
hashcode的值怎么得到的?
我在java编程中,hashcode用的最多的地方就是HashMap,毕竟jdk的Object源码都注释了
百度翻译一下就是:返回对象的哈希代码值。这种方法支持散列表的好处,如由HashMap.
墨迹了半天,那么hashcode的值怎么得到呢?
String str = "hello";
Integer i = 123;
System.out.println(str.hashCode());
System.out.println(i.hashCode());
首先测试一下输出结果是什么?
str=99162322
i=123
然后我们去看看String的hashcode源码
最主要的就是第1471这一行代码.h = 31 * h + val[i] 这行代码的意思在上面的注释中已经给出了,就是s[0]*31^(n-1) +s[1]*31^(n-2) + … + s[n-1] 意思就是吧String内部维护的那个final注释的数组的数字每次乘以31并且叠加最后返回.得到的这个值就是所谓的哈希值,但是,为啥子要乘以31?奇不奇怪?好不好奇?
为什么hashcode方法里要用31这个数字?
去栈溢出里搜一搜,得到如下结果
还是百度翻译一下吧:值31之所以被选择是因为它是奇数素数。如果是偶数,乘法溢出,信息就会丢失,乘以2等于移位。使用素数的优点不太清楚,但它是传统的。31的一个好特性是,乘法运算可以用移位和减法来代替,从而获得更好的性能:31*i==(i<<5)-i。现代虚拟机(JVM)自动进行这种优化。
还是有点不明白?
百度翻译:正如古德里奇和Tamassia所指出的,如果你接管了50000个英语单词(形成为UNIX两个变体中提供的单词列表的联合),使用常量31, 33, 37、39和41在每种情况下都会产生少于7次的冲突。知道这一点,许多Java实现选择这些常量中的一个就不足为奇了。
所以在hashcode中使用31是不是就明白了!
如果还不明白的话,建议看看这位大神的总结:https://m.imooc.com/article/22958 很强!
hash的算法是什么
在hashmap中,hash方法的源码是这样的:
我们很容易看到 (h = key.hashCode()) ^ (h >>> 16) 但是这个是什么呢?这就是"扰动函数".这个是jdk1.8的扰动函数,在1.8之前,扰动函数进行了4次16位右移异或混合.在jdk1.8后就优化了这段代码,只进行一次就好了.这样子可能看不明白这是在干什么,所以我们需要盗图!
一目了然是不是? 到最后一步前我相信都可以看明白,但是最后一步究竟是什么意思?为什么需要(n - 1)&hash呢? 这里的n代表素组长度.
(h = key.hashCode()) ^ (h >>> 16)
上面代码里的key.hashCode()函数调用的是key键值类型自带的哈希函数,由于key是Object类型的所以,key可能调用的是String类的hashCode(),Integer类的hashCode()等.
但是理论上散列值一般是一个int型,如果直接拿散列值作为下标访问HashMap主数组的话,考虑到2进制32位带符号的int表值范围从-2147483648到2147483648.前后加起来大概40亿的映射空间.只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的.
但问题是一个40亿长度的数组,内存是放不下的.你想,HashMap扩容之前的数组初始大小才16.所以这个散列值是不能直接拿来用的.用之前还要先做对数组的长度取模运算,得到的余数才能用来访问数组下标.在hashmap中的get方法是这样的
注意看第569行, (n-1)&hash 就代表数组下标,这也正好解释了为什么HashMap的数组长度要取2的整次幂。因为这样(数组长度-1)正好相当于一个“低位掩码”。“与”操作的结果就是散列值的高位全部归零,只保留低位值,用来做数组下标访问。以初始长度16为例,16-1=15。2进制表示是0000 0000 0000 0000 0000 0000 0000 1111。和某散列值做“与”操作,就能够得到我们上面那张图看不明白的最后一步.
0000 0000 0000 0000 0000 0000 0000 1111
& 1111 1111 1111 1111 0000 1111 0001 0101
------------------------------------------------
0000 0000 0000 0000 0000 0000 0000 0101 =5 //高位全部归零,只保留末四位
右位移16位,正好是32bit的一半,自己的高半区和低半区做异或,就是为了混合原始哈希码的高位和低位,以此来加大低位的随机性.而且混合后的低位掺杂了高位的部分特征,这样高位的信息也被变相保留下来.
详细参见:https://www.zhihu.com/question/20733617/answer/111577937
hashmap的长度为啥是2的次幂
为了能让 HashMap 存取高效,尽量较少碰撞,也就是要尽量把数据分配均匀。我们上面也讲到了过了,Hash 值的范围值-2147483648到2147483648,前后加起来大概40亿的映射空间,只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。但问题是一个40亿长度的数组,内存是放不下的。所以这个散列值是不能直接拿来用的。用之前还要先做对数组的长度取模运算,得到的余数才能用来要存放的位置也就是对应的数组下标。这个数组下标的计算方法是“ (n - 1) & hash ”
原文:https://blog.youkuaiyun.com/qq_38182963/article/details/78940047
tab[(n - 1) & hash];
其中 n 是数组的长度。其实该算法的结果和模运算的结果是相同的。但是,对于现代的处理器来说,除法和求余数(模运算)是最慢的动作。
上面情况下和模运算相同呢?
a % b == (b-1) & a ,当b是2的指数时,等式成立。
我们说 & 与运算的定义:与运算 第一个操作数的的第n位于第二个操作数的第n位如果都是1,那么结果的第n为也为1,否则为0;
当 n 为 16 时, 与运算 101010100101001001101 时,也就是
1111 & 101010100101001001000 结果:1000 = 8
1111 & 101000101101001001001 结果:1001 = 9
1111 & 101010101101101001010 结果: 1010 = 10
1111 & 101100100111001101100 结果: 1100 = 12
可以看到,当 n 为 2 的幂次方的时候,减一之后就会得到 1111* 的数字,这个数字正好可以掩码。并且得到的结果取决于 hash 值。因为 hash 值是1,那么最终的结果也是1 ,hash 值是0,最终的结果也是0。
试想一下,如果不使用 2 的幂次方作为数组的长度会怎么样?
假设我们的数组长度是10,还是上面的公式:
1010 & 101010100101001001000 结果:1000 = 8
1010 & 101000101101001001001 结果:1000 = 8
1010 & 101010101101101001010 结果: 1010 = 10
1010 & 101100100111001101100 结果: 1000 = 8
看到结果我们惊呆了,这种散列结果,会导致这些不同的key值全部进入到相同的插槽中,形成链表,性能急剧下降。
所以说,我们一定要保证 & 中的二进制位全为 1,才能最大限度的利用 hash 值,并更好的散列,只有全是1 ,才能有更多的散列结果。如果是 1010,有的散列结果是永远都不会出现的,比如 0111,0101,1111,1110…….,只要 & 之前的数有 0, 对应的 1 肯定就不会出现(因为只有都是1才会为1)。大大限制了散列的范围。
hashmap的原理剖析
原文:https://www.jianshu.com/p/f2361d06da82 有删改
参考:https://blog.youkuaiyun.com/login_sonata/article/details/76598675
HashMap底层是由多个结构组合实现的.从这四方面来进行剖析:put 操作 ,get 操作 , hash的实现 ,resize的流程
put操作
- 计算key值的hashcode(),并且得到下标
- 判断是否产生碰撞,如果没有碰撞直接放到bucket(存储空间)里
- 如果产生碰撞则以链表的形式放到bucket里
- 如果碰撞次数大于等于8,就把链表转换成红黑树
- 如果key节点存在,就重新赋值oldValue为此时key的value.
- 如果bucket满了(大于负载因子*现有容量)就resize
public V put(K key, V value) {
// 取关键字key的哈希值
return putVal(hash(key), key, value, false, true);
}
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;
// ((n - 1) & hash)作为key在tab[]数组中的下标,
if ((p = tab[i = (n - 1) & hash]) == null)
// 如果该下标下没有节点,则直接新建一个Node放在该位置。
tab[i] = newNode(hash, key, value, null);
else { // 如果哈希表当前位置上已经有节点的话,说明有hash冲突
Node<K,V> e; K k;
// hash和key都相等,则该Key已存在,获得该节点。
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;
}
// hash和key都相等,则该Key已存在,获得该节点并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;
}
get操作
- 计算key值的hashcode(),并且得到下标
- 如果得到的下标没有对应的值,直接返回null
- 如果得到的下标有对应的值,判断tab数组中该下标是否为第一个节点,如果是则直接返回
- 如果不是第一个节点,则判断该结构是链表还是树,然后再去查找,找到则返回
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
// 如果哈希表容量为0或者关键字没有命中,直接返回null
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// 通过hash和key判断是否为第一个节点
if (first.hash == hash && ((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
if (first instanceof TreeNode) // 以红黑树的方式查找
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;
}
resize流程
在put操作时,如果发现目前的bucket占用程度已经超过了Load Factor所希望的比例,那么就会发生resize。resize的过程,简单的说就是把bucket扩充为2倍,之后重新计算index,把节点再放到新的bucket中。
- 没有节点,不处理
- 单节点,重新计算index(hash&(newCap - 1))
- 多节点,跟单节同样的情况,只是没有重新计算所有的index,而是看看原来的hash值新增的那个位是1还是0(因为容量扩大了一倍,因此影响结果的是hash之前没有参与运算的最右侧位值,通过 hash & oldCap 便能得到),是0的话索引没变,是1的话索引变成“原索引+oldCap”。
下图是容量从8扩容到16的示意图:
横线上面表示扩容前的old和new两种确定索引位置的示例,横线下面表示扩容后old和new两种确定索引位置的示例,其中7是15对应的哈希与高位运算结果.
这就是为什么扩容为原来的2倍,因为要维持容量大小为2的幂,这样可以快速计算出 index 的值,而且同时,由于新增的1是0还是1可以认为是随机的,因此resize的过程,相对均匀地把之前冲突的节点分散到新的bucket里了。
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) { // 如果老容量大于0,说明哈希表中已经有数据了,然后进行扩容
if (oldCap >= MAXIMUM_CAPACITY) { // 超过最大容量的话,不扩容
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && // 容量加倍
oldCap >= DEFAULT_INITIAL_CAPACITY) // 如果老的容量超过默认容量的话
newThr = oldThr << 1; // 阀值加倍
}
else if (oldThr > 0) // 根据thresold初始化数组
newCap = oldThr;
else { // 使用默认配置
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) { // 扩容之后进行rehash操作
Node<K,V> e;
if ((e = oldTab[j]) != null) { // 无节点,不做处理
oldTab[j] = null;
if (e.next == null) // 单节点,重新计算index
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode) // 红黑树方式处理,跟链表的处理相似
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // 链表扩容
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) {
// 第一次 loTail 为空,则 loHead 和 loTail 都指向了e
if (loTail == null)
loHead = e;
else// 然后 loTail 不断向后移动来添加新的e
loTail.next = e;
loTail = e;
}
else { // 原索引+oldCap,方法同上
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 原索引放到bucket里
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
// 原索引+oldCap放到bucket里
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}