详细梳理JAVA7和JAVA8 HashMap的hash实现

基础知识

我们知道hashmap是个hash桶加上链表。

hashmap最关键的操作就是hash的逻辑,即根据把各种给了键值对的节点node,对应到数组中的逻辑,也就是确定哈希桶数组索引位置,然后才能谈冲突后的存储和处理方式。本文要详细解析的就是hash的这个映射过程。

关于hashmap的基础知识部分,在这里就不解释了,给出1.7及之前的hashmap内部存储的图解:
在这里插入图片描述

我们先给出JDK1.7的源码方便理解。

JDK1.7源码提要

int hash = hash(key);
int i = indexFor(hash, table.length);

第一步,求hash值
第二步,根据hash值和数组的长度length定位到数组里的具体位置。

(一)计算 hash 值

final int hash(Object k) {
    int h = hashSeed;
    if (0 != h && k instanceof String) {
        return sun.misc.Hashing.stringHash32((String) k);
    }

    h ^= k.hashCode();

    // This function ensures that hashCodes that differ only by 
    // constant multiples at each bit position have a bounded 
    // number of collisions (approximately 8 at default load factor). 
    h ^= (h >>> 20) ^ (h >>> 12);
    return h ^ (h >>> 7) ^ (h >>> 4);
}

先不用管具体实现,只需要知道它是通过对给的node中的key(键)调用每个Object都有的hashCode()方法得到一个值,然后做了一些处理,就给出了这里的hash值。

(二)定位

static int indexFor(int h, int length) {
    return h & (length-1);
}

把得到的hash值,对应到数组那个长度里。

接下来我们一步一步分析:

逻辑分析

hashmap的数组可动态扩容的,而hashcode这个int值往往会超出数组当前的长度。(不可能直接把长度设置为最大的上限,太浪费空间)

所以,最直接的办法,就是对hashcode做转换,让它无法超出length。这就是hash&(length -1)的原因。

那么,为什么是hash&(length -1)?

举个例子:

令 x = 1<<4,即 x 为 2 的 4 次方,它具有以下性质:
x : 00010000
x-1 : 00001111

令一个数 y 与 x-1 做与运算,可以去除 y 位级表示的第 4 位以上数:
y : 10110010
x-1 : 00001111
y&(x-1) : 00000010

这个性质和 y 对 x 取模效果是一样的:
y : 10110010
x : 00010000
y%x : 00000010

这里 只有x是2的n次幂,那么求y和x-1的&操作时,才能相当于y%x这种求模的操作。这就是为什么hashmap里数组长度必须是2的次幂 。
(有兴趣的可以看一下源码里tableSizeFor函数,在扩容时,用了非常巧妙的移位操作找到了比cap大的 最小的 是2的某次幂的 一个数返回设定为数组长度length )

为了分析这个取模操作和数组长度是2的次幂的关系,我们举个例子:

如果长度是17,length-1=16 y和16&操作后后4位一定是0,因为 16是 1 0000 后面4个0,加上&后四位必是0,那么只有从第5位才有分别,即最小的差距就是0和16的差距,严重违背了均匀分布的要求,会导致大量数组空间浪费和碰撞。

而如果长度是16 ,length-1=15 ,15是 1111 相当于取低4位的模,最小的差距是0和1的差距,就把hashcode的值均匀分布在0-15里面了,这样才符合求hash值,均匀分布以减少碰撞的要求。

我们知道,位运算的代价比求模运算小的多,因此在进行这种计算时用位运算的话能带来更高的性能。

确定桶下标的最后一步是将 key 的 hash 值对桶个数取模:hash%length,如果能保证 length 为 2 的 n 次方,那么就可以将这个操作转换为位运算。

所以这个操作即是让hash在数组长度以内的取模运算。

那么,直接调用Object的hashCode()然后用上面的方法取模行么?

再举个例子:
假设此时table[]的length是16,那么对于key1来说就是
0AC20000 & 0000000F = 0
对于key2来说
017F0000 & 0000000F = 0
也就是说key1和key2的存储位都是0,发生了hash冲突。

11280384和1568768这两个hashcode明显是完全不同的,但却发生了hash冲突,是因为这两个hashcode的低位相同,高位不同。而HashMap在计算index直接取模就是只取了低位,如果不对hashcode进行高低位扰动计算,就会极大增加了发生hash冲突的几率。
扰动的方法前面源码里已经出现了!就是这一段!

    h ^= (h >>> 20) ^ (h >>> 12);
    return h ^ (h >>> 7) ^ (h >>> 4);

我们按上面的代码一点点分析,加入扰动函数之后:
0AC20000 >>> 20 = 000000AC
0AC20000 >>> 12 = 0000AC20

000000AC ^ 0000AC20 = 0000AC8C
0AC20000 ^ 0000AC8C = 0AC2AC8C

看到这里应该明白了,这段扰动函数的目的是把hashcode的高低位混在一起,让高位发生变化时,低位同时也发生变化。

接下来又进行了一些更多的扰动计算:
0AC2AC8C >>> 7 = 00158559
0AC2AC8C >>> 4 = 00AC2AC8
0AC2AC8C ^ 00158559 ^ 00AC2AC8 = 0A7B031D

所以对于key1来说,扰动完成后的hash是0A7B031D
而key2进行相同的扰动计算后,hash是016A18B6

这样key1的存储位是13,key2是6,没有发生hash冲突。

逻辑总结

其实前面已经说过了
第一步,求hash值
第二步,根据hash值和数组的长度length定位到数组里的具体位置。

求hash值时,先调用key的Object.hashCode()得到一个hashcode,然后对它进行高位绕动,防止高位不同低位相同后造成的冲突情况。这样得到了hash值,再把此值用巧妙的位操作的方法取模(借助数组长度length是2的某次幂)至数组长度内均匀分布。

JDK1.8的改动

由于我用的是JDK10,在学习hashmap源码时,找到的好多都是1.7的资料,但是和我自己看到的源码对应不上,于是我自己梳理了一下逻辑。

对于 JDK1.8 之后的 HashMap 底层在解决哈希冲突的时候,就不单单是使用数组加上单链表的组合了,因为当处理如果 hash 值冲突较多的情况下,链表的长度就会越来越长,此时通过单链表来寻找对应 Key 对应的 Value 的时候就会使得时间复杂度达到 O(n),因此在 JDK1.8 之后,在链表新增节点导致链表长度超过 TREEIFY_THRESHOLD = 8 的时候,就会在添加元素的同时将原来的单链表转化为红黑树。

对数据结构很在行的读者应该,知道红黑树是一种易于增删改查的二叉树,他对与数据的查询的时间复杂度是 O(logn) 级别,所以利用红黑树的特点就可以更高效的对 HashMap 中的元素进行操作。
在这里插入图片描述

而在hash方面,首先,在高位扰动方面,只是简单的h = h ^ (h >>> 16),没有再做那么多的扰动,就得到了hash值。其次,去掉了indexFor这个专门定位的函数,而是在put,get等操作中直接定位,可以看到这些函数中都有这两行

n = tab.length //得到数组长度
tab[(n - 1) & hash] //根据hash值定位在数组中的位置

我自己的理解是,由于用红黑树优化了冲突很多,链很长的情况,所以没必要做那么多的高低位扰动了。有了冲突也可以处理。
大概就是这么多。

猜你喜欢

转载自blog.csdn.net/u013453787/article/details/84702992