HashMap
简介
散列表(Hash table,也叫哈希表),是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。
给定表M,存在函数f(key),对任意给定的关键字值key,代入函数后若能得到包含该关键字的记录在表中的地址,则称表M为哈希(Hash)表,函数f(key)为哈希(Hash) 函数。
从这段解释中,我们理应知道的:
- 哈希表是一种数据结构
- 哈希表表示了关键码值和记录的映射关系
- 哈希表可以加快查找速度
- 任意哈希表,都满足有哈希函数f(key),代入任意key值都可以获取包含该key值的记录在表中的地址
HashMap在jdk1.7及以前是使用数组+链表来实现的,jdk1.8及之后是采用数组+链表+红黑树来实现的,同时jdk1.8中还将HashEntry 修改为 Node,同时多个一个转化为红黑的阈值的变量
HashMap本质上是一个哈希表,那么就离不开三大问题:哈希函数、哈希冲突、扩容方案,同时还要考虑到多线程并发的线程安全问题,总共四个问题
下面的内容主要围绕这四个问题展开
四大问题
哈希函数
哈希函数的目标是计算key在哈希桶数组中的下标
HashMap中哈希函数的步骤为:①对key对象的hashcode进行扰动②通过取模求得数组下标
扰动的目的是让hashcode的随机性更高,提高散列均匀度,不会让所有的key都聚集在一起
①如下图,进行的扰动是将hashcode的高16位保持不变 ,而将低16位和高16位进行异或。取模运算中只有低位参与散列,高位与地位进行异或,让高位也得以参与散列运算,使得散列更加均匀
②对hashcode进行扰动之后就要对结果进行取模了,在jdk1.8中HashMap不是简单的使用%进行取模,而是采用了一种更加高性能的方法。HashMap控制数组长度为2的整数次幂,好处是对hashcode进行求余运算和让hashcode与数组长度-1进行位与运算是相同的效果,而位运算的效率要比求余高得多,这就提升了性能
哈希冲突解决方案
hash冲突指的是两个不同的key经过hash计算之后得到的数组的下标是相同的,解决hash冲突可以采用开放定址法、再哈希法、链地址法。HashMap中采用的是链地址法,jdk1.8之后还增加了红黑树的优化
-
开放定址法:如果冲突,在原来结果的基础上,进行+1,+2等的操作,找不冲突的位置。 缺点:不能随意删除节点,会影响其他节点的查找,只能标记为已删除节点。查找时,由于冲突的左右移动解决方法,也会影响查找速度
-
再哈希法:冲突时,再用另一个哈希算法对于键进行哈希 速度较慢。而且如果再冲突还需再哈希
-
链地址法:将冲突部分链接成一个链表。 简单。 但是当链表长度过长会一定程度影响查找速度
出现冲突之后会在当前节点形成链表,链表过长时会自动转化成红黑树来提高查找效率。红黑树是一个查找效率很高的数据结构,时间复杂度为O(logN),但红黑树只有在数据量较大时才能发挥它的优势。关于红黑树的转化,HashMap做了以下限制
- 当链表的长度>=8且数组长度>=64时,会把链表转化成红黑树。
- 当链表长度>=8,但数组长度<64时,会优先进行扩容,而不是转化成红黑树。
- 当红黑树节点数<=6,自动转化成链表。
解释:
①当数组长度较短时,如16,链表长度达到8已经是占用了最大限度的50%,意味着负载已经快要达到上限,此时如果转化成红黑树,之后的扩容又会再一次把红黑树拆分平均到新的数组中,这样非但没有带来性能的好处,反而会降低性能。所以在数组长度低于64时,优先进行扩容。
②树节点的比普通节点更大,在链表较短时红黑树并未能明显体现性能优势,反而会浪费空间,在链表较短是采用链表而不是红黑树。在理论数学计算中(装载因子=0.75),链表的长度到达8的概率是百万分之一;把7作为分水岭,大于7转化为红黑树,小于7转化为链表。红黑树的出现是为了在某些极端的情况下,抗住大量的hash冲突,正常情况下使用链表是更加合适的。
(static final int TREEIFY_THRESHOLD = 8是转化为红黑树的阈值)
③首先和hashcode碰撞次数的泊松分布有关,主要是为了寻找一种时间和空间的平衡。在负载因子0.75(HashMap默认)的情况下,单个hash槽内元素个数为8的概率小于百万分之一,将7作为一个分水岭,等于7时不做转换,大于等于8才转红黑树,小于等于6才转链表。链表中元素个数为8时的概率已经非常小,再多的就更少了,所以原作者在选择链表元素个数时选择了8,是根据概率统计而选择的。
④红黑树中的TreeNode是链表中的Node所占空间的2倍,虽然红黑树的查找效率为o(logN),要优于链表的o(N),但是当链表长度比较小的时候,即使全部遍历,时间复杂度也不会太高。所以,要寻找一种时间和空间的平衡,即在链表长度达到一个阈值之后再转换为红黑树。 之所以是8,是因为Java的源码贡献者在进行大量实验发现,hash碰撞发生8次的概率已经降低到了0.00000006,几乎为不可能事件,如果真的碰撞发生了8次,那么这个时候说明由于元素本身和hash函数的原因,此次操作的hash碰撞的可能性非常大了,后序可能还会继续发生hash碰撞。所以,这个时候,就应该将链表转换为红黑树了,也就是为什么链表转红黑树的阈值是8。 最后,红黑树转链表的阈值为6,主要是因为,如果也将该阈值设置于8,那么当hash碰撞在8时,会反生链表和红黑树的不停相互激荡转换,白白浪费资源。
扩容方案
当HashMap中的数据越来越多,那么发生hash冲突的概率也就会越来越高,通过数组扩容可以利用空间换时间,保持查找效率在常数时间复杂度
装载因子=HashMap中节点数/数组长度,他是一个比例值。当HashMap中节点数到达装载因子这个比例时,就会触发扩容;也就是说,装载因子控制了当前数组能够承载的节点数的阈值。如数组长度是16,装载因子是0.75,那么可容纳的节点数是16*0.75=12。装载因子的数值大小需要仔细权衡。装载因子越大,数组利用率越高,同时发生哈希冲突的概率也就越高;装载因子越小,数组利用率降低,但发生哈希冲突的概率也降低了。所以装载因子的大小需要权衡空间与时间之间的关系。在理论计算中,0.75是一个比较合适的数值,大于0.75哈希冲突的概率呈指数级别上升,而小于0.75冲突减少并不明显。HashMap中的装载因子的默认大小是0.75,没有特殊要求的情况下,不建议修改他的值。
那么在到达阈值之后,HashMap会把数组长度扩展为原来的两倍,再把旧数组的数据迁移到新的数组,而HashMap针对迁移做了优化:使用HashMap数组长度是2的整数次幂的特点,以一种更高效率的方式完成数据迁移。
JDK1.7之前的数据迁移比较简单,就是遍历所有的节点,把所有的节点依次通过hash函数计算新的下标,再插入到新数组的链表中。这样会有两个缺点:1、每个节点都需要进行一次求余计算;2、插入到新的数组时候采用的是头插入法,在多线程环境下会形成链表环。jdk1.8之后进行了优化,原因在于他控制数组的长度始终是2的整数次幂,每次扩展数组都是原来的2倍,带来的好处是key在新的数组的hash结果只有两种:在原来的位置,或者在原来位置整体往右移动原数组长度个单位
从图中我们可以看到,在新数组中的hash结果,仅仅取决于高一位的数值。如果高一位是0,那么计算结果就是在原位置,而如果是1,则加上原数组的长度即可。这样我们只需要判断一个节点的hashCode高一位是1 or 0就可以得到他在新数组的位置,而不需要重复hash计算。HashMap把每个链表拆分成两个链表,对应原位置或原位置+原数组长度,再分别插入到新的数组中,保留原来的节点顺序
线程安全
HashMap作为一个集合,主要功能是CURD,那么肯定会涉及到多线程的线程安全问题。HashMap并不是线程安全的,在多线程的情况下无法保证数据的一致性。例如:线程A需要将节点X插入下标2的位置,在判断是否为null之后,线程被挂起;此时线程B把新的节点Y插入到下标2的位置;恢复线程A,节点X会直接插入到下标2,覆盖节点Y,导致数据丢失。同时也有可能出现死循环问题
jdk1.7及以前扩容时采用的是头插法,这种方式插入速度快,但在多线程环境下会造成链表环,而链表环会在下一次插入时找不到链表尾而发生死循环。jdk1.8之后扩容采用了尾插法,解决了这个问题,但并没有解决数据的一致性问题
解决数据一致性问题,可以采用的方案有:
①Hashtable或者Collections.synchronizeMap(),但是这两个的思路是相似的,都是在每个方法中给整个对象上锁,即加上synchronized关键字。造成的后果是:1.锁是非常重量级的,会严重影响性能2.同一时间只能有一个线程进行读写,限制了并发效率
②ConcurrentHashMap的设计就是为了解决此问题的,这个后面会讲到。他通过降低锁粒度+CAS的方式来提高效率。简单来说,ConcurrentHashMap锁的并不是整个对象,而是一个数组的一个节点,那么其他线程访问数组其他节点是不会互相影响,极大提高了并发效率;同时ConcurrentHashMap读操作并不需要获取锁。但是也无法保证绝对线程安全
HashMap的遍历方法
存在的线程安全问题
jdk1.8之前
正常的 ReHash 的过程
- 假设我们的 hash 算法就是简单的用 key mod 一下表的大小(也就是数组的长度)
- 最上面的是 old hash 表,其中的 Hash 表的 size = 2,所以 key = 3, 7, 5,在 mod 2 以后都冲突在 table[1] 这里了(头插入法)
- 接下来的三个步骤是 Hash 表 resize 成 4,然后所有的 <key, value> 重新 rehash 的过程
并发下的 Rehash
初始的HashMap还是:
我们现在假设有两个线程并发操作,都进入了扩容操作, 我们以颜色进行区分两个线程。
回顾我们的扩容代码,我们假设,线程1执行到Entry<K,V> next = e.next;时被操作系统调度挂起了,而线程2执行完成了扩容操作
于是,在线程1,2看来,就应该是这个样子
接下来,线程1被调度回来执行:
1)
2)
3)
4)
5)
6)
7)
循环列表产生后,一旦线程1调用get(11,15之类的元素)时,就会进入一个死循环的情况,将CPU的消耗到100%。
JDK 8 的改进
JDK 8 中采用的是位桶 + 链表/红黑树的方式,当某个位桶的链表的长度超过 8 的时候,这个链表就将转换成红黑树
HashMap 不会因为多线程 put 导致死循环(JDK 8 用 head 和 tail 来保证链表的顺序和之前一样;JDK 7 rehash 会倒置链表元素),但是还会有数据丢失等弊端(并发本身的问题)。因此多线程情况下还是建议使用 ConcurrentHashMap
为什么线程不安全
HashMap 在并发时可能出现的问题主要是两方面:
- 如果多个线程同时使用 put 方法添加元素,而且假设正好存在两个 put 的 key 发生了碰撞(根据 hash 值计算的 bucket 一样),那么根据 HashMap 的实现,这两个 key 会添加到数组的同一个位置,这样最终就会发生其中一个线程 put 的数据被覆盖
- 如果多个线程同时检测到元素个数超过数组大小 * loadFactor,这样就会发生多个线程同时对 Node 数组进行扩容,都在重新计算元素位置以及复制数据,但是最终只有一个线程扩容后的数组会赋给 table,也就是说其他线程的都会丢失,并且各自线程 put 的数据也丢失
\