Debug HashMap

本文通过HashMap面试常见问题和JDK1.7与1.8的源码debug,深入探讨HashMap的原理。内容包括HashMap的面试必问问题、1.7和1.8版本的HashMap元素添加、获取、扩容及死循环问题的分析。通过实战加深理解,强调理论结合实践的重要性。
摘要由CSDN通过智能技术生成

最近跟两个正在找工作的同学聊天,说起集合,都是面试的重灾区,必问的选项,而且在实际的面试中并不会单独提问某一个问题,而是围绕核心知识连环炮提问。所以背面试题治标不治本,还是得读一读源码。谁让这是个面试造火箭,工作拧螺丝的市场氛围,就连CSDN的首页第二张轮播图都在蹭这个热点:

image-20200715110439001

本文主要包括两部分:

  • HashMap面试必问(总结了一些常见面试题)

  • JDK1.7 & JDK1.8 关于HashMap原理分析

    这部分主要是通过断点debug来分析HashMap中常见操作的过程,但由于步骤繁多,只记录了关键步骤,建议读者也在自己电脑上debug一遍,了解详细流程。(计算机是一门实践性很强的学科,看的再多也不如自己亲自操作一遍,当然理论也同样重要)

长文警告!!!

1,HashMap面试必问

这是笔者在一篇博客中找出来的,很有代表性,实际的面试提问中不会按部就班的问,而是千变万化,所以除了把面试题背住之外,一定要花点时间看看源码具体实现,虽然不会360度无死角,但对源码总体有个大概的把握,回答起来就知道哪些知道哪些不知道,一来方便查漏补缺,二来也能更加灵活的回答问题。

示例性提问(真实场景下):

  • 你看过JDK的源码吗?

    看过。

  • HashMap是如何通过put添加元素的?

    根据key计算hash值,再将hash值转换为数组下标。

  • 底层数组默认的长度为多少?

    默认为16。

  • 什么时候会触发扩容机制?

    元素个数超过阈值就会触发扩容机制,并且是在新增元素发生hash冲突的情况下。

  • 扩容时,直接将数据从原数组平移到新数组可以吗?

    不行,需要重新计算hash值(更正,是重新计算index值,而不是重新计算hash值,hash值只与key相关,index与table.length相关)

  • 为什么需要重新计算hash值?

    因为数组扩容了,从hash值转换为数组下标这个过程就发生了变化,同时,获取value这个过程也会发生变化。所以必须重新计算,不然之前保存的元素就无法访问。

一般性问题(建议背住,而后融会贯通):

  • 什么是HashMap?

    HashMap是基于Map接口的实现,主要用于存储键值对(1.7通过Entry对象封装键值对,1.8通过Node封装键值对)

  • HashMap采用了什么数据结构?

    1.7:数组+链表

    1.8:数组+链表+红黑树

  • HashMap是如何解决hash冲突的问题的?

    链表。

  • hash冲突和index冲突的关系?

    hash冲突就会导致index冲突,indexFor方法的两个参数一个是hash值,另外一个是table.length。

  • HashMap的put方法是如何实现的?

    先通过key计算hash值,再通过indexFor方法转换为数组下标。

  • HashMap的扩容机制是什么样的?

    HashMap默认初始容量为16,加载因子为0.75,实际存储大小为12。hashMap容量达到12并且当前加入的元素产生hash冲突时时,进行初始容量的2倍扩容

    • 为什么初始容量为16?

      HashMap重写的hash采用的是位运算,目的是使key到index的映射分布更加均匀

      	static final int hash(Object key) {
              int h;
              return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
          }
          
          也解释了为什么hash允许空值,实际上当key为null时,自动转换为0
      
  • 为什么链表使用头插法?

    HashMap的发明者认为,后插入的Entry被查找的可能性更大

  • hashMap中的链表是单链表还是双链表?

    单链表

     		final int hash;
            final K key;
            V value;
            Node<K,V> next;
    
  • 扩容阈值threshold被赋值了几次?

    • 调用构造函数被赋值,初始化容量大小(默认为16)
    • 数组为空,初始化数组时,被赋值为初始化容量*加载因子(默认为12)
  • hash冲突插入链表的方式?

    1.7:采用头插法:作者认为,后插入的会被优先访问

    1.8:采用尾插法:避免链表死循环

  • hashMap允许key为null值吗?

    允许一个key为null,会转换为数组下标0。当出现第二个key为null,其value会自动覆盖第一个null的值。

  • hashMap中链表过长会导致什么问题?

    查询效率降低。时间复杂度为O(n)【需要遍历链表】

  • jdk7中的HashMap存在哪些问题?

    • 链表过长导致查询效率降低

    • 扩容导致的死循环

    • 线程不安全(个人认为这不是问题,而是在设计上就没有考虑这个,线程安全就会导致效率降低,本质上是效率和安全之间的取舍)

  • jdk7和jdk8处理hash冲突的区别?为什么?

    jdk7计算hash值的运算是非常复杂的,因为如果产生了hash冲突是用链表来进行存储的,效率比较慢,所以在设计上要尽可能避免冲突。

    jdk8计算hash值的方法相对简单,因为采用了红黑树的结构,即使发生了hash冲突,也可以通过转换为红黑树来提高效率。

  • 为什么加载因子是0.75而不是其他值?

    因为加载因子参与indexFor数组下标的计算,return h & (length-1);

    其数值会影响index是否发生冲突,同时也会影响空间利用率,默认情况下table长度为16,但只能存12个值。

    所以这个加载因子是在index冲突和空间利用率之间寻求的一个平衡点。

  • HashMap是否可以存放自定义对象?

    可以,因为HashMap使用了泛型。

  • 为什么JDK8引入红黑树?

    由于hash冲突导致链表查询非常慢,时间复杂度为O(n),引入红黑树后链表长度为8时会自动转换为红黑树,以提高查询效率O(logn)。

  • Java集合中ArrayList,LinkedList,HashMap的时间复杂度分别为多少?

    ArrayList基于数组实现,基于下标查询的话时间复杂度为O(1),如果基于内容查找需要遍历的话,时间复杂度为O(n)。

    LinkedList基于链表实现,查询效率为O(n)

    HashMap在不考虑Hash冲突没有形成链表的情况下时间复杂度为O(1),形成链表后时间复杂度为O(n)

2,Debug源码的心得体会

【关注核心步骤,选择性忽略】

JDK是一个相当庞大的系统,把所有的类和原理全部弄清楚是相当有难度的,所以在debug源码的时候,如果遇见了不相关的类,忽略就是了。

然而单看HashMap源码(2300行)也是一个较为庞大的代码量,所以对其中不重要或者不常用的方法,最好先选择性忽略。比如计算hash值的各种位运算,研究起来还是得废一些功夫的,这个可以在把握了HashMap的大致框架后再做精细化的研究。

总的来说,先重点关注核心步骤,选择性忽略更加具体的实现,逐个击破,从而提高阅读效率

ps:建议把1.7和1.8的jdk都装上,切换着分析。

3,JDK 1.7

3.1 用debug分析一个元素是如何加入到HashMap中的【jdk1.7】

创建一个Main.java类

 		HashMap<String,String> hashMap = new HashMap<>(16);
        
        hashMap.put("x","x");
        hashMap.put("y","y");

在创建HashMap对象上打上断点:

image-20200715162215255

debug运行,强制进入方法内部(Alt+Shift+F7):

调用构造函数:

image-20200715165233458

this方法,初始值判空异常(初始值不能小于0大于最大值),加载因子判空异常,

threshold被初始化容量赋值(threshold为扩容阈值)

image-20200715165318136

在插入第一个元素上打上断点:

image-20200715165820913

debug运行,强制进入方法内部(Alt+Shift+F7):

	public V put(K key, V value) {
		//判断数组是否为空,如果为空进行初始化,inflateTable初始化方法见下文①
		//threshold:扩容的阈值(当前元素个数超过这个数值就会进行扩容)
        if (table == EMPTY_TABLE) {
            inflateTable(threshold);
        }
        
        //判断key是否为空
        if (key == null)
        	//hashMap处理空值的方法②
            return putForNullKey(value);
            
        //计算key的hash值(主要是各种位运算)
        int hash = hash(key);
        
        //i就是将key的hash值再进行一次转换得出的数组下标
        int i = indexFor(hash, table.length);
        //同样是个处理hash冲突的头插算法
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {
            Object k;
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }

        modCount++;
        
        //添加元素③
        addEntry(hash, key, value, i);
        return null;
    }

①inflateTable初始化容量方法:

private void inflateTable(int toSize) {
        //向上舍入为2的幂
        int capacity = roundUpToPowerOf2(toSize);

	    //重点:threshold在初始化构造函数时默认为16,在初始化数组时,乘以加载因子被二次赋值
        threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
        //初始化数组容量
        table = new Entry[capacity];
        initHashSeedAsNeeded(capacity);
    }

②hashMap处理空值的方法

private V putForNullKey(V value) {

		//处理key为null值的hash冲突,采用头插法(null会自动转为0)
        for (Entry<K,V> e = table[0]; e != null; e = e.next) {
            if (e.key == null) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }
        modCount++;
        addEntry(0, null, v
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值