1、寻址算法优化
HashMap的底层是一个数组,要知道元素存储在数组的哪一个位置,就需要将元素的hash值跟数组长度进行取模,这样就能得到元素在数组中的下标了,但是在HashMap中并不是使用的这种简单的取模方式,而是使用了下面这种方式
int index = (n - 1) & hash;
HashMap中获取元素下标使用的是(n - 1) & hash,n为数组长度,这样的位运算比取模运算效率要高,主要原因是位运算直接对内存数据进行操作,不需要转成十进制,因此处理速度非常快。
但是有一个要求,就是HashMap的容量要是2的n次方。HashMap的默认容量就是16,这是一个经验值,太小会频繁扩容,而太大又会浪费空间。HashMap中指定容量初始化和扩容的方法中,都有算法保证HashMap的容量是2的n次方,所以这一步位运算可以提高元素的寻址速度。
2、hash算法优化
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
这段代码是意思是如果key不为空,则对key进行hashCode取值,再将hash值右移16位,两个值进行异或,最后返回的才是hash方法返回的hash值
举个例子:
1111 1111 1111 1111 1111 1010 0111 1100(hashCode值,int值是32位的)
0000 0000 0000 0000 1111 1111 1111 1111 (右移16位后,相当于将高16的值替代了低16位的值)
1111 1111 1111 1111 0000 0101 1000 0011 (最后异或结果,高16位不变,低16位改变)
寻址:
1111 1111 1111 1111 0000 0101 1000 0011 (算法优化之后的hash值)
0000 0000 0000 0000 0000 0000 0000 1111 (HashMap默认容量 - 1)
经过这一步位运算之后可以得到元素在HashMap的下标
那如果不做这一步算法优化会是怎么样的呢?
1111 1111 1111 1111 1111 1010 0111 1100 (原始hashCode值)
0000 0000 0000 0000 0000 0000 0000 1111 (HashMap默认容量 - 1)
HashMap的容量一般都不会特别大,所以(n - 1)这个值都不会太大,进行位运算的时候,高16位的位运算结果基本可以忽略不计(HashMap默认容量 - 1的高16位都是0000 0000 0000 0000,所以高16位位运算之后还是0000 0000 0000 0000),主要是低16位的运算影响结果,相当于:
0000 0101 1000 0011 (算法优化之后的hash值)
0000 0000 0000 1111 (HashMap默认容量 - 1)
1111 1010 0111 1100 (原始hashCode值)
0000 0000 0000 1111 (HashMap默认容量 - 1)
优化之后的hash值是高16位与低16位的异或结果,未优化的只保留了低16的值,若需要添加的元素的低16位一致,而高16位不同,就会出现hash冲突,为了降低hash冲突的概率,对key的hashCode进行扰动计算,也就是上面贴出的代码,将高16位和低16的hash值的特征组合起来,尽量使任何一位的变化都能对最终得到的结果产生影响。
3、如何解决hash冲突
HashMap解决hash冲突使用的是链地址法(拉链法),将哈希表的每个单元作为链表的头结点,所有哈希地址为i的元素构成一个同义词链表。即发生冲突时就把该关键字链在以该单元为头结点的链表的尾部。
如果一个元素跟另一个元素发生了hash冲突,那么就会以链表的形式存储在数组中,每次获取元素的时候,就需要遍历链表(时间复杂度O(n)),所以链表过长会降低性能,所以当链表的长度大于阈值8的时候,就会转换为红黑树(时间复杂度O(logn)),长度为6的时候又会从红黑树退化为链表。
(图是直接在网上找的,侵删)
红黑树特性:
(1)每个节点或者是黑色,或者是红色。
(2)根节点是黑色。
(3)每个叶子节点(NIL)是黑色。 [注意:这里叶子节点,是指为空(NIL或NULL)的叶子节点!]
(4)如果一个节点是红色的,则它的子节点必须是黑色的。
(5)从一个节点到该节点的子孙节点的所有路径上包含相同数目的黑节点。[这里指到叶子节点的路径]
4、HashMap扩容
HashMap的扩容条件就是当HashMap中的元素个数(size)超过临界值(threshold)时就会自动扩容。
在HashMap中,threshold = loadFactor * capacity。
loadFactor 负载因子默认为0.75,默认情况下HashMap元素个数达到12的时候,就会扩容了,扩容之后的大小为之前的2倍。既然容量变了,那么寻址时的元素的下标也会随之改变,所以扩容时,需要先创建一个新的数组,容量是之前的两倍,然后再Rehash把之前的元素都重新进行hash计算,放入新的数组中。
这里有一个问题,就是扩容时,JDK1.7时使用的是头插法,同一位置上新元素总会被放在链表的头部位置,所以在多线程的情况下有可能会产生环形链表。JDK1.8之后使用尾插法,在扩容时会保持链表元素原本的顺序,就不会出现环形链表的问题。