1. hashmap的数据结构
Hashmap实际上是一个数组和链表的结合体(在数据结构中,一般称之为“链表散列“),请看下图(横排表示数组,纵排表示数组元素【实际上是一个链表】)。
从图中我们可以看到一个hashmap就是一个数组结构,当新建一个hashmap的时候,就会初始化一个数组。Entry就是数组中的元素,它持有一个指向下一个元素的引用,这就构成了链表。
当我们往hashmap中put元素的时候,先根据key的hash值得到这个元素在数组中的位置(即下标),然后就可以把这个元素放到对应的位置中了。如果这个元素所在的位子上已经存放有其他元素了,那么在同一个位子上的元素将以链表的形式存放,新加入的放在链头,最先加入的放在链尾。从hashmap中get元素时,首先计算key的hashcode,找到数组中对应位置的某一元素,然后通过key的equals方法在对应位置的链表中找到需要的元素。从这里我们可以想象得到,如果每个位置上的链表只有一个元素,那么hashmap的get效率将是最高的,一般实现不了。
2. hash算法
我们可以看到在hashmap中要找到某个元素,需要根据key的hash值来求得对应数组中的位置。如何计算这个位置就是hash算法。前面说过hashmap的数据结构是数组和链表的结合,所以我们当然希望这个hashmap里面的元素位置尽量的分布均匀些,尽量使得每个位置上的元素数量只有一个,那么当我们用hash算法求得这个位置的时候,马上就可以知道对应位置的元素就是我们要的,而不用再去遍历链表。java中时这样做的,
首先算得key得hashcode值,然后跟数组的长度-1做一次“与”运算(&)。看上去很简单,其实比较有玄机。比如数组的长度是2的4次方,那么hashcode就会和2的4次方-1做“与”运算。很多人都有这个疑问,为什么hashmap的数组初始化大小都是2的次方大小时,hashmap的效率最高,我以2的4次方举例,来解释一下为什么数组大小为2的幂时hashmap访问的性能最高。
看下图,左边两组是数组长度为16(2的4次方),右边两组是数组长度为15。两组的hashcode均为8和9,但是很明显,当它们和1110“与”的时候,产生了相同的结果,也就是说它们会定位到数组中的同一个位置上去,这就产生了碰撞,8和9会被放到同一个链表上,那么查询的时候就需要遍历这个链表,得到8或者9,这样就降低了查询的效率。同时,我们也可以发现,当数组长度为15的时候,hashcode的值会与14(1110)进行“与”,那么最后一位永远是0,而0001,0011,0101,1001,1011,0111,1101这几个位置永远都不能存放元素了,空间浪费相当大,更糟的是这种情况中,数组可以使用的位置比数组长度小了很多,这意味着进一步增加了碰撞的几率,减慢了查询的效率!
所以说,当数组长度为2的n次幂的时候,不同的key算得得index相同的几率较小,那么数据在数组上分布就比较均匀,也就是说碰撞的几率小,相对的,查询的时候就不用遍历某个位置上的链表,这样查询效率也就较高了。
说到这里,我们再回头看一下hashmap中默认的数组大小是多少,查看源代码可以得知是16,为什么是16,而不是15,也不是20呢,看到上面的解释之后我们就清楚了吧,显然是因为16是2的整数次幂的原因,在小数据量的情况下16比15和20更能减少key之间的碰撞,而加快查询的效率。
所以,在存储大容量数据的时候,最好预先指定hashmap的size为2的整数次幂次方。就算不指定的话,也会以大于且最接近指定值大小的2次幂来初始化的。
3. hashmap的resize
当hashmap中的元素越来越多的时候,碰撞的几率也就越来越高(因为数组的长度是固定的),所以为了提高查询的效率,就要对hashmap的数组进行扩容,数组扩容这个操作也会出现在ArrayList中,所以这是一个通用的操作,很多人对它的性能表示过怀疑,不过想想我们的“均摊”原理,就释然了,而在hashmap数组扩容之后,最消耗性能的点就出现了:原数组中的数据必须重新计算其在新数组中的位置,并放进去,这就是resize。
那么hashmap什么时候进行扩容呢?当hashmap中的元素个数超过数组大小*loadFactor时,就会进行数组扩容,loadFactor的默认值为0.75,也就是说,默认情况下,数组大小为16,那么当hashmap中元素个数超过16*0.75=12的时候,就把数组的大小扩展为2*16=32,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常消耗性能的操作,所以如果我们已经预知hashmap中元素的个数,那么预设元素的个数能够有效的提高hashmap的性能。比如说,我们有1000个元素new HashMap(1000), 但是理论上来讲new HashMap(1024)更合适,不过上面annegu已经说过,即使是1000,hashmap也自动会将其设置为1024。 但是new HashMap(1024)还不是更合适的,因为0.75*1000 < 1000, 也就是说为了让0.75 * size >1000, 我们必须这样new HashMap(2048)才最合适,既考虑了&的问题,也避免了resize的问题。
4. key的hashcode与equals方法改写
在第一部分hashmap的数据结构中,写了get方法的过程:首先计算key的hashcode,根据hash算法找到数组中对应位置的某一元素,然后通过key的equals方法在对应位置的链表中找到需要的元素。所以,hashcode与equals方法对于找到对应元素是两个关键方法。
Hashmap的key可以是任何类型的对象,例如User这种对象,为了保证两个具有相同属性的user的hashcode相同,我们就需要改写hashcode方法,比方把hashcode值的计算与User对象的id关联起来,那么只要user对象拥有相同id,那么他们的hashcode也能保持一致了,这样就可以找到在hashmap数组中的位置了。如果这个位置上有多个元素,还需要用key的equals方法在对应位置的链表中找到需要的元素,所以只改写了hashcode方法是不够的,equals方法也是需要改写滴~当然啦,按正常思维逻辑,equals方法一般都会根据实际的业务内容来定义,例如根据user对象的id来判断两个user是否相等。
在改写equals方法的时候,需要满足以下三点:
(1) 自反性:就是说a.equals(a)必须为true。
(2) 对称性:就是说a.equals(b)=true的话,b.equals(a)也必须为true。
(3) 传递性:就是说a.equals(b)=true,并且b.equals(c)=true的话,a.equals(c)也必须为true。
通过改写key对象的equals和hashcode方法,我们可以将任意的业务对象作为map的key(前提是你确实有这样的需要)。
5. 解决hash冲突的算法
1) 开放地址法:开放地址法的基本思想是当发生地址冲突时,在散列表中再按照某种方法继续探测其他的存储地址,知道找到空闲的地址为止。改过程可描述为:Hi(key)=(H(key)+di)mod m(i=1,2,…k(k<=m-1)
其中,H(key)为关键字key的直接散列地址,m为散列表的长度,di为每次再探测时的地址增量。
采用这种方法时,首先计算出关键字的直接散列地址,即H(key),若该直接散列地址上已经有其他的关键字,则继续查看地址为[H(key)+di]的存储地址,判判断其是否为空。如此反复,直至找到空闲的存储地址为止,然后将关键字key存放到该地址。
增量d可以有不同的取法,常用的有以下3种
1)di=1,2,3,…,m-1,称为线性探测再散列。
2)di=1^2,- 1^2 , 2^2 ,- 2^2 , k^2, -k^2 (k<=m/2),称为二次探测再散列。
3)di=伪随机序列,称为伪随机再散列。
注意:对于利用开放地址法处理冲突所产生的散列表中,删除一个元素时不能直接删除。因为这样将会截断其他具有相同散列地址的元素的查找地址,所以,通常采用设定一个特殊标志的方法表示该元素已经被删除。
2) 链地址法,见上面。
3) 再哈希法:这种方法是同时构造多个不同的哈希函数,当地址发生冲突时,使用第二个、第三个哈希函数计算地址,知道无冲突为止。但是这种方法的缺点是计算时间会大幅度增加。
4) 建立公共溢出区:将哈希表分为基本表和溢出表两部分,凡是和基本表发生冲突的元素,一律填入溢出表。
哈希表主要用来快速存取,查找时间复杂度为O(1)。哈希表里面的数据是无序的。在海量数据处理过程中,哈希法可以快速存取,统计某些数据,将大量数据进行分类,例如提取某日访问网站次数最多的IP
6. hashmap链表过长怎么办
HashMap在JDK1.8及以后的版本中引入了红黑树结构,若桶中链表元素个数大于等于8时,链表转换成树结构;若桶中链表元素个数小于等于6时,树结构还原成链表。因为红黑树的平均查找长度是log(n),长度为8的时候,平均查找长度为3,如果继续使用链表,平均查找长度为8/2=4,这才有转换为树的必要。链表长度如果是小于等于6,6/2=3,虽然速度也很快的,但是转化为树结构和生成树的时间并不会太短。
还有选择6和8,中间有个差值7可以有效防止链表和树频繁转换。假设一下,如果设计成链表个数超过8则链表转换成树结构,链表个数小于8则树结构转换成链表,如果一个HashMap不停的插入、删除元素,链表个数在8左右徘徊,就会频繁的发生树转链表、链表转树,效率会很低。
红黑树是在二叉搜索树的基础上加以改变的。
恢复红黑树的属性需要少量(O(log n))的颜色变更(实际是非常快速的)和
不超过三次树旋转(对于插入操作是两次)。
7. 高并发情况下,为什么HashMap出现死锁?(hashmap线程不安全的原因)
我们看到默认HashMap的初始长度是16,比较小,每一次put的时候,都会检查当前hashmap中的元素个数是否超过数组大小*loadFactor时,是就会进行数组扩容,loadFactor的默认值为0.75。如果超过,扩大HashMap容量一倍,整个表里的所有元素都需要按照新的hash算法被算一遍,这个代价较大。提到死锁,对于HashMap来说,貌似只能和链表操作有关。
正常ReHash过程,可以看到,每个元素重新算hash值,将链表翻转,放到对应的bucket上的链表中。这个过程在并发环境下会发生错误,导致数组链表中的链表形成循环链表,在后面的get操作时e = e.next操作无限循环,无限循环出现。HashMap在并发环境下多线程put后可能导致get死循环,具体表现为CPU使用率100%。
多线程put的时候可能导致元素丢失。两个线程发生碰撞,就可能出现覆盖丢失的情况。那么就要使用线程安全的哈希表容器。如下:使用Hashtable 类,Hashtable是线程安全的;使用java并发包(java.util.concurrent)下的ConcurrentHashMap,ConcurrentHashMap实现了更高级的线程安全。或者使用synchronizedMap() 同步方法包装 HashMap object,得到线程安全的Map,并在此Map上进行操作。