HashMap面试题+源码解析

参考链接

https://www.bilibili.com/video/BV1FE411t7M7?spm_id_from=333.999.0.0
https://www.html.cn/qa/other/20583.html
https://blog.csdn.net/weixin_41105242/article/details/106972635
https://blog.csdn.net/fan2012huan/article/details/51087722
https://segmentfault.com/q/1010000012304833
…等等等等

  • 本文其中有一个邪门问题(entrySet和keySet方法的生命周期),作者花了十几个小时,查遍了国内外很多资料最后才总结的
  • 与其他面试题相比,本文能解决的疑惑(第3个大标题):
  1. idea在debug时偷偷调用toString方法,在HashMap追源码时产生的疑惑
  2. HashMap的entrySet()和keySet()方法真的创建了一个存满数据的set对象吗
  3. 内部类:EntrySet类没有构造器,那它是怎么初始化的呢?
  4. 为什么集合框架中一定要利用好迭代器iterator()来获取入口
  5. entrySet().size()获取的值是该set的实际大小吗?

1.数据结构

  • 1.7中是:数组+单链表——头插法(多线程写可能死循环)
    1.8中是:数组+单链表——尾插法 + 红黑树
  • 数组默认初始大小16,负载因子默认0.75(泊松分布下的折中最佳值)

JDK1.7:数组+单向链表

Q:为什么使用单向链表而不使用双向链表?

插入新数据or查询的时候需要遍历相同桶中的所有元素,来确保和相同hash值的所有元素调用equals方法进行比对

Q:头插法会出现什么问题?

并发下:同时扩容可能出现死循环:

图解

JDK1.8:数组+单向链表+红黑树

Q:为什么选择红黑树

  • 红黑树是一个绝对平衡的二叉树,插入、删除、查询的效果都比较平衡;
  • 单向链表过长则会导致效率降低
  • 而选择二叉树则可能在极端情况下成为链表;

Q:单链表什么时候转红黑树

  • 当同时满足:链表长度>=8 且 底层数组长度>=64
  • 底层数组长度不足64时,选择扩容来解决链表过长问题;

Q:JDK1.8使用尾插法

  • 防止了头插法在并发扩容场景下可能出现的死循环

Q:底层数组的元素存储的是值还是节点?

底层数组本身上面存储的是Node implements Map.Entry节点,有一个next指针指向下链表or红黑树的root节点

2.源码解析

2.1 成员变量

内部类

  1. JDK7中使用的是Map.Entry存储键值对, JDK8中Node实际上只是Entry的一个套壳
    JDK8中的Node节点

  2. TreeNode就是红黑树的节点类

成员变量

约定前面的数组结构的每一个格格称为
约定桶后面存放的每一个数据称为bin
bin这个术语来自于JDK 1.8的HashMap注释。


由图可知:

  1. 实现了序列化机制
  2. 1<<<4 位左移4,相当于2^4 = 16,默认底层数组容量是16
  3. 1<<<30 位左移30,相当于2^30 ,即最大底层数组容量
  4. 默认加载因子:0.75,这个数是泊松分布下的折中最佳值:假设底层数组长度是默认值16,当桶数量 >= 16*0.75 = 12时,则扩容
  5. 单链表最大长度=8 , 超过8则扩容 or 转红黑树
  6. 当底层数组长>=64时,才会选择生成红黑树,否则扩容
  7. table即底层数组本身存储的是Node<K,V>节点,链表or红黑树的root节点只是被Node.next指向而已,他们本身不存储在table数组中
  8. entrySet:获取所有元素的set集合(具体过程在后面有)
  9. size:注意区分capacity(数组长度),size是实际k-v对的个数capacity是数组长度;threshold = capacity * loadFactory是扩容临界域
  10. modCount:记录HashMap被修改的次数,Put、Clear等修改操作都会++modCount。并且各种操作都会判断modCount的值是否改变,以在并发场景下抛出异常。
  11. threshold:扩容临界域,桶数量超过该值则扩容
  12. loadFactor:负载因子(加载因子):默认0.75,也可在构造函数中设置。0.75并不是统计学上的最佳,这跟不同的计算机也有关,而是JDK取了一个折中的值。

2.2 常用方法

put方法

put方法实际调用的是putVal:这个方法会计算hash值++modCount计数器哈希碰撞的处理(链表or红黑树)判断添加之后是否需要扩容

Q:这里为什么是元素总数size > threshold 而不是 已使用的桶的数量 > threshold扩容边界值 呢?

补:size、threshold、桶的使用个数

  • 首先,JDK源码中并没有给出桶的使用个数这个filed,只给出了threshold临界阈:例如initCapacity = 16 ,那么threshold = 12 。
  • size是所有元素的个数,查看JDK源码的put方法可知:只要size>桶临界阈threshold,就会进行扩容resize()。
  • 因此可能存在一种极端情况:16个桶(capacity=16),极端的哈希碰撞只使用了两个桶(一个桶7个元素,另一个桶6个元素),但是size > 12 ,扩容。

hash值计算

我们在put方法中可以看到,调用了hash()方法,这个哈希方法首先计算出 key 的 hashCode 赋值给 h,然后与 h 无符号右移 16 位后的二进制进行按位异或得到最后的 hash 值hash()源码

补:为什么capacity必须是2^n

  • 首先为了提高性能,JDK中大量使用了位运算,比如在计算hash值的时候,利用按位与hash&(cap-1),一个数如7(0111)的cap-1就是(0110)。那么最后一位数的按位与始终是0
  • 而一个数如8(1000)的cap-1就是(0111),低位都是1,按位与不容易出现哈希碰撞
  • 总之:2的n次幂 - 1 的二进制码低位全部都是1,于hash值相与时不会改变hash的低位值,因此减少了碰撞的概率

引用

补:JDK如何做到扩容时保持capacity=2^n?

底层也是用了位运算,效果就是:扩容后的是最接近的2^n

扩容时调用的resize()方法

  • 扩容是一个非常耗费资源的操作,并且在JDK7版本需要对所有元素进行hash值重新计算
  • JDK8引入了新的算法rehash(这不是一个JDK方法,这是一个算法),在rehash算法下,可以让一部分元素待在原索引处,另一部分元素 索引 += 新增的容量数

remove删除

  • 如果删除后该桶处的红黑树节点<=8,则红黑树转为单链表
  • 删除也是先算hash定位,然后遍历红黑树or单链表

同理get()方法也是先算hash定位,后在桶中遍历

2.3遍历的方法

一般情况下都是使用迭代器,迭代器的效率最高,阿里开发手册指出:第三种方法会遍历两次

三种方法

2.4迭代器原理

可以自己写一个,然后跟着debug

    Set set = hs.entrySet();//重点关注entrySet怎么来的
    Iterator iterator = set.iterator();

    while (iterator.hasNext()){
        Map.Entry next = (Map.Entry) iterator.next();
        next.getKey();
        next.getValue();
    }

iterator.next()方法

  1. 调用next()方法相当于调用Iterator接口中的nextNode()方法

  1. 在HashIterator类中查看nextNode()方法
    1
    2

2.5 entrySet()、keySet()是如何获取Set的?

  • 总所周知HashMap的底层是数组+链表+红黑树
  • 并且put方法中并没有同时构造一个entrySet、keySet
  • 那么hashMap.entrySet()为何就能直接获取entry键值对的Set集合的呢?

我们以keySet()方法为例:

调用keySet()方法会 new一个keySet类对象

public Set<K> keySet() {
    Set<K> ks = keySet;
    if (ks == null) {
        ks = new KeySet();//1.调用keySet()方法时就new一个KeySet对象
        keySet = ks;
    }
    return ks;
}


//2.但是KeySet类中并没有一个构造方法来使得keySet被赋值
final class KeySet extends AbstractSet<K> {
//3.有的也只是重写了size()方法,返回的是当前hashMap的size
    public final int size()                 { return size; }
    public final void clear()               { HashMap.this.clear(); }
    //4.并且初始化时也没有调用迭代器
    public final Iterator<K> iterator()     { return new KeyIterator(); }
    public final boolean contains(Object o) { return containsKey(o); }
    public final boolean remove(Object key) {
        return removeNode(hash(key), key, null, false, true) != null;
    }
    public final Spliterator<K> spliterator() {
        return new KeySpliterator<>(HashMap.this, 0, -1, 0, 0);
    }
    //5.更没有调用forEach方法
    public final void forEach(Consumer<? super K> action) {
        Node<K,V>[] tab;
        if (action == null)
            throw new NullPointerException();
        if (size > 0 && (tab = table) != null) {
            int mc = modCount;
            for (int i = 0; i < tab.length; ++i) {
                for (Node<K,V> e = tab[i]; e != null; e = e.next)
                    action.accept(e.key);
            }
            if (modCount != mc)
                throw new ConcurrentModificationException();
        }
    }
}

然后我们去KeySet的父类,发现只有一个空参的空构造器,那么entrySet()和keySet()方法在调用的时候,到底是如何赋值的呢?

3.entrySet()和keySet()详解

  • 前面已经提到,entrySet()和keySet()方法都是返回一个set对象,并且在debug时也能看到set的值和size;但是源码中并没有利用构造器对其初始化。
  • 在此文中以entrySet()举例,来阐述entrySet和keySet的生命周期

3.1测试代码示例,并提出问题

    HashMap hs = new HashMap();
    hs.put("啊啊", "2");
    hs.put("2", "2");
    hs.put("3", "2");
    hs.put("4", "2");
    hs.put("5", "2");
    hs.put("6", "2");
    hs.put("7", "2");
    hs.put("8", "2");
    hs.put("9", "2");

    Set entrySet = hs.entrySet();//1.获取entrySet对象
    System.out.println(entrySet );//2.结果:正确输出了上面put的内容
    Object[] objects = entrySet .toArray();
    System.out.println(objects[3]);//3.结果:正确打印了5=2

    Iterator iterator = entrySet .iterator();//4.获取迭代器
    while (iterator.hasNext()){//5.迭代器遍历
        Map.Entry next = (Map.Entry) iterator.next();
        next.getKey();
        next.getValue();
    }
  • 如果在idea中使用debug对Set entrySet = hs.entrySet()打断点,你会发现即便是没有运行到第二步,debug信息栏中已然出现了该set全部的内容。
  • 跟着debug一直step into进行分析,发现并没有任何一个方法对entrySet对象进行过赋值,那么hashMap对象中的内容是如何进入到entrySet中的呢?

3.1 debug分析

new EntrySet()

  1. 没有找到构造函数

  1. AbstractSet中只有一个空参且空函数体的方法

第一个结论:entrySet是空对象

entrySet只是new出来了,但它是一个没有内容的空对象。

3.2第二个问题:entrySet对象调用方法时为何有值?

既然 Set entrySet = hs.entrySet()出来的entrySet 对象是个空对象,那为何System.out.println(entrySet );
Object[] objects = entrySet .toArray();
Iterator iterator = entrySet .iterator()
对entrySet对象进行调用方法的时候却能正确的执行呢?

3.2 源码分析

toString方法

System.out.println(entrySet ); 显然调用的是toString方法;

  • 在2.5中的源码可知entrySet对象继承于AbstractSet类,间接实现了AbstractCollection接口
  • 重写的toString()方法调用了this.iterator()即EntrySet类的iterator()方法进行输出

iterator方法

  1. 我们发现HashMap中的内部类EntrySet中重写了iterator()方法,实际调用的是EntryIterator对象

  2. EntryIterator类继承于HashIterator类 :nextNode方法是HashIterator中的方法
    EntryIterator

  3. 跟进nextNode方法,框体内容是指针的变化的判断细节,不是本论题的重点


由此可知:

这个EntrySet类重写的iterator()方法可以使得指针正确得指向下一个节点

toArray方法

  • 测试代码中也正确输出了toArray()的结果,同样的,这个方法也是重写在其父类AbstractCollection中的
  • 同理,其底层实现还是调用了iterator(),利用HashIterator类中的nextNode()方法对指针进行下移

size、clear方法

这两个方法很简单,就是直接用的当前HashMap对象的属性和方法

hashMap.entrySet().size()的大小并不是真正这个entrySet对象的大小,而是调用了size属性的fake size

3.3小总结

  • entrySet()和keySet()都是懒汉式,调用方法,new对象的时候并没有对其进行赋值
  • 而是在使用hashMap.entrySet().toString()、hashMap.entrySet(). toArray() 等方法的时候才调用iterator()来获取迭代器
  • EntrySet类和KeySet类也都没有构造器能进行初始化
  • 不得不说HashMap的设计十分精巧,entrySet()表面上获取了一个set对象,实际上这个set对象是空的,几乎所有的方法都是先直接获取迭代器入口性能大幅提升

3.4关于idea在debug时的问题

参考

  • DEA在debug时,当debug到某个对象时,会调用对象的toString()方法,用来在debug界面显示对象信息。
  • IDEA调用toString()方法时,即使在toString()方法中设置了断点,该断点也不会被触发,也就是说,开发者多数情况下不会知道toString()方法被调用了。
  • 多数情况下调用一下toString()方法没有什么问题,但是也有例外,比如重写了toString()方法的类,随意的调用toString()方法会导致未知的问题本案例就是因为重写toString()方法而产生了问题
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值