HashMap相关

我们知道hashmap的扩容因子是0.75,元素个数> 如果hashmap的数组长度*75%就会引起扩容,会新申请一个长度为原来两倍的桶数组,然后将原数组的元素重新映射到新的数组中,原有数据的引用会逐个被置为null。就是在resize()扩容的时候会造成线程不安全。另外当一个新节点想要插入hashmap的链表时,在jdk1.8之前的版本是插在头部,在1.8后是插在尾部

那么hashmap什么时候进行扩容呢?有三个时间点会扩容,当首次进行put操作判断table为null时会调用resize方法以初始长度进行扩容;当put时发现数组下标链表长度到达8转换成红黑树时,会判断当前数组长度是否大于64,当不足64时会进行扩容;当put完成,发现hashmap中的元素个数超过threshold(即数组大小*loadFactor)时,就会进行数组扩容,loadFactor的默认值为0.75,也就是说,默认情况下,数组大小为16,那么当hashmap中元素个数超过16*0.75=12的时候,就把数组的大小扩展为2*16=32,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常消耗性能的操作,所以如果我们已经预知hashmap中元素的个数,那么预设数组的大小能够有效的提高hashmap的性能。比如说,我们有1000个元素new HashMap(1000), 但是理论上来讲new HashMap(1024)更合适,不过数组的容量总是2的整数次幂,即使是1000,hashmap也自动会将其设置为1024。 但是new HashMap(1024)还不是更合适的,因为0.75*1024 < 1000, 也就是说为了让0.75 * size > 1000, 我们必须这样new HashMap(2048)才最合适,避免了resize的问题。

不安全原因:

(1)在put的时候,因为该方法不是同步的,假如有两个线程A,B它们的put的key的hash值相同,不论是从头插入还是从尾插入,假如A获取了插入位置为x,但是还未插入,此时B也计算出待插入位置为x,则不论AB插入的先后顺序肯定有一个会丢失

(2)在扩容的时候,jdk1.8之前是采用头插法,当两个线程同时检测到hashmap需要扩容,在进行同时扩容的时候有可能会造成链表的循环,主要原因就是,采用头插法,新链表与旧链表的顺序是反的,在1.8后采用尾插法就不会出现这种问题,同时1.8的链表长度如果大于8就会转变成红黑树。

并且在1.8之后,put插入时,1.7是先判断是否需要扩容在插入,1.8是先插入再判断是否需要扩容

1.hashmap的容量是2的整次幂,当设置的容量不满足这一条件时,会找出比其大的最小2的整次幂,这一过程是如何实现的?

当我们不设置容量cap时,默认是16,负载因子是0.75,n最大为2的30次幂,如果设置了cap,则会进行如下操作

// Returns a power of two size for the given target capacity
static final int tableSizeFor(int cap) {
    int n = cap - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

首先会将cap先减1,这是为了如果当前传入的cap恰好是2的n次幂,得出的将会是原数,假设原数字的二进制表示为是000000 1xxxxxxx xxxxxxxx xxxxxxxx xxxxxxxx,x代表为0和1都可以,咱们假设x之中不全为0,那么cap-1之后对我们现在整个二进制表示则没有影响

n |= n>>>1; >>>表示将二进制从左向右移,左边用0补全,| 表示或,任何树与1或都是1,与0或都是原数

原 n     000000 1xxxxxxx xxxxxxxx xxxxxxxx xxxxxxxx

n>>>1 000000 01xxxxxxx xxxxxxxx xxxxxxxx xxxxxxx

或后得 000000 11xxxxxx xxxxxxxx xxxxxxxx xxxxxxxx

这样它的前两位都是1

之后的右移2位 4位 8位16位取或操作不再演示,最终我们可以得到一个数,原数字1之后都变成了1,即000000 1xxxxxxx xxxxxxxx xxxxxxxx xxxxxxxx,x都变成了1,因为容量最大位2的30次幂,当n大于次值时直接返回2的30次幂,小于时,进行+1返回大于该数字的最小2的n次幂,讲到这里大家应该就明白了,至于我们设定的容量本来就是2的n次幂的情况,大家可以自己手动去推一下,返回的是原数。

下面再说一下为什么hashmap的容量是2的整次幂?

当我们使用hashmap的get或put方法时,都会对key进行hash计算,hashmap中的hash()方法,是将key的原hash值与hash值进行>>>计算向右移动后16位后得出的值进行^计算,取异或,异或表示相同为0,不同为1,如110 ^ 101 为 011

static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }


从put方法截取的定位key在数组中的下标代码
tab[i = (n - 1) & hash])

之后得到的这个hash值在与当前hashmap容量n-1进行&与运算,原本容量n为2的整数次幂,减一之后则高位1变成0,原来1之后的0全变为1,这样执行与hash()返回值的&操作时就完全取决于hash的返回值,如果两个key的hash(key)返回值其低位在容量n-1上的2进制不同的话那么它们分别与n-1执行完&后获得的数组下标肯定不同,如果n不是2的整数次幂,那么它减一后的2进制位上可能不全为1,这样就有可能出现低位hash不同的key,它们反而被分配到了数组的同一下标,产生了冲突,所以容量为2的整数次幂使其取模的结果更加的散列,也减少了hash冲突;并且一个数与2的n次幂取模等同于和2的n次幂减一做与&运算,且&操作是位运算速度更快

2.hash()函数将key的hash值右移16位后,再与原值取异或也是为了使hash值更加的散列,减少hash冲突

当put的时候如果我们发现当前的table为null或table.size为0就会触发resize()函数,当原table为null或table.size为0时,如果指定了容量的大小,则会使其该值作为新数组的默认大小,如果没指定则使用默认大小16;如果table不为null且table.size大于0,则新数组的大小为原来的2倍。如果原数组中有元素,则将元素重新映射到新的数组链表(红黑树)中。

下面继续进行插入操作,如果key对应的数组下标中存储node为null则直接插入,判断是否于头节点key相同若相同则替换value;判断头节点是否为树节点(即该数组下标对应链表是否已经转换为红黑树),若是则判断该key是否在红黑树中,存在则替换,不存在则插入,进行旋转调整;否则判断key是否再该对应下标链表之中,若在则替换value,若不再则在尾节点插入,当链表长度大于8时转换为红黑树

最后将判断当前数组容量是否需要扩容resize()操作

3.扩容因子为什么是0.75

我们假设扩容因子不是0.75,如果是1,虽然数组的空间利用率大大提升,但是相应的hash冲突也会增多,链表的长度也会增加,这样也增加了查询的时间成本;如果负载因子是0.5,虽然降低了hash冲突的概率,但是空间利用率也降低了,并且扩容和rehash的操作也会增加。所以采用0.75这个值是对时间和空间成本的一种折中。

4.链表到达8会转化成红黑树,红黑树节点个数到6会变成链表,为什么不是到7就变成链表呢?为什么到8才转化成红黑树

hashmap注释中有写其实在hash比较好的情况下数组下标中对应的链表节点个数到达8个的概率只有0.00000006千万分之6,因此当到达8时说明hashmap中节点个数已经足够多了,这个时候才采用红黑树的结构代替链表提高查询效率。中间有个7是为了作缓冲,防止链表和红黑树之间频繁的互相转换,造成资源浪费。

hashmap put方法的执行过程

(1)调用hash()方法,计算key的hash值,将key的hash值与hash值右移16位的值做异或操作,得到返回值(hash^ (hash >>> 16))。

(2)之后会进入到putval方法中,此时会判断当前table是否为null或长度为0,如果未创建则会使用resize()方法根据用户传递的初始容量对应的最小2的n次幂或者默认值16创建初始数组。将第(1)步中获取的key的hash值和hashmap长度n做hash&(n-1)或该key对应的数组下标位置。

(3)如果该数组下标下对应节点为null的话,则创建新的节点直接放入数组中即可

(4)否则判断头节点的key是否与新插入键值对key是否相同,相同则将value替换;否则判断该数组下标下对应的是否是红黑树,是的话遍历该树查找是否存在key相同的节点,不存在则创建新的树节点,进行旋转调整,维护红黑树的平衡;如果不是红黑树,则遍历该链表,同样寻找相同key节点或者将该节点插入到链表尾部,如果此时链表长度变为8则将该链表转化为红黑树。在原红黑树或链表中如果存在key相同的接口则会将value覆盖,此时会调用afterNodeAccess()方法,在LinkedHashMap中有关于它的实现。

(5) 如果是新插入了节点则会判断当前的size >= n * 0.75,如果满足则需要调用resize()进行扩容。在risize方法中首先会创建一个长度为原数组两倍长度的新数组,之后会遍历原数组的每一个下标,如果存在node节点,当它的next为null时则会直接利用 hash&(newCap-1),将它映射到新数组;如果它是一个树节点,即原数组该下标对应的是红黑树,此时会将红黑树中的节点分为两个list,一个叫highList,一个叫lowList,划分的标准是节点key的hash值与老数组的长度做&运算结果为0的进入low,否则进入high,之后会将划分好的两个数组进行长度判断,如果小于等于6则再转换为链表。之后将low链表以红黑树或链表的形式添加到新数组中相同下标处,将high链表添加进新数组原下标+原数组长度的下标处;链表节点与树节点执行的是相同的操作,划分为low和high两个list,只不过他没有红黑树链表的转换过程。

上面说的hash都是key经过hash()函数计算所得的hash值,由于数组长度是2的n次幂,可以用 假设第n位为1,后面n-1位为0,hash&oldCap = 0,表示hash在第n位为0其余位随意;扩容后newCap = old << 1(左移一位,即×2),那么第n+1位为1,其余后面n位为0;当oldcap-1后 第n位为0,后面n-1位为1,newCap-1后,第n位为1,后面n-1位也为1,由于key的hash值在n位为0,且oldcap-1和newcap-1的后面n-1位都为1,所以当hash&oldcap=0时hash&(oldcap-1)=hash&(newcap-1),即此时key所在新老数组的下标相同。

而其余hash&oldCap != 0则表示hash值在第n位为1,与newCap-1做&运算时第n位的1可以保留,则相当于+了一个原数组的长度

在1.7中是将链表中每一个键值对的key的hash与新数组长度的-1做&运算,1.8中将它们分为了两个数组,其实结果是一样的。

最后会执行afterNodeInsertion方法

位运算符 >> ^ ~ & |-CSDN博客

https://potato.blog.csdn.net/article/details/106835525

原创|如果懂了HashMap这两点,面试就没问题了-CSDN博客

  • 2
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值