你知道HashMap中hash方法的具体实现吗?你知道HashTable、ConcurrentHashMap中hash方法的实现以及缘由吗?你知道为何要这么实现吗?你知道为何JDK 7和JDK 8中hash方法实现的不一样以及区别吗?若是你不能很好的回答这些问题,那么你须要好好看看这篇文章。文中涉及到大量代码和计算机底层原理知识。绝对的干货满满。整个互联网,把hash()分析的如此透彻的,别无二家。html
Hash,通常翻译作“散列”,也有直接音译为“哈希”的,就是把任意长度的输入,经过散列算法,变换成固定长度的输出,该输出就是散列值。这种转换是一种压缩映射,也就是,散列值的空间一般远小于输入的空间,不一样的输入可能会散列成相同的输出,因此不可能从散列值来惟一的肯定输入值。简单的说就是一种将任意长度的消息压缩到某一固定长度的消息摘要的函数。git
全部散列函数都有以下一个基本特性:根据同一散列函数计算出的散列值若是不一样,那么输入值确定也不一样。可是,根据同一散列函数计算出的散列值若是相同,输入值不必定相同。程序员
两个不一样的输入值,根据同一散列函数计算出的散列值相同的现象叫作碰撞。github
常见的Hash函数有如下几个:算法
直接定址法:直接以关键字k或者k加上某个常数(k+c)做为哈希地址。数组
数字分析法:提取关键字中取值比较均匀的数字做为哈希地址。安全
除留余数法:用关键字k除以某个不大于哈希表长度m的数p,将所得余数做为哈希表地址。数据结构
分段叠加法:按照哈希表地址位数将关键字分红位数相等的几部分,其中最后一部分能够比较短。而后将这几部分相加,舍弃最高进位后的结果就是该关键字的哈希地址。函数
平方取中法:若是关键字各个部分分布都不均匀的话,能够先求出它的平方值,而后按照需求取中间的几位做为哈希地址。post
伪随机数法:采用一个伪随机数看成哈希函数。
上面介绍过碰撞。衡量一个哈希函数的好坏的重要指标就是发生碰撞的几率以及发生碰撞的解决方案。任何哈希函数基本都没法完全避免碰撞,常见的解决碰撞的方法有如下几种:
在Java中,保存数据有两种比较简单的数据结构:数组和链表。数组的特色是:寻址容易,插入和删除困难;而链表的特色是:寻址困难,插入和删除容易。上面咱们提到过,经常使用的哈希函数的冲突解决办法中有一种方法叫作链地址法,其实就是将数组和链表组合在一块儿,发挥了二者的优点,咱们能够将其理解为链表的数组。
咱们能够从上图看到,左边很明显是个数组,数组的每一个成员是一个链表。该数据结构所容纳的全部元素均包含一个指针,用于元素间的连接。咱们根据元素的自身特征把元素分配到不一样的链表中去,反过来咱们也正是经过这些特征找到正确的链表,再从链表中找出正确的元素。其中,根据元素特征计算元素数组下标的方法就是哈希算法,即本文的主角hash()函数(固然,还包括indexOf()函数)。
咱们拿JDK 1.7的HashMap为例,其中定义了一个final int hash(Object k) 方法,其主要被如下方法引用。
上面的方法主要都是增长和删除方法,这不难理解,当咱们要对一个链表数组中的某个元素进行增删的时候,首先要知道他应该保存在这个链表数组中的哪一个位置,即他在这个数组中的下标。而hash()方法的功能就是根据Key来定位其在HashMap中的位置。HashTable、ConcurrentHashMap同理。
首先,在同一个版本的Jdk中,HashMap、HashTable以及ConcurrentHashMap里面的hash方法的实现是不一样的。再不一样的版本的JDK中(Java7 和 Java8)中也是有区别的。我会尽可能所有介绍到。相信,看文这篇文章,你会完全理解hash方法。
在上代码以前,咱们先来作个简单分析。咱们知道,hash方法的功能是根据Key来定位这个K-V在链表数组中的位置的。也就是hash方法的输入应该是个Object类型的Key,输出应该是个int类型的数组下标。若是让你设计这个方法,你会怎么作?
其实简单,咱们只要调用Object对象的hashCode()方法,该方法会返回一个整数,而后用这个数对HashMap或者HashTable的容量进行取模就好了。没错,其实基本原理就是这个,只不过,在具体实现上,由两个方法int hash(Object k)
和int indexFor(int h, int length)
来实现。可是考虑到效率等问题,HashMap的实现会稍微复杂一点。
hash :该方法主要是将Object转换成一个整型。
indexFor :该方法主要是将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(); h ^= (h >>> 20) ^ (h >>> 12); return h ^ (h >>> 7) ^ (h >>> 4); } static int indexFor(int h, int length) { return h & (length-1); }
前面我说过,indexFor
方法其实主要是将hash生成的整型转换成链表数组中的下标。那么return h & (length-1);
是什么意思呢?其实,他就是取模。Java之全部使用位运算(&)来代替取模运算(%),最主要的考虑就是效率。位运算(&)效率要比代替取模运算(%)高不少,主要缘由是位运算直接对内存数据进行操做,不须要转成十进制,所以处理速度很是快。
那么,为何可使用位运算(&)来实现取模运算(%)呢?这实现的原理以下:
X % 2^n = X & (2^n – 1)
2^n表示2的n次方,也就是说,一个数对2^n取模 == 一个数和(2^n – 1)作按位与运算 。
假设n为3,则2^3 = 8,表示成2进制就是1000。2^3 = 7 ,即0111。
此时X & (2^3 – 1) 就至关于取X的2进制的最后三位数。
从2进制角度来看,X / 8至关于 X >> 3,即把X右移3位,此时获得了X / 8的商,而被移掉的部分(后三位),则是X % 8,也就是余数。
上面的解释不知道你有没有看懂,没看懂的话其实也不要紧,你只须要记住这个技巧就能够了。或者你能够找几个例子试一下。
6 % 8 = 6 ,6 & 7 = 6
10 & 8 = 2 ,10 & 7 = 2
因此,return h & (length-1);
只要保证length的长度是2^n
的话,就能够实现取模运算了。而HashMap中的length也确实是2的倍数,初始值是16,以后每次扩充为原来的2倍。
分析完indexFor
方法后,咱们接下来准备分析hash
方法的具体原理和实现。在深刻分析以前,至此,先作个总结。
HashMap的数据是存储在链表数组里面的。在对HashMap进行插入/删除等操做时,都须要根据K-V对的键值定位到他应该保存在数组的哪一个下标中。而这个经过键值求取下标的操做就叫作哈希。HashMap的数组是有长度的,Java中规定这个长度只能是2的倍数,初始值为16。简单的作法是先求取出键值的hashcode,而后在将hashcode获得的int值对数组长度进行取模。为了考虑性能,Java总采用按位与操做实现取模操做。
接下来咱们会发现,不管是用取模运算仍是位运算都没法直接解决冲突较大的问题。好比:CA11 0000
和0001 0000
在对0000 1111
进行按位与运算后的值是相等的。
两个不一样的键值,在对数组长度进行按位与运算后获得的结果相同,这不就发生了冲突吗。那么如何解决这种冲突呢,来看下Java是如何作的。
其中的主要代码部分以下:
h ^= k.hashCode(); h ^= (h >>> 20) ^ (h >>> 12); return h ^ (h >>> 7) ^ (h >>> 4);
这段代码是为了对key的hashCode进行扰动计算,防止不一样hashCode的高位不一样但低位相同致使的hash冲突。简单点说,就是为了把高位的特征和低位的特征组合起来,下降哈希冲突的几率,也就是说,尽可能作到任何一位的变化都能对最终获得的结果产生影响。
举个例子来讲,咱们如今想向一个HashMap中put一个K-V对,Key的值为“hollischuang”,通过简单的获取hashcode后,获得的值为“1011000110101110011111010011011”,若是当前HashTable的大小为16,即在不进行扰动计算的状况下,他最终获得的index结果值为11。因为15的二进制扩展到32位为“00000000000000000000000000001111”,因此,一个数字在和他进行按位与操做的时候,前28位不管是什么,计算结果都同样(由于0和任何数作与,结果都为0)。以下图所示。
能够看到,后面的两个hashcode通过位运算以后获得的值也是11 ,虽然咱们不知道哪一个key的hashcode是上面例子中的那两个,可是确定存在这样的key,这就产生了冲突。
那么,接下来,我看看一下通过扰动的算法最终的计算结果会如何。
从上面图中能够看到,以前会产生冲突的两个hashcode,通过扰动计算以后,最终获得的index的值不同了,这就很好的避免了冲突。
其实,使用位运算代替取模运算,除了性能以外,还有一个好处就是能够很好的解决负数的问题。由于咱们知道,hashcode的结果是int类型,而int的取值范围是-2^31 ~ 2^31 – 1,即[ -2147483648, 2147483647];这里面是包含负数的,咱们知道,对于一个负数取模仍是有些麻烦的。若是使用二进制的位运算的话就能够很好的避免这个问题。首先,无论hashcode的值是正数仍是负数。length-1这个值必定是个正数。那么,他的二进制的第一位必定是0(有符号数用最高位做为符号位,“0”表明“+”,“1”表明“-”),这样里两个数作按位与运算以后,第一位必定是个0,也就是,获得的结果必定是个正数。
上面是Java 7中HashMap的hash
方法以及indexOf
方法的实现,那么接下来咱们要看下,线程安全的HashTable是如何实现的,和HashMap有何不一样,并试着分析下不一样的缘由。如下是Java 7中HashTable的hash方法的实现。
private int hash(Object k) { // hashSeed will be zero if alternative hashing is disabled. return hashSeed ^ k.hashCode(); }
咱们能够发现,很简单,至关于只是对k作了个简单的hash,取了一下其hashCode。而HashTable中也没有indexOf
方法,取而代之的是这段代码:int index = (hash & 0x7FFFFFFF) % tab.length;
。也就是说,HashMap和HashTable对于计算数组下标这件事,采用了两种方法。HashMap采用的是位运算,而HashTable采用的是直接取模。
为啥要把hash值和0x7FFFFFFF作一次按位与操做呢,主要是为了保证获得的index的第一位为0,也就是为了获得一个正数。由于有符号数第一位0表明正数,1表明负数。
咱们前面说过,HashMap之因此不用取模的缘由是为了提升效率。有人认为,由于HashTable是个线程安全的类,原本就慢,因此Java并无考虑效率问题,就直接使用取模算法了呢?可是其实并不彻底是,Java这样设计仍是有必定的考虑在的,虽然这样效率确实是会比HashMap慢一些。
其实,HashTable采用简单的取模是有必定的考虑在的。这就要涉及到HashTable的构造函数和扩容函数了。因为篇幅有限,这里就不贴代码了,直接给出结论:
HashTable默认的初始大小为11,以后每次扩充为原来的2n+1。
也就是说,HashTable的链表数组的默认大小是一个素数、奇数。以后的每次扩充结果也都是奇数。
因为HashTable会尽可能使用素数、奇数做为容量的大小。当哈希表的大小为素数时,简单的取模哈希的结果会更加均匀。(这个是能够证实出来的,因为不是本文重点,暂不详细介绍,可参考:http://zhaox.github.io/algorithm/2015/06/29/hash)
至此,咱们看完了Java 7中HashMap和HashTable中对于hash的实现,咱们来作个简单的总结。
private int hash(Object k) { int h = hashSeed; if ((0 != h) && (k instanceof String)) { return sun.misc.Hashing.stringHash32((String) k); } h ^= k.hashCode(); // Spread bits to regularize both segment and index locations, // using variant of single-word Wang/Jenkins hash. h += (h << 15) ^ 0xffffcd7d; h ^= (h >>> 10); h += (h << 3); h ^= (h >>> 6); h += (h << 2) + (h << 14); return h ^ (h >>> 16); } int j = (hash >>> segmentShift) & segmentMask;
上面这段关于ConcurrentHashMap的hash实现其实和HashMap一模一样。都是经过位运算代替取模,而后再对hashcode进行扰动。区别在于,ConcurrentHashMap 使用了一种变种的Wang/Jenkins 哈希算法,其主要母的也是为了把高位和低位组合在一块儿,避免发生冲突。至于为啥不和HashMap采用一样的算法进行扰动,我猜这只是程序员自由意志的选择吧。至少我目前没有办法证实哪一个更优。
在Java 8 以前,HashMap和其余基于map的类都是经过链地址法解决冲突,它们使用单向链表来存储相同索引值的元素。在最坏的状况下,这种方式会将HashMap的get方法的性能从O(1)
下降到O(n)
。为了解决在频繁冲突时hashmap性能下降的问题,Java 8中使用平衡树来替代链表存储冲突的元素。这意味着咱们能够将最坏状况下的性能从O(n)
提升到O(logn)
。关于HashMap在Java 8中的优化,我后面会有文章继续深刻介绍。
若是恶意程序知道咱们用的是Hash算法,则在纯链表状况下,它可以发送大量请求致使哈希碰撞,而后不停访问这些key致使HashMap忙于进行线性查找,最终陷入瘫痪,即造成了拒绝服务攻击(DoS)。
关于Java 8中的hash函数,原理和Java 7中基本相似。Java 8中这一步作了优化,只作一次16位右位移异或混合,而不是四次,但原理是不变的。
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
在JDK1.8的实现中,优化了高位运算的算法,经过hashCode()的高16位异或低16位实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度、功效、质量来考虑的。以上方法获得的int的hash值,而后再经过h & (table.length -1)
来获得该对象在数据中保存的位置。
HashTable In Java 8
在Java 8的HashTable中,已经不在有hash方法了。可是哈希的操做仍是在的,好比在put方法中就有以下实现:
int hash = key.hashCode(); int index = (hash & 0x7FFFFFFF) % tab.length;
这其实和Java 7中的实现几乎无差异,就不作过多的介绍了。
Java 8 里面的求hash的方法从hash改成了spread。实现方式以下:
static final int spread(int h) { return (h ^ (h >>> 16)) & HASH_BITS; }
Java 8的ConcurrentHashMap一样是经过Key的哈希值与数组长度取模肯定该Key在数组中的索引。一样为了不不太好的Key的hashCode设计,它经过以下方法计算获得Key的最终哈希值。不一样的是,Java 8的ConcurrentHashMap做者认为引入红黑树后,即便哈希冲突比较严重,寻址效率也足够高,因此做者并未在哈希值的计算上作过多设计,只是将Key的hashCode值与其高16位做异或并保证最高位为0(从而保证最终结果为正整数)。
至此,咱们已经分析完了HashMap、HashTable以及ConcurrentHashMap分别在Jdk 1.7 和 Jdk 1.8中的实现。咱们能够发现,为了保证哈希的结果能够分散、为了提升哈希的效率,JDK在一个小小的hash方法上就有不少考虑,作了不少事情。固然,我但愿咱们不只能够深刻了解背后的原理,还要学会这种对代码精益求精的态度。
Jdk的源代码,每一行都颇有意思,都值得花时间去钻研、推敲。