面试官听我讲完HashMap直呼要为我点赞

面试官听我讲完HashMap直呼要为我点赞
前言

这是Homer与面试官系列第一篇,在一个周末阳光明媚的午后,Homer拿起他的电脑,便开始了一顿知识的输出。HashMap作为面试必问重点,也是必须非常清晰掌握的。

开场

面试开始,一个还剩少量稀疏头发的大叔,踏着轻快的步伐走过来,喃喃道:又来一个不怕死。开口说道:“用过Hashmap吧?”。

心里想:这不是废话嘛!当然用过,这也是问题?然后回到道:嗯,用过的。

那你跟我讲讲他的结构吧

在JDK1.7中HashMap是由数组加链表构成的数据结构。
在这里插入图片描述数组的每一个地方存储有key-value的数据节点,在1.7中称为Entry,1.8中为Node。
当执行put(“A”,“Homer”)方法时,会先将key值的hash值进行计算得到一个存储地址,如hash(“A”)得到2,就将其存入数组2的位置。继续执行put(“B”,“Jack”),在某些情况之下,如果hash(“B”)得到又是2,会通过equal()比较key值是否相等,如果相等覆盖,如果不相等就会形成链表。(Hash相等,值不一定相等)

那JDK1.8呢?

在JDK1.8中添加了红黑树,变成了数组+链表/红黑树的结构。
在这里插入图片描述当链表的长度大于8时,链表会转换成红黑树。

小伙子,那你跟我讲讲Node是啥样呢?插入流程呢?

每一个Node节点都会保存自身的hash、key、value、以及下个节点。
在这里插入图片描述
1.当第一Node节点插入时,判断数组是否为空,为空则进行初始化。
2.若不为空,通过key的hash值进行计算得到数组存储下标index。
3.查看数组index位置是否有数据,如果为空则初始化一个node节点放入。
4.若数组index位置不为空,则判断key值是否相等,相等则覆盖掉原有value值。
5.key值不等(发生hash冲突),判断是否是树型节点,如果是则插入红黑树中。
6.不是树型节点,创建Node加入链表,判断链表长度是否大于 8并且数组长度大于64, 大于的话链表转换为红黑树
7.插入完毕后判断节点个数是否大于阈值(容量*装载因子),大于(没有等于)则进行扩容。

你说的还是比较清楚,你刚才提到容量,那是怎么设计的呢?

我这臭嘴,咦!多说啥呢。跪着也得继续讲清楚啊。当进行new HashMap()时不传入值,则初始化容量为16。如果传入值,初始化大小为 大于值的 2的整数次方。如传入17,初始化为32。

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操作后,进行初始二进制右移1,2,4,8,16位,分别与自己位或,把高位第一个为1的数通过不断右移,把高位为1的后面全变为1,最后再进行+1操作,就变成 大于值的 2的整数次方。
如传入14,为1110。进行-1,就为1101。
>>>1 = 0110,进行或操作:1101 | 0110 = 1111.
后面操作同理,最后如果不大于Integer最大值,则加1返回。1111+1 = 10000,也就是16.

那为啥要减一呢?

咦!这也问?当然要减一,如果传入16,不减一。则10000变成11111,最后加一则返回了32.

嗯,小伙子不错,那装载因子是怎么回事呢?

装载因子用于表示HashMap满的程度,默认值为0.75f,设置成0.75,因为0.75正好是3/4,而capacity又是2的幂。所以,两个数的乘积都是整数。
之所以设定为0.75,因为可以尽可能提高查询效率和降低空间利用的成本,满足泊松分布,碰撞最小。如果加载因子过高如1,如第16个元素插入时,碰撞概率非常高了,导致查询效率降低。加载因子过低如0.5则扩容的频率加大,对空间使用效率很低。

你刚才提到容量初始化为16,那为啥是16呢?

因为容量过大可能空间浪费,过小频繁扩容影响效率,这应该一种经验值。
在这里插入图片描述
为了位运算的方便,位与运算比算数计算的效率高了很多,是为了在生成数组下标index时更高的效率。

前面都讲的很nice,你在说hash碰撞,那你说是怎么样hash的?

先拿到key的hashcode,是一位32位的int数值。然后让hashcode的高16位和低16位进行异或操作。此为扰动函数:尽量降低hash碰撞概率,同时采用位运算提高效率。

 static final int hash(Object key) {
        int h;
        //可以看出key可以为null,但也只能插入一个null。
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

为什么需要高16位和低16位异或可以降低碰撞呢?

 n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)

数组的位置index = hash&(n - 1)。在这里也解释了为什么需要容量为2的n次方,Length-1的值是所有二进制位全为1。“与”操作的结果就是散列值的高位全部归零,只保留低位值,用来做数组下标访问。以初始长度16为例,16-1=15。2进制表示是00000000 00000000 00001111。和某散列值做“与”操作如下,结果就是截取了最低的四位值。
在这里插入图片描述
这时候问题就来了,这样就算我的散列值分布再松散,要是只取最后几位的话,碰撞也会很严重。更要命的是如果散列本身做得不好,分布上成等差数列的漏洞,恰好使最后几个低位呈现规律性重复。
这时候“扰动函数”的价值就体现出来了:
在这里插入图片描述
右移16位,正好是32位的一半,自己的高半区和低半区做异或,就是为了混合原始哈希码的高位和低位,以此来加大低位的随机性。
1.7做了四次移位和四次异或,但明显Java 8觉得扰动做一次就够了,做4次的话,多了可能边际效用也不大,所谓为了效率考虑就改成一次了。
下面是1.7的hash代码:

static int hash(int h) {
    h ^= (h >>> 20) ^ (h >>> 12);
    return h ^ (h >>> 7) ^ (h >>> 4);
}

为啥需要将链表转为红黑树呢?

防止发生hash冲突,链表长度过长,将时间复杂度由O(n)降为O(logn);

嗯!!不错不错。看来我是低估你了呀?那我们继续吧!谈谈HashMap怎么扩容的?

在插入时,1.7先判断是否需要扩容,再插入。1.7采用头插法,也就是将新元素放到数组中,原始节点作为新节点的后继节点,新节点到了头部。因为写这个代码的作者认为后来的值被查找的可能性更大一点,提升查找的效率。

你提到头插法,头插法会面临什么问题?

头插法在多线程的条件下可能会导致产生环。数组的容量是有限的,当超过了数组所能装载的节点个数,就会扩容。
扩容分为两步:

  • 扩容:创建一个新的Entry空数组,长度是原数组的2倍。
  • ReHash:遍历原Entry数组,把所有的Entry重新Hash到新数组。

为啥需要重新hash呢?

是因为长度扩大以后,Hash的规则也随之改变。
Hash的公式—> index = HashCode(Key) & (Length - 1)

继续你的产生环问题吧!

1.7的扩容代码

void transfer(Entry[] newTable, boolean rehash) {
  int newCapacity = newTable.length;
  for (Entry<K,V> e : table) {
    while(null != e) {
      Entry<K,V> next = e.next;
      if (rehash) {
        e.hash = null == e.key ? 0 : hash(e.key);
      }
      int i = indexFor(e.hash, newCapacity);
      e.next = newTable[i]; 
      newTable[i] = e;
      e = next;
    }
  }
}

现在往一个容量大小为2的put两个值,负载因子是0.75是不是我们在put第二个的时候就会进行transfer。现在我们要在容量为2的容器里面用不同线程插入3,7,5,假如我们在transfer之前打个断点,那意味着数据都插入了但是还没扩容前可能是这样的。
可以看到链表的指向3->7->5:
![在这里插入图片描述](https://img-blog.csdnimg.cn/20210221210358505.png
单链表的头插入方式,同一位置上新元素总会被放在链表的头部位置,在旧数组中同一条Entry链上的元素,通过重新计算索引位置后,有可能被放到了新数组的不同位置上。
在单线程条件下:
在这里插入图片描述
然后在多线程环境下,假设有两个线程A和B都在进行put操作。线程A在执行到transfer函数中newTable[i] = e;处挂起

 Entry<K,V> next = e.next;
 e.next = newTable[i]; 
 newTable[i] = e;
 e = next;

此时线程A中运行结果如下:
在这里插入图片描述此为扩容关键代码,e为原数组的头一个元素,也就是3。此时还未在newTable完成赋值,此时缓存中e=3,e.next = null,next = 7,7.next = 5;
此时线程B前来扩容,线程A、B是共享一个table,也就是共享数据。线程B完成Transfer:
在这里插入图片描述
此时,线程A继续执行,但是由于共享数据,此时的7.next = 3,3.next =null。
再次贴出扩容代码,因为核心就在这里:

void transfer(Entry[] newTable, boolean rehash) {
  int newCapacity = newTable.length;
  for (Entry<K,V> e : table) {
    while(null != e) {
      Entry<K,V> next = e.next;
      if (rehash) {
        e.hash = null == e.key ? 0 : hash(e.key);
      }
      int i = indexFor(e.hash, newCapacity);
      e.next = newTable[i]; 
      newTable[i] = e;// newTable[3] = 3
      e = next;// e = 7
    }
  }
}

线程A再执行时,如下:

  newTable[i] = e;// newTable[3] = 3
  e = next;// e = 7

在这里插入图片描述继续循环:此时的e为A线程阻塞时next = 7(此为线程私有数据,不会改变)

e=7
next=e.next ----> next=3【从主存中取值】
e.next=newTable[3] ----> e.next=3【从主存中取值】
newTable[3]=e ----> newTable[3]=7
e=next ----> e=3

再次进行循环:在正常情况这里就应该为null终止循环了

e=3
next=e.next ----> next=null
e.next=newTable[3] ----> e.next=7 即:3.next=7
newTable[3]=e ----> newTable[3]=3
e=next ----> e=null

此次循环:e.next=7,而在上次循环中7.next=3,出现环形链表,并且此时e=null循环结束。
在这里插入图片描述只要涉及轮询hashmap的数据结构,就会在这里发生死循环。

嗯!你真是天生神人啊!知道这么多。不过你以为就完了?天真,1.8尾插就没有问题了?

依然线程不安全。

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
 2                    boolean evict) {
 3         Node<K,V>[] tab; Node<K,V> p; int n, i;
 4         if ((tab = table) == null || (n = tab.length) == 0)
 5             n = (tab = resize()).length;
 6         if ((p = tab[i = (n - 1) & hash]) == null) // 如果没有hash碰撞则直接插入元素
 7             tab[i] = newNode(hash, key, value, null);

第6行代码,如果没有hash碰撞则会直接插入元素。如果线程A和线程B同时进行put操作,刚好这两条不同的数据hash值一样,并且该位置数据为null,所以这线程A、B都会进入第6行代码中。假设一种情况,线程A进入后还未进行数据插入时挂起,而线程B正常执行,从而正常插入数据,然后线程A获取CPU时间片,此时线程A不用再进行hash判断了,问题出现:线程A会把线程B插入的数据给覆盖,发生线程不安全。

那怎么解决线程安全问题呢?

Java中有HashTable、Collections.synchronizedMap、以及ConcurrentHashMap可以实现线程安全的Map。
HashTable是直接在操作方法上加synchronized关键字,锁住整个数组,粒度比较大,Collections.synchronizedMap是使用Collections集合工具的内部类,通过传入Map封装出一个SynchronizedMap对象,内部定义了一个对象锁,方法内通过对象锁实现;ConcurrentHashMap使用分段锁,降低了锁粒度,让并发度大大提高。

嗯,我还想继续问一下关于线程安全问题,以及锁粒度等,不过今天已经不早了,咋们下次再约吧!

嗯嗯嗯,好的。我们下一次再见

  • 5
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 8
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值