赞
踩
JDK7中HashMap底层实现数据结构为数组+链表的形式,JDK8及其以后的版本中使用了数组+链表+红黑树实现,解决了链表太长导致的查询速度变慢的问题。
简单来说,HashMap由数组+链表组成的,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的。HashMap通过key的HashCode经过扰动函数处理过后得到Hash值,然后通过位运算判断当前元素存放的位置,如果当前位置存在元素的话,就判断该元素与要存入的元素的hash值以及key是否相同,如果相同的话,直接覆盖,不相同就通过拉链法解决冲突。当Map中的元素总数超过Entry数组的0.75时,触发扩容操作,为了减少链表长度,元素分配更均匀。
JDK1.8中,是通过hashCode()的高16位异或低16位实现的:(h=k.hashCode())^(h>>>16),主要是从速度,功效和质量来考虑的,减少系统的开销,也不会造成因为高位没有参与下标的计算,从而引起的碰撞。
static final int hash(Object key){
int h;
return (key==null)?0:(h=key.hashCode())^(h>>>16);
}
计算规则说明:
key.hashCode();返回散列值也就是hashcode,假设随便生成的一个值。
n表示数组初始化的长度是16。
&(按位与运算):运算规则:相同的二进制数位上,都是1的时候,结果为1,否则为零。
^(按位异或运算):运算规则:相同的二进制数位上,数字相同,结果为0,不同为1。
计算过程如下所示:
问:为什么这里把key的hashcode取出来,把它右移16位,然后取异或?
答:因为int是4个字节,也就是32位,让高16位向右移动16位后参与到位运算中,较少了hash冲突。
DEFAULT_INITIAL_CAPACITY:默认的初始化容量,1<<4位运算的结果是16,也就是默认的初始化容量为16。当然如果对要存储的数据有一个估计值,最好在初始化的时候显示的指定容量大小,减少扩容时的数据搬移等带来的效率消耗。同时,容量大小需要是2的整数倍。
MAXIMUM_CAPACITY:容量的最大值,1 << 30位,2的30次幂。
DEFAULT_LOAD_FACTOR:默认的加载因子,0.75 设计者认为这个数值是基于时间和空间消耗上最好的数值。这个值和容量的乘积是一个很重要的数值,也就是阈值,当达到这个值时候会产生扩容,扩容的大小大约为原来的二倍。
TREEIFY_THRESHOLD:因为jdk8以后,HashMap底层的存储结构改为了数组+链表+红黑树的存储结构(之前是数组+链表),刚开始存储元素产生碰撞时会在碰撞的数组后面挂上一个链表,当链表长度大于这个参数时,链表就可能会转化为红黑树,为什么可能后面还有一个参数,需要他们两个都满足的时候才会转化。
UNTREEIFY_THRESHOLD:介绍上面的参数时,我们知道当长度过大时可能会产生从链表到红黑树的转化,但是,元素不仅仅只能添加还可以删除,或者另一种情况,扩容后该数组槽位置上的元素数据不是很多了,还使用红黑树的结构就会很浪费,所以这时就可以把红黑树结构变回链表结构,什么时候变,就是元素数量等于这个值也就是6的时候变回来(元素数量指的是一个数组槽内的数量,不是HashMap中所有元素的数量)。
MIN_TREEIFY_CAPACITY:链表树化的一个标准,前面说过当数组槽内的元素数量大于8时可能会转化为红黑树,之所以说是可能就是因为这个值,当数组的长度小于这个值的时候,会先去进行扩容,扩容之后就有很大的可能让数组槽内的数据可以更分散一些了,也就不用转化数组槽后的存储结构了。当然,长度大于这个值并且槽内数据大于8时,那就转化为红黑树吧。
是因为长度扩大以后,Hash的规则也随之改变。比如原来长度(Length)是8,位运算出来的值是2 ,新的长度是16,位运算出来的值明显不一样了。
HashMap是线程不安全的,是允许key和value的值为null的,速度更慢,效率更快
Hashtable是线程安全的,是不允许key和value的值为null的,速度更快,效率更慢
HashMap是线程不安全的,HashTable是线程安全的;
由于线程安全,所以HashTable的效率比不上HashMap;
HashMap最多只允许一条记录的键为null,允许多条记录的值为null,而HashTable不允许;
HashMap默认初始化数组的大小为16,HashTable为11,前者扩容时,扩大两倍,后者扩大两倍+1;
HashMap需要重新计算hash值,而HashTable直接使用对象的hashCode;
HashMap底层是hash数组和单向链表实现,数组中的每个元素都是链表,由Node内部类(实现Map.Entry<K,V>接口)实现,HashMap通过put&get方法存储和获取。
存储对象时,将K/V键值传给put()方法:
调用hash(K)方法计算K的hash值,然后结合数组长度,计算得数组下标;
调整数组大小(当容器中的元素个数大于capacity*loadfactor时,容器会进行扩容resize为2n);
第三个步骤中包含三个小的步骤:
如果K的hash值在HashMap中不存在,则执行插入,若存在,则发生碰撞;
如果K的hash值在HashMap中存在,且它们两者equals返回true,则更新键值对;
如果K的hash值在HashMap中存在,且它们两者equals返回false,则插入链表的尾部(尾插法)或者红黑树中(树的添加方式)。
(JDK1.7之前使用头插法、JDK1.8使用尾插法)
(注意:当碰撞导致链表大于TREEIFY_THRESHOLD=8时,就把链表转换成红黑树)
table数组大小是由capacity这个参数确定的,默认是16,也可以构造时传入,最大限制是1<<30;
loadFactor是装载因子,主要目的是用来确认table数组是否需要动态扩展,默认值是0.75,比如table数组大小为16,装载因子为0.75时,threshold就是12,当table的实际大小超过12时,table就需要动态扩容;
扩容时,调用resize()方法,将table长度变为原来的两倍(注意是table长度,而不是threshold)
如果数据很大的情况下,扩展时将会带来性能的损失,在性能要求很高的地方,这种损失很可能很致命。
数组扩容的算法
创建一个新的数组,其容量为旧数组的两倍,并重新计算旧数组中结点的存储位置。结点在新数组中的位置只有两种,原下标位置或原下标+旧数组的大小。
什么时候才需要扩容
当HashMap中的元素个数超过数组大小(数组长度)*loadFactor(负载因子)时,就会进行数组扩容,loadFactor的默认值(DEFAULT_LOAD_FACTOR)是0.75,这是一个折中的取值。也就是说,默认情况下,数组大小为16,那么当HashMap中的元素个数超过16×0.75=12(这个值就是阈值或者边界值threshold值)的时候,就把数组的大小扩展为2×16=32,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常耗性能的操作,所以如果我们已经预知HashMap中元素的个数,那么预知元素的个数能够有效的提高HashMap的性能。
链表什么时候转换为红黑树
当HashMap中的其中一个链表的对象个数如果达到了8个,此时如果数组长度没有达到64,那么HashMap会先扩容解决,如果已经达到了64,那么这个链表会变成红黑树,结点类型由Node变成TreeNode类型。当然,如果映射关系被移除后,下次执行resize方法时判断树的结点个数低于6,也会再把树转换为链表。
HashMap的扩容是什么
进行扩容,会伴随着一次重新hash分配,并且会遍历hash表中所有的元素,是非常耗时的。在编写程序中,要尽量避免resize。
HashMap在进行扩容时,使用的rehash方式非常巧妙,因为每次扩容都是翻倍,与原来计算的 (n-1)&hash的结果相比,只是多了一个bit位,所以结点要么就在原来的位置,要么就被分配到”原位置+旧容量”这个位置。
在java1.8中,如果链表的长度超过了8,那么链表将转换为红黑树。(桶的数量必须大于64,小于64的时候只会扩容)
发生hash碰撞时,java1.7会在链表的头部插入,而java1.8会在链表的尾部插入
在java1.8中,Entry被Node替代
第一:当length为2的N次方的时候,h & (length-1) = h % length
为什么&效率更高呢?因为位运算直接对内存数据进行操作,不需要转成十进制,所以位运算要比取模运算的效率更高
第二:当length为2的N次方的时候,数据分布均匀,减少冲突
hashMap是线程不安全的,其主要体现:
在jdk1.7中,在多线程环境下,扩容时会造成环形链或数据丢失。
现在我们要在容量为2的容器里面用不同线程插入A,B,C,假如我们在resize之前打个短点,那意味着数据都插入了但是还没resize那扩容前可能是这样的。
我们可以看到链表的指向A->B->C
Tip:A的下一个指针是指向B的
因为resize的赋值方式,也就是使用了单链表的头插入方式,同一位置上新元素总会被放在链表的头部位置,在旧数组中同一条Entry链上的元素,通过重新计算索引位置后,有可能被放到了新数组的不同位置上。
就可能出现下面的情况,大家发现问题没有?
B的下一个指针指向了A
一旦几个线程都调整完成,就可能出现环形链表
如果这个时候去取值,悲剧就出现了——Infinite Loop。
在jdk1.8中,在多线程环境下,会发生数据覆盖的情况。
jdk1.8中HashMap中put操作的主函数,如果没有hash碰撞则会直接插入元素。如果线程A和线程B同时进行put操作,刚好这两条不同的数据hash值一样,并且该位置数据为null,所以这线程A、B都会进入接下来的逻辑中。假设一种情况,线程A进入后还未进行数据插入时挂起,而线程B正常执行,从而正常插入数据,然后线程A获取CPU时间片,此时线程A不用再进行hash判断了,问题出现:线程A会把线程B插入的数据给覆盖,发生线程不安全。
当我们使用HashMap(int initialCapacity)来初始化容量的时候,jdk会默认帮我们计算一个相对合理的值当做初始容量。那么,是不是我们只需要把已知的HashMap中即将存放的元素个数直接传给initialCapacity就可以了呢?
关于这个值的设置,在《阿里巴巴Java开发手册》有以下建议:
initialCapacity=(需要存储的元素个数/负载因子)+1,注意负载因子默认是0.75
也就是说,如果我们设置的默认值是7,经过Jdk处理之后,会被设置成8,但是,这个HashMap在元素个数达到 8*0.75 = 6的时候就会进行一次扩容,这明显是我们不希望见到的。我们应该尽量减少扩容。原因也已经分析过。
如果我们通过initialCapacity/ 0.75F + 1.0F计算,7/0.75 + 1 = 10 ,10经过Jdk处理之后,会被设置成16,这就大大的减少了扩容的几率。
当HashMap内部维护的哈希表的容量达到75%时(默认情况下),会触发rehash,而rehash的过程是比较耗费时间的。所以初始化容量要设置成initialCapacity/0.75 + 1的话,可以有效的减少冲突也可以减小误差。
所以,我可以认为,当我们明确知道HashMap中元素的个数的时候,把默认容量设置成initialCapacity/ 0.75F + 1.0F是一个在性能上相对好的选择,但是,同时也会牺牲些内存。
我们想要在代码中创建一个HashMap的时候,如果我们已知这个Map中即将存放的元素个数,给HashMap设置初始容量可以在一定程度上提升效率。
但是,JDK并不会直接拿用户传进来的数字当做默认容量,而是会进行一番运算,最终得到一个2的幂。原因也已经分析过。
所以为了最大程度的避免扩容带来的性能消耗,我们建议可以把默认容量的数字设置成initialCapacity/ 0.75F + 1.0F。
答:在解决这个问题之前,我们首先需要知道什么是哈希冲突,而在了解哈希冲突之前我们还要知道什么是哈希才行;
什么是哈希?
Hash,一般翻译为“散列”,也有直接音译为“哈希”的,这就是把任意长度的输入通过散列算法,变换成固定长度的输出,该输出就是散列值(哈希值);这种转换是一种压缩映射,也就是,散列值的空间通常远小于输入的空间,不同的输入可能会散列成相同的输出,所以不可能从散列值来唯一的确定输入值。简单的说就是一种将任意长度的消息压缩到某一固定长度的消息摘要的函数。
所有散列函数都有如下一个基本特性**:根据同一散列函数计算出的散列值如果不同,那么输入值肯定也不同。但是,根据同一散列函数计算出的散列值如果相同,输入值不一定相同**。
什么是哈希冲突?
当两个不同的输入值,根据同一散列函数计算出相同的散列值的现象,我们就把它叫做碰撞(哈希碰撞)。
HashMap的数据结构
在Java中,保存数据有两种比较简单的数据结构:数组和链表。数组的特点是:寻址容易,插入和删除困难;链表的特点是:寻址困难,但插入和删除容易;所以我们将数组和链表结合在一起,发挥两者各自的优势,使用一种叫做链地址法的方式可以解决哈希冲突:
这样我们就可以将拥有相同哈希值的对象组织成一个链表放在hash值所对应的bucket下,但相比于hashCode返回的int类型,我们HashMap初始的容量大小DEFAULT_INITIAL_CAPACITY = 1 << 4(即2的四次方16)要远小于int类型的范围,所以我们如果只是单纯的用hashCode取余来获取对应的bucket这将会大大增加哈希碰撞的概率,并且最坏情况下还会将HashMap变成一个单链表,所以我们还需要对hashCode作一定的优化
hash()函数
上面提到的问题,主要是因为如果使用hashCode取余,那么相当于参与运算的只有hashCode的低位,高位是没有起到任何作用的,所以我们的思路就是让hashCode取值出的高位也参与运算,进一步降低hash碰撞的概率,使得数据分布更平均,我们把这样的操作称为扰动,在JDK 1.8中的hash()函数如下:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);// 与自己右移16位进行异或运算(高低位异或)
}
这比在JDK 1.7中,更为简洁,相比在1.7中的4次位运算,5次异或运算(9次扰动),在1.8中,只进行了1次位运算和1次异或运算(2次扰动);
通过上面的链地址法(使用散列表)和扰动函数我们成功让我们的数据分布更平均,哈希碰撞减少,但是当我们的HashMap中存在大量数据时,加入我们某个bucket下对应的链表有n个元素,那么遍历时间复杂度就为O(n),为了针对这个问题,JDK1.8在HashMap中新增了红黑树的数据结构,进一步使得遍历复杂度降低至O(logn);
总结
简单总结一下HashMap是使用了哪些方法来有效解决哈希冲突的:
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。