当前位置: 首页 > article >正文

黑马Java面试教程_P7_常见集合_P4_HashMap

系列博客目录


文章目录

  • 系列博客目录
  • 4. HashMap相关面试题
    • 4.4 面试题-HashMap的put方法的具体流程 频5
      • 4.4.1 hashMap常见属性
      • 4.4.2 源码分析 HashMap的构造函数
      • 面试文稿:
    • 4.5 讲一讲HashMap的扩容机制 难3频4
      • 面试文稿:
    • 4.6 面试题-hashMap的寻址算法 难4频4
      • 面试文稿
    • 4.7 面试题-hashmap在1.7情况下的多线程死循环问题 难4频3
      • 面试文稿


4. HashMap相关面试题

4.4 面试题-HashMap的put方法的具体流程 频5

4.4.1 hashMap常见属性

在这里插入图片描述
size是存储元素的个数。

注意这个table,他是由hashMap的内部类Node组成的数组,Node里面有哈希值(用来记录存储的位置)、key和value,还有next,因为发生冲突的时候会用到链表以及红黑树。

4.4.2 源码分析 HashMap的构造函数

Map<String, String> map = new HashMap<>();
map.put("name","itheima");

按住Ctrl,左键点进HashMap中,

public HashMap() {
       this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted 默认加载因子 0.75
}

上方代码说明了以下两点:

  • HashMap是懒惰加载,在创建对象时并没有初始化数组
  • 在无参的构造函数中,设置了默认的加载因子是0.75

为了更好理解源码,先看一下添加数据的流程图

第一次添加数据流程图(涉及到了初始化,添加数据以及如果大于阈值调用resize())如下:
在这里插入图片描述
以下是非第一次,发现key存在时候的流程图
在这里插入图片描述
以下是发现key不存在的时候的流程图。
在这里插入图片描述
以下是针对上面流程图的JDK1.8的带注释的源码。

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    //判断数组是否未初始化
    if ((tab = table) == null || (n = tab.length) == 0)
        //如果未初始化,调用resize方法 进行初始化
        n = (tab = resize()).length;
    //通过 & 运算求出该数据(key)的数组下标并判断该下标位置是否有数据
    if ((p = tab[i = (n - 1) & hash]) == null)
        //如果没有,直接将数据放在该下标位置
        tab[i] = newNode(hash, key, value, null);
    //该数组下标有数据的情况
    else {
        Node<K,V> e; K k;
        //判断该位置数据的key和新来的数据是否一样
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            //如果一样,证明为修改操作,该节点的数据赋值给e,后边会用到
            e = p;
        //判断是不是红黑树
        else if (p instanceof TreeNode)
            //如果是红黑树的话,进行红黑树的操作
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        //新数据和当前数组既不相同,也不是红黑树节点,证明是链表
        else {
            //遍历链表
            for (int binCount = 0; ; ++binCount) {
                //判断next节点,如果为空的话,证明遍历到链表尾部了
                if ((e = p.next) == null) {
                    //把新值放入链表尾部
                    p.next = newNode(hash, key, value, null);
                    //因为新插入了一条数据,所以判断链表长度是不是大于等于8
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        //如果是,进行转换红黑树操作
                        treeifyBin(tab, hash);
                    break;
                }
                //判断链表当中有数据相同的值,如果一样,证明为修改操作
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                //把下一个节点赋值为当前节点
                p = e;
            }
        }
        //判断e是否为空(e值为修改操作存放原数据的变量)
        if (e != null) { // existing mapping for key
            //不为空的话证明是修改操作,取出老值
            V oldValue = e.value;
            //一定会执行  onlyIfAbsent传进来的是false
            if (!onlyIfAbsent || oldValue == null)
                //将新值赋值当前节点
                e.value = value;
            afterNodeAccess(e);
            //返回老值
            return oldValue;
        }
    }
    //计数器,计算当前节点的修改次数
    ++modCount;
    //当前数组中的数据数量如果大于扩容阈值 16* 0.75 = 12
    if (++size > threshold)
        //进行扩容操作
        resize();
    //空方法
    afterNodeInsertion(evict);
    //添加操作时 返回空值
    return null;
}

面试文稿:

  1. 判断键值对数组table是否为空或为null,否则执行resize()进行扩容(初始化)
  2. 根据键值key计算hash值得到数组索引
  3. 判断table[i]==null,条件成立,直接新建节点添加
  4. 如果table[i]==null ,不成立
     4.1 判断table[i]的首个元素是否和key一样,如果相同直接覆盖value
     4.2 判断table[i] 是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对
     4.3 遍历table[i],链表的尾部插入数据,然后判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操 作,遍历过程中若发现key已经存在直接覆盖value
  5. 插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold(数组长度*0.75),如果超过,进行扩容。

4.5 讲一讲HashMap的扩容机制 难3频4

扩容的流程

第一次初始化数组的流程如下:
在这里插入图片描述
第一次存储数量大于12时流程如下:
在这里插入图片描述

//扩容、初始化数组
final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;
        //如果当前数组为null的时候,把oldCap老数组容量设置为0
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        //老的扩容阈值
        int oldThr = threshold;
        int newCap, newThr = 0;
        //判断数组容量是否大于0,大于0说明数组已经初始化
        if (oldCap > 0) {
            //判断当前数组长度是否大于最大数组长度
            if (oldCap >= MAXIMUM_CAPACITY) {
                //如果是,将扩容阈值直接设置为int类型的最大数值并直接返回
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            //如果在最大长度范围内,则需要扩容  OldCap << 1等价于oldCap2
            //运算过后判断是不是最大值并且oldCap需要大于16
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
*                newThr = oldThr << 1; // double threshold  等价于oldThr*2
        }
        //如果oldCap<0,但是已经初始化了,像把元素删除完之后的情况,那么它的临界值肯定还存在,                如果是首次初始化,它的临界值则为0
        else if (oldThr > 0) // initial capacity was placed in threshold
            newCap = oldThr;
        //数组未初始化的情况,将阈值和扩容因子都设置为默认值
        else {               // zero initial threshold signifies using defaults
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        //初始化容量小于16的时候,扩容阈值是没有赋值的
        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;
                //判断当前下标为j的数组如果不为空的话赋值个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 {
                        //比如老数组容量是16,那下标就为0-15
                        //扩容操作*2,容量就变为32,下标为0-31
                        //低位:0-15,高位16-31
                        //定义了四个变量
                        //        低位头          低位尾
                        Node<K,V> loHead = null, loTail = null;
                        //        高位头          高位尾
                        Node<K,V> hiHead = null, hiTail = null;
                        //下个节点
                        Node<K,V> next;
                        //循环遍历
                        do {
                            //取出next节点
                            next = e.next;
                            //通过 与操作 计算得出结果为0
                            if ((e.hash & oldCap) == 0) {
                                //如果低位尾为null,证明当前数组位置为空,没有任何数据
                                if (loTail == null)
                                    //将e值放入低位头
                                    loHead = e;
                                //低位尾不为null,证明已经有数据了
                                else
                                    //将数据放入next节点
                                    loTail.next = e;
                                //记录低位尾数据
                                loTail = e;
                            }
                            //通过 与操作 计算得出结果不为0
                            else {
                                 //如果高位尾为null,证明当前数组位置为空,没有任何数据
                                if (hiTail == null)
                                    //将e值放入高位头
                                    hiHead = e;
                                //高位尾不为null,证明已经有数据了
                                else
                                    //将数据放入next节点
                                    hiTail.next = e;
                               //记录高位尾数据
                                hiTail = e;
                            }
                        }
                        //如果e不为空,证明没有到链表尾部,继续执行循环
                        while ((e = next) != null);
                        //低位尾如果记录的有数据,是链表
                        if (loTail != null) {
                            //将下一个元素置空
                            loTail.next = null;
                            //将低位头放入新数组的原下标位置
                            newTab[j] = loHead;
                        }
                        //高位尾如果记录的有数据,是链表
                        if (hiTail != null) {
                            //将下一个元素置空
                            hiTail.next = null;
                            //将高位头放入新数组的(原下标+原数组容量)位置
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        //返回新的数组对象
        return newTab;
    }

面试文稿:

  • 在添加元素或初始化的时候需要调用resize方法进行扩容,第一次添加数据初始化数组长度为16,以后每次每次扩容都是达到了扩容阈值(数组长度 * 0.75)
  • 每次扩容的时候,都是扩容之前容量的2倍;
  • 扩容之后,会新创建一个数组,需要把老数组中的数据挪动到新的数组中
    • 没有hash冲突的节点,则直接使用 e.hash & (newCap - 1) 计算新数组的索引位置
    • 如果是红黑树,走红黑树的添加
    • 如果是链表,则需要遍历链表,可能需要拆分链表,判断(e.hash & oldCap)是否为0,该元素的位置要么停留在原始位置,要么移动到原始位置+增加的数组大小这个位置上

4.6 面试题-hashMap的寻址算法 难4频4

public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
}

在putVal方法中,有一个hash(key)方法,这个方法就是来去计算key的hash值的,看下面的代码

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

首先获取key的hashCode值,然后右移16位 异或运算 原来的hashCode值,主要作用就是使原来的hash值更加均匀,减少hash冲突。

GPT:通常情况下,hashCode 的低位信息可能有某些模式或偏向,而高位的信息可能没有充分地分布到低位。为了避免这些模式导致哈希值不均匀,HashMap 会对哈希值进行调整,使得低位和高位信息更加混合,从而避免过多元素落在哈希表的同一桶中。右移操作 (h >>> 16) 将原始 hashCode 的高 16 位移到低 16 位。
然后,使用 异或 (^) 操作将这两部分合并在一起。这是为了将 hashCode 的低位和高位信息都引入到哈希计算中。

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) 
{
	。。。。。
	if ((p = tab[i = (n - 1) & hash]) == null)
	    tab[i] = newNode(hash, key, value, null);
	。。。。

(n-1)&hash : 得到数组中的索引,代替取模,性能更好,数组长度必须是2的n次幂

面试文稿

关于hash值的其他面试题:为何HashMap的数组长度一定是2的次幂?

  1. 计算索引时效率更高:如果是 2 的 n 次幂可以使用位与运算代替取模
  2. 扩容时重新计算索引效率更高: hash & oldCap == 0 的元素留在原来位置 ,否则新位置 = 旧位置 + oldCap

4.7 面试题-hashmap在1.7情况下的多线程死循环问题 难4频3

jdk7的的数据结构是:数组+链表
在数组进行扩容的时候,因为链表是头插法,在进行数据迁移的过程中,有可能导致死循环
在这里插入图片描述

  • 变量e指向的是需要迁移的对象
  • 变量next指向的是下一个需要迁移的对象  (e迁移完毕后,next把自己赋值给e)
  • Jdk1.7中的链表采用的头插法
  • 在数据迁移的过程中并没有新的对象产生,只是改变了对象的引用

产生死循环的过程:
线程1和线程2的变量e和next都引用了这个两个节点。
在这里插入图片描述
线程2扩容后,由于头插法,链表顺序颠倒,但是线程1的临时变量e和next还引用了这两个节点
在这里插入图片描述

第一次循环
由于线程2迁移的时候,已经把B的next执行了A
在这里插入图片描述
第二次循环
在这里插入图片描述
第三次循环
在这里插入图片描述

面试文稿

参考回答:
在jdk1.7的hashmap中在数组进行扩容的时候,因为链表是头插法,在进行数据迁移的过程中,有可能导致死循环
比如说,现在有两个线程
线程一:读取到当前的hashmap数据,数据中一个链表,在准备扩容时,线程二介入
线程二:也读取hashmap,直接进行扩容。因为是头插法,链表的顺序会进行颠倒过来。比如原来的顺序是AB,扩容后的顺序是BA,线程二执行结束。
线程一:继续执行的时候就会出现死循环的问题。
线程一先将A移入新的链表,再将B插入到链头,由于另外一个线程的原因,B的next指向了A,
所以B->A->B,形成循环。
当然,JDK 8 将扩容算法做了调整,不再将元素加入链表头(而是保持与扩容前一样的顺序),使用尾插法,就避免了jdk7中死循环的问题。


http://www.kler.cn/a/447416.html

相关文章:

  • 3354. 使数组元素等于零
  • 中国人工智能学会技术白皮书
  • DB-GPT V0.6.3 版本更新:支持 SiliconCloud 模型、新增知识处理工作流等
  • 智能工厂的设计软件 认知系统和内涵智能机 之1
  • 智能工厂的设计软件 三种处理单元(NPU/GPU/CPU)及其在深度学习框架中的作用 之5(腾讯云AI代码助手 之3)
  • Pytorch | 利用FGSM针对CIFAR10上的ResNet分类器进行对抗攻击
  • homebrew,gem,cocoapod 换源,以及安装依赖
  • uniapp实现手写签名,并在app中将其转为base64格式的图片
  • springboot中的AOP以及面向切面编程思想
  • Vue.js前端框架教程8:Vue消息提示ElMessage和ElMessageBox
  • Win/Mac 如何实现测试 IP 和端口
  • ​在VMware虚拟机上设置Ubuntu与主机共享文件夹​
  • ubuntu 开机自动mount 的方法
  • 行情接入手册
  • 信息安全管理与评估赛题第6套
  • 【初阶数据结构与算法】八大排序算法之选择排序(直接选择排序、堆排)
  • 使用C#绘制具有平滑阴影颜色的曼德布洛特集分形
  • 国产操作系统openEuler22.09系统OpenStackYoga 部署指南
  • [笔记]关于Qt的nativeEvent事件无法接收window消息的Bug
  • 【从零开始入门unity游戏开发之——C#篇17】C#面向对象的封装——类(Class)和对象、成员变量和访问修饰符、成员方法
  • Liquibase结合SpringBoot使用实现数据库管理
  • 使用 mstsc 远程桌面连接时无法复制粘贴本地文件或文字解决方法
  • SAP PP ECN CSAP_MAT_BOM_MAINTAIN
  • run postinstall error, please remove node_modules before retry!
  • PyTorch实战-模拟线性函数和非线性函数
  • 基于matlab的单目相机标定