前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >BAT大厂面试官必问的HashMap相关面试题及部分源码分析

BAT大厂面试官必问的HashMap相关面试题及部分源码分析

作者头像
用户3587585
发布2022-09-21 06:47:51
2430
发布2022-09-21 06:47:51
举报
文章被收录于专栏:阿福谈Web编程阿福谈Web编程
0 引言

HashMap的相关面试题一直是BAT大厂面试官的高频面试题,笔者最近接到的阿里和开源中国面试官的面试题中都有问到有关HashMap底层实现的一些面试题,当时回答的不是很好。于是抽个时间来捋一捋HashMap的相关面试题并分析其中的部分源码,希望对我的读者粉丝们也会有一定的帮助。

1 JDK8 中的 HashMap与 JDK7 的 HashMap 有什么不一样?
  • JDK8中新增了红黑树,JDK8是通过数组+链表+红黑树来实现的
  • JDK7中链表的插入是用的头插法,而JDK8中则改为了尾插法
  • JDK8中的因为使用了红黑树保证了插入和查询了效率,所以实际上JDK8中的Hash算法实现的复杂度降低了
  • JDK8中数组扩容的条件也发了变化,只会判断是否当前元素个数是否查过了阈值,而不再判断当前put进来的元素对应的数组下标位置是否有值
  • JDK7中是先扩容再添加新元素,JDK8中是先添加新元素然后再扩容

图 1 HashMap中的散列桶数组存储示意图

2 HashMap 中 put 方法流程

我们找到HashMap源码中的put方法源码:

代码语言:javascript
复制
public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }

put方法调用了putVal方法,继续找到putVal方法的源码:

代码语言:javascript
复制
/**
     * Implements Map.put and related methods
     * @param hash key的哈希值
     * @param key key值
     * @param value 需要Put进去的value
     * @param onlyIfAbsent 如果为true,不改变已存在的值
     * @param evict 如果为false,则table 为creation 模式.
     * @return 返回之前的值, 不存在则返回nullh
     */
    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        //先判断table是否为空或者长度为零
        if ((tab = table) == null || (n = tab.length) == 0)
            //调用resize方法初始化数组
            n = (tab = resize()).length;
        //计算数组下标i=(tab.length - 1) & hash
        if ((p = tab[i = (n - 1) & hash]) == null)
            //如果数组下标处元素为空,则将hash, key, value等值封装成链表后保存在此数组下标处
            tab[i] = newNode(hash, key, value, null);
        else {
            //数组下标处元素不为空
            Node<K,V> e; K k;
            //若p = tab[i = (n - 1) & hash]的hash值等于入参hash同时其key值等于入参key
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                //将tab[i = (n - 1) & hash]赋值给局部变量e
                e = p;
            //若数组下标处的元素是一棵红黑树
            else if (p instanceof TreeNode)
                //调用TreeNode#putTreeVal方法并将其返回值赋值给e,putTreeVal方法后面再讲
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else { //数组下标元素不同时满足其hash值等于入参hash和其key值等于入参key,
                //同时它也不是红黑树节点,此时它仍然是Node类型的节点
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) { //若p的下一个节点为空,并把p.next赋值给e
                        //封装hash, key, value为一个新的Node节点赋值给p.next
                        p.next = newNode(hash, key, value, null);
                        //若binCount大于等于TREEIFY_THRESHOLD - 1
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            //调用treeifyBin方法后跳出循环
                            treeifyBin(tab, hash);
                        break;
                    }
                    //若e的hash值等于入参hash,同时e的key值等于入参key
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        //跳出循环
                        break;
                    //以上两种情况都没出现时将e赋值给p
                    p = e;
                }
            }
            if (e != null) { // 若对应的key值在HashMap中存在映射值
                //将e的value值赋值给oldValue
                V oldValue = e.value;
                //若onlyIfAbsent==false或者oldValue == null
                if (!onlyIfAbsent || oldValue == null)
                    //将新值value赋值给e.value
                    e.value = value;
                //调用afterNodeAcces方法
                afterNodeAccess(e);
                //返回旧值
                return oldValue;
            }
        }
        //对应的key值在HashMap中不存在映射值,属于新增一个元素,modCount值加1
        ++modCount;
        if (++size > threshold)
            //若size+1后大于阈值threshold,则调用resize方法扩容
            resize();
        //调用afterNodeInsertion方法
        afterNodeInsertion(evict);
        //返回null
        return null;
    }

TreeNode#putTreeVal方法源码:

代码语言:javascript
复制
/**
 * Tree version of putVal.
 */
final TreeNode<K,V> putTreeVal(HashMap<K,V> map, Node<K,V>[] tab,
                               int h, K k, V v) {
    Class<?> kc = null;
    boolean searched = false;
    //若当前TreeNode的父节点不为null则调用root()方法获得node节点,
    //否则当前TreeNode即为root节点
    TreeNode<K,V> root = (parent != null) ? root() : this;
    //从红黑树的root节点开始遍历
    for (TreeNode<K,V> p = root;;) {
        int dir, ph; K pk;
        //若p节点的hash值大于入参hash值h则dir赋值为-1
        if ((ph = p.hash) > h)
            dir = -1;
        //若p节点的hash值小于入参hash值h则dir赋值为1
        else if (ph < h)
            dir = 1;
        //p.key赋值给pk
        else if ((pk = p.key) == k || (k != null && k.equals(pk)))
            //若满足(pk = p.key) == k或者k != null && k.equals(pk)
            //则返回p节点
            return p;
        //若满足kc为null同时调用comparableClassFor(k)方法的返回值赋给kc后的值也为null
        //或者调用compareComparables(kc, k, pk)方法后的返回值赋值给dir后等于0
        else if ((kc == null &&
                  (kc = comparableClassFor(k)) == null) ||
                 (dir = compareComparables(kc, k, pk)) == 0) {
            if (!searched) {
                //若searched==false
                TreeNode<K,V> q, ch;
                searched = true;
                //p.left或p.right赋值给ch后不为null,同时满足调用ch.find(h, k, kc)方法后的
                //返回值赋值给q后也不为null则返回q节点
                if (((ch = p.left) != null &&
                     (q = ch.find(h, k, kc)) != null) ||
                    ((ch = p.right) != null &&
                     (q = ch.find(h, k, kc)) != null))
                    return q;
            }
            //调用tieBreakOrder(k, pk)方法的返回值赋值给dir
            dir = tieBreakOrder(k, pk);
        }
        //定义红黑树节点xp,并将p赋值给xp
        TreeNode<K,V> xp = p;
        //若dir <= 0则将p.left赋值给p,否则将p.right赋值给p,以进行下一次遍历
        if ((p = (dir <= 0) ? p.left : p.right) == null) {
            //p节点为null走以下逻辑
            //定义xpn节点,并将xp.next节点赋值给xpn
            Node<K,V> xpn = xp.next;
            //定义x节点为调用map.newTreeNode(h, k, v, xpn)方法后的返回值
            TreeNode<K,V> x = map.newTreeNode(h, k, v, xpn);
            //若dir<=0 则将x节点赋值给xp的左孩子节点,否则将x节点赋值给xp的右孩子节点
            if (dir <= 0)
                xp.left = x;
            else
                xp.right = x;
            //将x节点赋值给xp的next节点
            xp.next = x;
            //将xp节点同时赋值给x的prev节点和parent节点
            x.parent = x.prev = xp;
            if (xpn != null)
                //若xpn节点不为null则将x节点赋值给xpn的prev节点
                ((TreeNode<K,V>)xpn).prev = x;
            //确保root节点是根节点,里面调用了插入节点后的平衡红黑树方法balanceInsertion(root, x)
            moveRootToFront(tab, balanceInsertion(root, x));
            //返回null
            return  null;
        }
    }
}

以上涉及到红黑树中的复杂算法待自己搞明白了红黑树数据结构再另外撰写一篇文章发布

综上,JDK8HashMap的put操作流程如下:

1) 对KeyHash值,然后再计算下标:

  1. 如果没有碰撞,直接放入桶中(碰撞的意思是计算得到的Hash值相同,需要放到同一个bucket中)

3)如果碰撞了,以链表的方式链接到后面

4)如果链表长度超过阀值( TREEIFY_THRESHOLD==8),就把链表转成红黑树,链表长度低于6,就把红黑树转回链表

  1. 如果节点已经存在就替换旧值
  2. 如果桶满了(容量16*加载因子0.75),就需要 resize(扩容2倍后重排)
3 HashMap 的 get 方法流程

当我们调用get()方法,HashMap会使用键对象的hashcode找到bucket位置,找到bucket位置之后,会调用keys.equals()方法去找到链表中正确的节点,最终找到要找的值对象。

图 2 HashMap#get(key)方法流程

4 HashMap 扩容流程是怎样的?
  1. HashMap的扩容指的就是数组的扩容, 因为数组占用的是连续内存空间,所以数组的扩容其实只能新开一个新的数组,然后把老数组上的元素转移到新数组上来,这样才是数组的扩容
  2. HashMap中也是一样,先新建一个2被数组大小的数组
  3. 然后遍历老数组上的每一个位置,如果这个位置上是一个链表,就把这个链表上的元素转移到新数组上去
  4. 在这个过程中就需要遍历链表,当然jdk7,和jdk8在实现时是不一样的,jdk7就是简单的遍历链表上的每一个元素,然后按每个元素的hashcode结合新数组的长度重新计算得出一个下标,而重新得到的这个数组下标很可能和之前的数组下标是不一样的,这样子就达到了一种效果,就是扩容之后,某个链表会变短,这也就达到了扩容的目的,缩短链表长度,提高了查询效率
  5. 而在jdk8中,因为涉及到红黑树,这个其实比较复杂,jdk8中其实还会用到一个双向链表来维护红黑树中的元素,所以jdk8中在转移某个位置上的元素时,会去判断如果这个位置是一个红黑树,那么会遍历该位置的双向链表,遍历双向链表统计哪些元素在扩容完之后还是原位置,哪些元素在扩容之后在新位置,这样遍历完双向链表后,就会得到两个子链表,一个放在原下标位置,一个放在新下标位置,如果原下标位置或新下标位置没有元素,则红黑树不用拆分,否则判断这两个子链表的长度,如果超过八,则转成红黑树放到对应的位置,否则把单向链表放到对应的位置
  6. 元素转移完了之后,在把新数组对象赋值给HashMaptable属性,老数组会被回收到垃圾收集器中
5 说说你对红黑树的理解

图 3 红黑树数据结构示意图

  1. 每个节点非红即黑
  2. 根节点总是黑色的
  3. 如果节点是红色的,则它的子节点必须是黑色的(反之不一定)
  4. 每个叶子节点都是黑色的空节点(NIL节点)
  5. 从根节点到叶节点或空子节点的每条路径,必须包含相同数目的黑色节点(即相同的黑色高度)
6 为什么 HashMap 的数组的大小是2的幂次方数?

当某个key-value对需要存储到数组中时,需要先生成一个数组下标index,并且这个index不能越界

HashMap中,先得到keyhashcodehashcode是一个数字,然后通过 hashcode & (table.length - 1) 运算得到一个数组下标index,是通过位运算中的与运算计算出来一个数组下标的,而不是通过取余,与运算比取余运算速度更快,但是也有一个前提条件,那就是数组的长度必须是一个2的幂次方数

7 参考文章

[1] HashMap相关面试题(https://www.yuque.com/books/share/9f4576fb-9aa9-4965-abf3-b3a36433faa6/hv4o3e)

[2] HashMap?面试?我是谁?我在哪

---END---

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2021-02-21,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 阿福谈Web编程 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 0 引言
  • 1 JDK8 中的 HashMap与 JDK7 的 HashMap 有什么不一样?
  • 2 HashMap 中 put 方法流程
  • 3 HashMap 的 get 方法流程
  • 4 HashMap 扩容流程是怎样的?
  • 5 说说你对红黑树的理解
  • 6 为什么 HashMap 的数组的大小是2的幂次方数?
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档