hello world

简析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次方:

\[h >>> 16 = h\div2^{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

两个进行异或:

\[\frac{00001000110011011010110000000000}{00000000000000000000100011001101}异或 => 00001000110011011010010011001101 \]

对比结果可以发现,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

\[c(u)=\begin{cases} \& \frac{1000010001110001000001111000000}{00000000000000000000000000001111} => 00000000000000000000000000000000 = 0\\ \& \frac{1001011001111001101001111000000}{00000000000000000000000000001111} => 00000000000000000000000000000000 = 0 \end{cases} \]

那么它们计算出来的索引下标都是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,就把红黑树转回链表,因为根本不需要引入红黑树,引入反而会慢。

posted @ 2022-07-09 23:27  未完待续、  阅读(62)  评论(0)    收藏  举报