简析HashMap原理
HashMap
HashMap的底层数据结构?
数组+链表/红黑树
key+value,组成键值对<key,value>
数据封装:
- jdk7 ---> Entry
- jdk8 ---> Node
HashMap的默认hash计算方法
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
关键:(h = key.hashCode()) ^ (h >>> 16)
1、先调用key对象的hashCode()方法得到h
2、h >>> 16 将h位移运算后再异或,有符号右移16位,相当于除以2^16次方:
public static void main(String[] args) {
String a = "hashCode";
int h = a.hashCode();
System.out.println("hashCode字符串的hashcode为:"+ h);
System.out.println("有符号右移后的值为:" + (h >>> 16));
System.out.println("两个值进行异或:" + (h ^ (h >>> 16)));
}
Console:
hashCode字符串的hashcode为:147696667
有符号右移后的值为:2253
两个值进行异或:147694806
为什么要对key的hashCode值这样的算法操作(为什么右移 16 位,为什么要使用 ^ 位异或)?
答:最终的目的是为了尽可能得到一个均匀分布的hash值。也就是尽可能让元素均匀分布在HashMap的数组上。右移加异或的操作是为了将高低位二进制特征混合。这样会尽可能的减少低位连续多个零的情况。异或的特性为是:和1异或就翻转,和0异或则保留原值。假设有个key的HashCode为147696640,
147696640 ====> 00001000110011011010110000000000
147696640 >>> 16 ====> 00000000000000000000100011001101
两个进行异或:
对比结果可以发现,hashcode高位16位因为是和右移后补充的0异或,特征被保留,低位的16位和自己的右移过来的高位16位进行异或,可以将低位中的连续0消除。防止hash的低位全是0,导致计算数组下标时计算为0。元素集中在首个hash桶中,发生hash碰撞。
先看看 HashMap 如何根据 hash 值找到数组中的对象的。即确定元素在数组中的索引下标。
p = tab[i = (n - 1) & hash]
计算下标的位置算法为:(数组长度 - 1) & key的hash
为何计算元素在数组中的位置需要使用数组长度来与运算key的hash呢?
因为我们的目标是尽可能的让元素的下标均匀的落在数组上,常见的还有取模的方式。但是取模运算是算数运算,效率太低了,对于现代的处理器来说,除法和求余数(模运算)是最慢的。根据数学公式:
当b是2的指数时,有 a % b == (b-1) & a 成立
扩展:
按位与 &,操作为:两个数都转为二进制,然后从高位开始比较,如果两个数都为1则为1,否则为0。
字符串 "hashCode" 的hash值:147696667,二进制:00001000110011011010110000011011
HashMap默认容量为16 length-1=15,15的二进制:00000000000000000000000000001111
a % b = 147696667 % 16 = 11
(b-1) & a = (16 -1 ) & 147696667 ======>
\[\& \frac{00001000110011011010110000011011}{00000000000000000000000000001111} => 00000000000000000000000000001011 = 11 \]
由此可见,为了均匀分布,需要取模,取模操作效率低,转换成位与操作,前提是b需是2的指数。
由于确定了数组的长度最好是 2的指数,b-1 的二进制数必定是 1111...11111,全部都是1,假如hash值的后几位全部为0,则它们(n - 1) & hash计算出的索引下标也会相同,如:数组长度为16(1111),1000010001110001000001111000000 和 1000010001110001000001111000000
那么它们计算出来的索引下标都是0,这样必定会影响均匀分布,因为很容易就出现了后面几位全是0的hashcode,且数组容量在较小的时候,更容易出现大量落在0下标的情况。
所以为了减少hashcode的二进制后面位数都为0的情况,将hash值和右移16位后的hash值进行异或(二进制数从高位比较,如果相同则为0,不相同则为1)
HashMap的存取原理?
存:
数组为空则先初始化,计算key的hash,确定数组中的位置(hash & (length-1)),该位置为空则直接插入,有数据则遍历链表进行尾插,遍历的时候利用equals方法判断链表中的元素是否相等,链表长度大于8转换成红黑树存储,链表长度小于6又转换成链表。插入后判断是否需要扩容。
取:
判断 数组不为空,key的hash对应的桶不为空,判断首节点,如果命中就返回,没命中就遍历链表或红黑树进行查找。
常见问题
Java7和Java8的区别?
答:数据结构从Entry改成了Node,数组元素从单纯的单链表,改成了大于8转换成红黑树。头插法改成了尾插法。
有什么线程安全的类代替么?
答:ConcurrentHashMap 或者 Collections工具类中的SynchronizedMap。
默认初始化大小是多少?为啥是这么多?为啥大小都是2的幂?
答:默认初始化容量大小16。因为hash函数的特性决定决定的。为减少hash碰撞,均匀分布。
HashMap的扩容方式?负载因子是多少?为什是这么多?
答:当容量到达了设定的阈值(当前容量大小 * 负载因子),就会进行扩容,扩容的时候先开辟出当前容量两倍大小的新数组,然后遍历当前所有元素并重新计算Hash之后放到新数组的对应位置,(重新计算Hash是因为hash是和数组的容量相关,容量变了,hash值自然不能使用旧的)。负载因子默认为0.75。
这个默认的0.75是根据数学概率计算出来的。当负载因子为1的时候,hash桶的空间利用率更高,hash碰撞更加频繁,hash桶中的红黑树复杂度变高,查询效率变低。当负载因子为0.5的时候,hash桶中的空间利用率较低,扩容频繁,红黑树高度降低,查询效率提升。
根据官方文档,0.75的值是根据 泊松分布得出。使得HashMap在空间利用率和查询效率之间相对较优的一个值。
HashMap的主要参数都有哪些?
答:1、桶容量,capacity,即数组长度:DEFAULT_INITIAL_CAPACITY=1<<4;默认值为16。
2、MAXIMUM_CAPACITY = 1 << 30。极限容量,表示hashmap能承受的最大桶容量为2的30次方,超过这个容量将不再扩容。
3、static final float DEFAULT_LOAD_FACTOR = 0.75f。负载因子,表示当前容量大于capacity * loadFactory 时,将进行扩容。
HashMap是怎么处理hash碰撞的?
答:常见的解决Hash碰撞的方法有:链表法(拉链法)、开放地址法、再Hash法。HashMap使用的链表法,即将具有相同的Hash值的元素,都存放于数组相同位置(同一个桶),它们之间用链表的方式进行连接,链表上使用equals方法对比是否相同元素。1.8有个链表过长转红黑树的过程。
开放地址法是通过一个探测算法,当某个槽位已经被占据的情况下继续查找下一个可以使用的槽位。
链表过长之后为什么使用红黑树,而不是二叉查找树?
答:二叉查找树在特殊情况下会变成一条线性结构,这就跟原来使用链表结构一样了,造成很深的问题,而红黑树在插入新数据后可能需要通过左旋,右旋、变色这些操作来保持平衡,引入红黑树就是为了查找数据快,解决链表查询深度的问题。红黑树属于平衡二叉树,但是为了保持“平衡”是需要付出代价的,但是该代价所损耗的资源要比遍历线性链表要少,所以当长度大于8的时候,会使用红黑树,链表长度低于6,就把红黑树转回链表,因为根本不需要引入红黑树,引入反而会慢。
浙公网安备 33010602011771号