美文网首页
一文搞懂所有HashMap面试题

一文搞懂所有HashMap面试题

作者: dybaby | 来源:发表于2020-11-26 16:36 被阅读0次

    刚刚经历过秋招,看了大量的面经,HashMap是面试的高频问题,这里将HashMap常见面试题总结了一下,并根据被问到的频率大致做了一个标注。问题后面的星数量越多表示,在面试中出现的频率越高,搞懂下面所有的问题,就再也不用担心面试官问你HashMap了。

    微信搜索公众号路人zhang,回复面试手册,领取本文档PDF版及更多面试资料。

    推荐阅读:一文搞懂所有Java基础知识面试题

    Map集合

    HashMap在JDK1.7和JDK1.8中有哪些不同?HashMap的底层实现 ***

    • JDK1.7的底层数据结构(数组+链表)
    在这里插入图片描述
    • JDK1.8的底层数据结构(数组+链表)
    在这里插入图片描述
    • JDK1.7的Hash函数

      static final int hash(int h){
        h ^= (h >>> 20) ^ (h >>>12);
          return h^(h >>> 7) ^ (h >>> 4);
      }
      
    • JDK1.8的Hash函数

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

      JDK1.8的函数经过了一次异或一次位运算一共两次扰动,而JDK1.7经过了四次位运算五次异或一共九次扰动。这里简单解释下JDK1.8的hash函数,面试经常问这个,两次扰动分别是key.hashCode()key.hashCode()右移16位进行异或。这样做的目的是,高16位不变,低16位与高16位进行异或操作,进而减少碰撞的发生,高低Bit都参与到Hash的计算。如何不进行扰动处理,因为hash值有32位,直接对数组的长度求余,起作用只是hash值的几个低位。

    HashMap在JDK1.7和JDK1.8中有哪些不同点:

    JDK1.7 JDK1.8 JDK1.8的优势
    底层结构 数组+链表 数组+链表/红黑树(链表大于8) 避免单条链表过长而影响查询效率,提高查询效率
    hash值计算方式 9次扰动 = 4次位运算 + 5次异或运算 2次扰动 = 1次位运算 + 1次异或运算 可以均匀地把之前的冲突的节点分散到新的桶(具体细节见下面扩容部分)
    插入数据方式 头插法(先讲原位置的数据移到后1位,再插入数据到该位置) 尾插法(直接插入到链表尾部/红黑树) 解决多线程造成死循环地问题
    扩容后存储位置的计算方式 重新进行hash计算 原位置或原位置+旧容量 省去了重新计算hash值的时间

    HashMap 的长度为什么是2的幂次方 ***

    因为HashMap是通过key的hash值来确定存储的位置,但Hash值的范围是-2147483648到2147483647,不可能建立一个这么大的数组来覆盖所有hash值。所以在计算完hash值后会对数组的长度进行取余操作,如果数组的长度是2的幂次方,(length - 1)&hash等同于hash%length,可以用(length - 1)&hash这种位运算来代替%取余的操作进而提高性能。

    HashMap的put方法的具体流程? **

    HashMap的主要流程可以看下面这个流程图,逻辑非常清晰。

    在这里插入图片描述

    HashMap的扩容操作是怎么实现的? ***

    • 初始值为16,负载因子为0.75,阈值为负载因子*容量

    • resize()方法是在hashmap中的键值对大于阀值时或者初始化时,就调用resize()方法进行扩容。

    • 每次扩容,容量都是之前的两倍

    • 扩容时有个判断e.hash & oldCap是否为零,也就是相当于hash值对数组长度的取余操作,若等于0,则位置不变,若等于1,位置变为原位置加旧容量。

      源码如下:

      final Node<K,V>[] resize() {
          Node<K,V>[] oldTab = table;
          int oldCap = (oldTab == null) ? 0 : oldTab.length;
          int oldThr = threshold;
          int newCap, newThr = 0;
          if (oldCap > 0) {
              if (oldCap >= MAXIMUM_CAPACITY) { //如果旧容量已经超过最大值,阈值为整数最大值
                  threshold = Integer.MAX_VALUE;
                  return oldTab;
              }else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                       oldCap >= DEFAULT_INITIAL_CAPACITY)
                  newThr = oldThr << 1;  //没有超过最大值就变为原来的2倍
          }
          else if (oldThr > 0) 
              newCap = oldThr;
      
          else {               
              newCap = DEFAULT_INITIAL_CAPACITY;
              newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
          }
      
          if (newThr == 0) {
              float ft = (float)newCap * loadFactor;
              newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                        (int)ft : Integer.MAX_VALUE);
          }
          threshold = newThr;
          @SuppressWarnings({"rawtypes","unchecked"})
              Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
          table = newTab;
          if (oldTab != null) {
              for (int j = 0; j < oldCap; ++j) {
                  Node<K,V> e;
                  if ((e = oldTab[j]) != null) {
                      oldTab[j] = null;
                      if (e.next == null)
                          newTab[e.hash & (newCap - 1)] = e;
                      else if (e instanceof TreeNode)
                          ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                      else { 
                          Node<K,V> loHead = null, loTail = null;//loHead,loTail 代表扩容后在原位置
                          Node<K,V> hiHead = null, hiTail = null;//hiHead,hiTail 代表扩容后在原位置+旧容量
                          Node<K,V> next;
                          do {             
                              next = e.next;
                              if ((e.hash & oldCap) == 0) { //判断是否为零,为零赋值到loHead,不为零赋值到hiHead
                                  if (loTail == null)
                                      loHead = e;
                                  else                                
                                      loTail.next = e;
                                  loTail = e;                           
                              }
                              else {
                                  if (hiTail == null)
                                      hiHead = e;
                                  else
                                      hiTail.next = e;
                                  hiTail = e;
                              }
                          } while ((e = next) != null);
                          if (loTail != null) {
                              loTail.next = null;
                              newTab[j] = loHead;   //loHead放在原位置
                          }
                          if (hiTail != null) {
                              hiTail.next = null;
                              newTab[j + oldCap] = hiHead;  //hiHead放在原位置+旧容量
                          }
                      }
                  }
              }
          }
          return newTab;
      }
      
      

    HashMap默认加载因子为什么选择0.75?

    这个主要是考虑空间利用率和查询成本的一个折中。如果加载因子过高,空间利用率提高,但是会使得哈希冲突的概率增加;如果加载因子过低,会频繁扩容,哈希冲突概率降低,但是会使得空间利用率变低。具体为什么是0.75,不是0.74或0.76,这是一个基于数学分析(泊松分布)和行业规定一起得到的一个结论。

    为什么要将链表中转红黑树的阈值设为8?为什么不一开始直接使用红黑树?

    可能有很多人会问,既然红黑树性能这么好,为什么不一开始直接使用红黑树,而是先用链表,链表长度大于8时,才转换为红红黑树。

    • 因为红黑树的节点所占的空间是普通链表节点的两倍,但查找的时间复杂度低,所以只有当节点特别多时,红黑树的优点才能体现出来。至于为什么是8,是通过数据分析统计出来的一个结果,链表长度到达8的概率是很低的,综合链表和红黑树的性能优缺点考虑将大于8的链表转化为红黑树。
    • 链表转化为红黑树除了链表长度大于8,还要HashMap中的数组长度大于64。也就是如果HashMap长度小于64,链表长度大于8是不会转化为红黑树的,而是直接扩容。

    HashMap是怎么解决哈希冲突的? ***

    哈希冲突:hashMap在存储元素时会先计算key的hash值来确定存储位置,因为key的hash值计算最后有个对数组长度取余的操作,所以即使不同的key也可能计算出相同的hash值,这样就引起了hash冲突。hashMap的底层结构中的链表/红黑树就是用来解决这个问题的。

    HashMap中的哈希冲突解决方式可以主要从三方面考虑(以JDK1.8为背景)

    • 拉链法

      HasMap中的数据结构为数组+链表/红黑树,当不同的key计算出的hash值相同时,就用链表的形式将Node结点(冲突的keykey对应的value)挂在数组后面。

    • hash函数

      key的hash值经过两次扰动,keyhashCode值与keyhashCode值的右移16位进行异或,然后对数组的长度取余(实际为了提高性能用的是位运算,但目的和取余一样),这样做可以让hashCode取值出的高位也参与运算,进一步降低hash冲突的概率,使得数据分布更平均。

    • 红黑树

      在拉链法中,如果hash冲突特别严重,则会导致数组上挂的链表长度过长,性能变差,因此在链表长度大于8时,将链表转化为红黑树,可以提高遍历链表的速度。

    HashMap为什么不直接使用hashCode()处理后的哈希值直接作为table的下标? ***

    hashCode()处理后的哈希值范围太大,不可能在内存建立这么大的数组。

    能否使用任何类作为 Map 的 key? ***

    可以,但要注意以下两点:

    • 如果类重写了 equals()方法,也应该重写hashCode()方法。
    • 最好定义key类是不可变的,这样key对应的hashCode()值可以被缓存起来,性能更好,这也是为什么String特别适合作为HashMapkey

    为什么HashMap中String、Integer这样的包装类适合作为Key? ***

    • 这些包装类都是final修饰,是不可变性的, 保证了key的不可更改性,不会出现放入和获取时哈希值不同的情况。
    • 它们内部已经重写过hashcode(),equal()等方法。

    如果使用Object作为HashMap的Key,应该怎么办呢? **

    • 重写hashCode()方法,因为需要计算hash值确定存储位置
    • 重写equals()方法,因为需要保证key的唯一性。

    HashMap 多线程导致死循环问题 ***

    由于JDK1.7的hashMap遇到hash冲突采用的是头插法,在多线程情况下会存在死循环问题,但JDK1.8已经改成了尾插法,不存在这个问题了。但需要注意的是JDK1.8中的HashMap仍然是不安全的,在多线程情况下使用仍然会出现线程安全问题。基本上面试时说到这里既可以了,具体流程用口述是很难说清的,感兴趣的可以看这篇文章。HASHMAP的死循环

    ConcurrentHashMap 底层具体实现知道吗? **

    • JDK1.7

      在JDK1.7中,ConcurrentHashMap采用Segment数组 + HashEntry数组的方式进行实现。Segment实现了ReentrantLock,所以Segment有锁的性质,HashEntry用于存储键值对。一个ConcurrentHashMap包含着一个Segment数组,一个Segment包含着一个HashEntry数组,HashEntry是一个链表结构,如果要获取HashEntry中的元素,要先获得Segment的锁。

    在这里插入图片描述
    • JDK1.8

      在JDK1.8中,不在是Segment+HashEntry的结构了,而是和HashMap类似的结构,Node数组+链表/红黑树,采用CAS+synchronized来保证线程安全。当链表长度大于8,链表转化为红黑树。在JDK1.8中synchronized只锁链表或红黑树的头节点,是一种相比于segment更为细粒度的锁,锁的竞争变小,所以效率更高。

    在这里插入图片描述

    总结一下:

    • JDK1.7底层是ReentrantLock+Segment+HashEntry,JDK1.8底层是synchronized+CAS+链表/红黑树
    • JDK1.7采用的是分段锁,同时锁住几个HashEntry,JDK1.8锁的是Node节点,只要没有发生哈希冲突,就不会产生锁的竞争。所以JDK1.8相比于JDK1.7提供了一种粒度更小的锁,减少了锁的竞争,提高了ConcurrentHashMap的并发能力。

    HashTable的底层实现知道吗? **

    HashTable的底层数据结构是数组+链表,链表主要是为了解决哈希冲突,并且整个数组都是synchronized修饰的,所以HashTable是线程安全的,但锁的粒度太大,锁的竞争非常激烈,效率很低。

    在这里插入图片描述

    HashMap、ConcurrentHashMap及Hashtable 的区别 ***

    HashMap(JDK1.8) ConcurrentHashMap(JDK1.8) Hashtable
    底层实现 数组+链表/红黑树 数组+链表/红黑树 数组+链表
    线程安全 不安全 安全(Synchronized修饰Node节点) 安全(Synchronized修饰整个表)
    效率 较高
    扩容 初始16,每次扩容成2n 初始16,每次扩容成2n 初始11,每次扩容成2n+1
    是否支持Null key和Null Value 可以有一个Null key,Null Value多个 不支持 不支持

    Map的常用方法 **

    这些常用方法是需要背下来的,虽然面试用不上,但是笔试或者面试写算法题时会经常用到。

    方法
    void clear() 清除集合内的元素
    boolean containsKey(Object key) 查询Map中是否包含指定key,如果包含则返回true
    Set entrySet() 返回Map中所包含的键值对所组成的Set集合,每个集合元素都是Map.Entry的对象
    Object get(Object key) 返回key指定的value,若Map中不包含key返回null
    boolean isEmpty() 查询Map是否为空,若为空返回true
    Set keySet() 返回Map中所有key所组成的集合
    Object put(Object key,Object value) 添加一个键值对,如果已有一个相同的key,则新的键值对会覆盖旧的键值对,返回值为覆盖前的value值,否则为null
    void putAll(Map m) 将制定Map中的键值对复制到Map中
    Object remove(Object key) 删除指定key所对应的键值对,返回所关联的value,如果key不存在返回null
    int size() 返回Map里面的键值对的个数
    Collection values() 返回Map里所有values所组成的Collection
    boolean containsValue ( Object value) 判断映像中是否存在值 value

    相关文章

      网友评论

          本文标题:一文搞懂所有HashMap面试题

          本文链接:https://www.haomeiwen.com/subject/gucziktx.html