HashMap学习
1基本数据结构和行为特征
hashmap的底层数据结构是由数组、链表以及红黑树组成。默认初始化时的大小为16,内部存在负载因子参数默认0.75。每当hashmap的容量达到当前容量*0.75时,就会引发hashmap的扩容,每次扩容就是向左移一位,也就是2倍,而容量设置需要设置为2的倍数。
2如何进行hash取值
说到对hasmmap如何取值,相信就算是刚入门的小白也可能清楚,那就是对hashmap的容量进行取模获取到对应的位置。但是到底如何取模呢?直接拿到值进行取模操作吗?可能真相会与你的猜想大相径庭。
让我们直接上源码,毕竟源码无秘密
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
使用hashmap首先得调用put方法,将key和value传入,再对key取其hashcode,并且与该hashcode右移16位后的值进行异或操作。(异或操作:相同为0,不同为1)
那么问题来了,为什么要做这样的操作呢?不急,我们先接着往下看
在putVal()方法中,截取一段代码取模操作的代码
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
(n - 1) & hash,将容量长度减1再对hash值进行与操作。
1、首先hash值为什么要与右移16位后的值进行异或操作?
这是因为大多数的hashmap的容量都会不超过16位,如果直接用32位的hash值进行与操作,那么高16位的值根本就没有机会进行与操作。那么这么一个操作,将高16位和低16位的特征都融入了低16位中,这样hash碰撞的概率就会变小。
2、为什么做与操作而不是直接取模?
在计算机中,位运算的速度是远远大于取模运算的,而使用与操作就能实现取模的效果,那当然是用位运算来实现。
3、为什么hashmap的容量需要设置为2的倍数
就是由于使用(n - 1)& hash的位运算来代替了取余运算,而2的倍数的二进制肯定是最高位为1余位全为0,那么在n - 1后就会变成最高位为0,余位全为1。这样的数字来进行与运算才能减少hash冲突,所以需要设置为2的倍数
举个例子:
假设当前hashmap的容量为9,现在要2和3确定位置
9的二进制:0000 1001 (n - 1):0000 1000
2的二进制:0000 0010 位运算:(0000 1000)& (0000 0010)= 0
3的二进制:0000 0011 位运算:(0000 1000)& (0000 0011)= 0
产生hash冲突
那么如果容量为8呢?
8的二进制:0000 1000 (n - 1):0000 0111
2的二进制:0000 0010 位运算:(0000 0111)& (0000 0010)= 2
3的二进制:0000 0011 位运算:(0000 0111)& (0000 0011)= 3
没有hash冲突
3怎么解决hash碰撞
不管怎么去设计,对于散列表来说一个回避不了的问题,那就是碰撞的问题。什么是hash碰撞,当两个不同的key进行hash操作之后定位到的位置是同一个位置,这个就是hash碰撞。
在1.7的版本里只是简单的使用拉链法来解决hash碰撞,当碰撞的数据变多的时候,查询该key的时间复杂度就变成了O(n),因为不得不去遍历这个链表来获取到对应的数据(这里获取数据的首先进行hashcode的取值进行位置定位,定位之后如果发现是个链表,那么就会使用equals来进行判断,这也是为什么hashcode与equals需要一起进行重写的原因)
而在1.8的版本中除了使用拉链法之外还引入了红黑树的算法,当链表数据达到8的时候,链表转换为红黑树,红黑树它就能给让数据保持有序且平衡的状态,那么树的查询那就好办了,时间复杂度仅为O(log n)。
4并发场景resize产生环形链表导致CPU100%?
在1.7hashmap中采用头插法导致在并发场景下会形成环形列表的bug。但是我觉得这个问题就算是不解决也没什么问题,因为hashmap本来就不是在并发场景下使用的容器,当然JDK为了代码的健壮性在1.8的版本里采用尾插法解决了这个问题。
resize方法的核心就是用两重循环来重建hashmap,第一次循环会拿到槽位上的元素,然后do while遍历会槽位上的所有元素,元素会按照顺序重新加到新的hashmap的槽位上。
那么假设某个槽位上有三个元素,分别为(k1,v1)->(k2,v2)->(k3,v3)那么,重建过程中可能新hashmap的某个槽位又会正好对应这三个元素,那么元素的该槽位对应的变化就是:
1、(k1,v1)
2、(k2,v2) ->(k1,v1)
3、(k3,v3)->(k2,v2)->(k1,v1)
最终会把链表的顺序对调。
然后另一个线程来了,此时它首先拿到(k1,v1),这步的时候还不会有问题。然后拿到(k2,v2),将槽位指向(k2,v2),这时继续进行链表遍历,发现(k2,v2)的下一个节点是(k1,v1),那么就会将槽位指向(k1,v1),并将(k1,v1)指向(k2,v2)
这样环形链表就形成了