面试:为了进阿里,必须掌握HashMap原理和面试题(图解版一)

简介: 集合在基础面试中是必备可缺的一部分,其中重要的HashMap更是少不了,那面试官会面试中提问那些问题呢,这些在JDK1.7和1.8有什么区别??

前言


集合在基础面试中是必备可缺的一部分,其中重要的HashMap更是少不了,那面试官会面试中提问那些问题呢,这些在JDK1.7和1.8有什么区别??


  • HashMap的底层原理
  • HashMap的hash哈希函数的设计原理,以及HashMap下标获取方式?
  • HashMap扩容机制,hashMap中什么时候需要进行扩容,扩容resize()又是如何实现的
  • hashMap中put是如何实现的 ,JDK1.7和1.8有什么区别?
  • hashMap中get是如何实现的
  • 其他涉及问题


  • HashMap具备的特性
  • 为什么Hash的底层数据长度总为2的N次方?如果输入值不是2的幂比如10会怎么样?
  • 加载因子为什么是 0.75?
  • 哈希表如何解决Hash冲突
  • 当有哈希冲突时,HashMap 是如何查找并确认元素的?
  • HashMap 是线程安全的吗,为什么不是线程安全的?


1. HashMap的底层原理


JDK1.7使用的是数组+ 单链表的数据结构。JDK1.8之后,使用的是数组+链表+红黑树的数据结构


HashMap数据结构图(jdk1.8)


16.png


//解决hash冲突,链表转成树的阈值,当桶中链表长度大于8时转成树 
static final int TREEIFY_THRESHOLD = 8;
//进行resize操作时,若桶中数量少于6则从树转成链表
static final int UNTREEIFY_THRESHOLD = 6;
/* 当需要将解决 hash 冲突的链表转变为红黑树时,需要判断下此时数组容量,若是由于数组容量太小(小于 MIN_TREEIFY_CAPACITY )导致的 hash 冲突太多,则不进行链表转变为红黑树操作,转为利用 resize() 函数对 hashMap 扩容 */
static final int MIN_TREEIFY_CAPACITY = 64;
复制代码


从HashMap常量中可以看出,当链表的深度达到8的时候,也就是默认阈值TREEIFY_THRESHOLD=8,就会自动扩容把链表转成红黑树的数据结构来把时间复杂度从O(n)变成O(logN)提高了效率,而且当进行resize操作时,若桶中数量少于6则从树转成链表。


那为什么数据结构需要从JDK1.7换成JDK1.8的数组+链表+红黑树?


在JDK1.7中,当相同的hash值时,HashMap不断地产生碰撞,那么相同key位置的链表就会不断增长,当查询HashMap的相应key值的Vaule值时,就会去循环遍历这个超级大的链表,查询性能非常低下。


但在JDK1.8当链表超过8个节点数时,将会让红黑树来替代链表,查询性能得到了很好的提升,从原来的是O(n)到O(logn)。


2. HashMap的hash哈希函数的设计原理,以及HashMap下标获取 hash &(n - 1)?


hash哈希函数的设计原理


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


  1. 首先获取hashcode,一个32位的int值
  2. 然后将hashcode左移16位的值进行与或,即将高位与低位进行异或运算,减少碰撞机率。


17.png


HashMap下标获取h % n = h &(n - 1)


18.png


  1. 取余运算,但在计算机运算中&肯定比%快,又因为h % n = h &(n - 1),所以最终将第二步得到的hash跟n-1进行与运算。n是table中的长度。


设计原因


  1. 一定要尽可能降低hash碰撞,越分散越好;
  2. 算法一定要尽可能高效,因为这是高频操作, 因此采用位运算;


3. HashMap扩容机制resize()


HashMap扩容步骤分成两步:


  • 获取新值:新的容量值newCap ,新的扩容阀界值newThr获取
  • 数据迁移:如果oldTab老数组不为空,说明是扩容操作,那么涉及到元素的转移操,遍历老数组,如果当前位置元素不为空,那么需要转移该元素到新数组


获取新值:新的容量值newCap ,新的扩容阀界值newThr获取


  • 扩容变量


//原的元素数组
Node<K,V>[] oldTab = table; 
//老的元素数组长度
int oldCap = (oldTab == null) ? 0 : oldTab.length; 
// 老的扩容阀值设置
int oldThr = threshold;
// 新数组的容量,新数组的扩容阀值都初始化为0
int newCap, newThr = 0;
// 设置map的扩容阀值为 新的阀值
 threshold = newThr; 
 //创建新的数组(对于第一次添加元素,那么这个数组就是第一个数组;对于存在oldTab的时候,那么这个数组就是要需要扩容到的新数组)
 Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
 // 将该map的table属性指向到该新数组
  table = newTab; 
复制代码


  • 当如果老数组长度oldCap > 0,说明已经存在元素,


  • 如果此时oldCap>=MAXIMUM_CAPACITY(1 << 30),表示已经到了最大容量,这时还要往map中put数据,则阈值设置为整数的最大值 Integer.MAX_VALUE,直接返回这个oldTab的内存地址
  • 如果扩容之后的新容量小于最大容量 ,且老的数组容量大于等于默认初始化容量(16),那么新数组的扩容阀值设置为老阀值的2倍(左移1位相当于乘以2,newCap = oldCap << 1),阈值也double(newThr= oldThr << 1);


// 如果老数组长度大于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; // double threshold
    }
复制代码


  • 当老数组没有任何元素,如果老数组的扩容阀值大于0,那么设置新数组的容量为该阀值,newCap = oldThr。当newThr扩容阀值为0 ,newThr = (float)newCap * loadFactor这一步也就意味着构造该map的时候,指定了初始化容量构造函数);


else if (oldThr > 0) // initial capacity was placed in threshold
 newCap = oldThr;
 ....
 if (newThr == 0) {
     float ft = (float)newCap * loadFactor;
      newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                (int)ft : Integer.MAX_VALUE);  
  }
复制代码


  • 其他情况**,设置新数组容量 为 16,且设置新数组扩容阀值为 16*0.75 = 12。0.75为负载因子,newCap =16,newThr=12(***使用默认参数创建的该map,并且第一次添加元素


else { // zero initial threshold signifies using defaults
            // 设置新数组容量 为 16
            newCap = DEFAULT_INITIAL_CAPACITY;
             // 设置新数组扩容阀值为 16*0.75 = 12。0.75为负载因子
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
       }
复制代码


数据迁移


如果oldTab老数组不为空,说明是扩容操作,那么涉及到元素的转移操,遍历老数组,如果当前位置元素不为空,那么需要转移该元素到新数组。


  • 如果元素没有有下一个节点,说明该元素不存在hash冲突,因将元素存储到新的数组中,存储到数组的哪个位置需要根据hash值和数组长度来进行取模


// 如果元素没有有下一个节点,说明该元素不存在hash冲突
  if (e.next == null)   
    newTab[e.hash & (newCap - 1)] = e;
复制代码


  • 如果该节点为TreeNode类型,插入红黑树中


// 如果该节点为TreeNode类型
    else if (e instanceof TreeNode)  
           ((TreeNode<K,V>)e).split(this, newTab, j, oldCap); 
复制代码


  • 遍历链表,并将链表节点按原顺序进行分组


  • 将元素的hash值 和 老数组的长度做与运算e.hash & oldCap,判断出是在原位置还是在原位置再移动2次幂的位置(loTail低位指的是新数组的 0  到 oldCap-1 hiTail高位指定的是oldCap newCap - 1


  • (e.hash & oldCap) == 0原位置,循环到链表尾端,赋值低位的元素loTail
  • (e.hash & oldCap) != 0 原位置再移动2次幂的位置,循环到链表尾端,赋值高位的元素hiTail


Node<K,V> loHead = null, loTail = null;  // 低位首尾节点
 Node<K,V> hiHead = null, hiTail = null;  // 高位首尾节点
 Node<K,V> next;
 // 遍历链表
 do {  
     next = e.next;                 
     //如果hash值和该原长度做与运算等于0,说明该元素可以放置在低位链表中。
     if ((e.hash & oldCap) == 0) {  
          // 如果没有尾,说明链表为空
          if (loTail == null) 
                  loHead = e; 
           // 如果有尾,那么链表不为空,把该元素挂到链表的最后。
           else
               loTail.next = e; 
         // 把尾节点设置为当前元素
           loTail = e; 
         }
         // 如果与运算结果不为0,说明hash值大于老数组长度(例如hash值为17)
         // 此时该元素应该放置到新数组的高位位置上
         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;
 }
 // 高位的元素组成的链表放置的位置只是在原有位置上偏移了老数组的长度个位置。
  if (hiTail != null) {  
       hiTail.next = null;
       newTab[j + oldCap] = hiHead;                  
  }
复制代码


JDK1.8对resize()扩容方法进行了优化,经过rehash之后,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。


是不是有点不明白呢?那我们来用图来解析一下:


结合e.hash & oldCapn取值判断是在高位还是在低位,即如图(a)表示扩容前的key1和key2两种key确定索引位置的示例,


19.png


图(b)表示扩容后key1和key2两种key确定索引,元素在重新计算hash之后,因为n变为2倍,那么n-1的mask范围在高位多1bit(红色),因此新的index就会发生这样的变化:


20.png


因此,我们在扩充HashMap的时候,不需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”,可以看看下图为16扩充为32的resize示意图:


21.png


在JDK1.7中rehash扩容的时候,旧链表迁移新链表的时候,如果在新表的数组索引位置相同的链表元素会倒置,但是在JDK1.8进行了优化,从上图可以看出,JDK1.8链表元素不会倒置。因此不会出现链表死循环的问题。


由于篇幅过长,将分成两篇来介绍,接下来内容看**《面试:为了进阿里,必须掌握HashMap源码原理和面试题(图解版二)》**


各位看官还可以吗?喜欢的话,动动手指点个再看💗呗!!谢谢支持!



目录
相关文章
|
26天前
|
存储 NoSQL 前端开发
美团面试:手机扫描PC二维码登录,底层原理和完整流程是什么?
45岁老架构师尼恩详细梳理了手机扫码登录的完整流程,帮助大家在面试中脱颖而出。该过程分为三个阶段:待扫描阶段、已扫描待确认阶段和已确认阶段。更多技术圣经系列PDF及详细内容,请关注【技术自由圈】获取。
|
2月前
|
Java Linux 调度
硬核揭秘:线程与进程的底层原理,面试高分必备!
嘿,大家好!我是小米,29岁的技术爱好者。今天来聊聊线程和进程的区别。进程是操作系统中运行的程序实例,有独立内存空间;线程是进程内的最小执行单元,共享内存。创建进程开销大但更安全,线程轻量高效但易引发数据竞争。面试时可强调:进程是资源分配单位,线程是CPU调度单位。根据不同场景选择合适的并发模型,如高并发用线程池。希望这篇文章能帮你更好地理解并回答面试中的相关问题,祝你早日拿下心仪的offer!
50 6
|
2月前
|
存储 算法 Java
面试必备!一文搞懂HashMap如何优雅处理哈希冲突
大家好,我是小米,一个积极的程序员。今天聊聊Java面试中的常见问题——“HashMap是怎么解决哈希冲突的?”。通过一个小故事,我们了解到HashMap使用链地址法(JDK 1.8前)和红黑树(JDK 1.8后)来处理哈希冲突。链地址法用链表存储冲突的元素,而红黑树在链表长度超过8时启用,提升查找效率。希望这个讲解能帮助你更好地理解HashMap的工作原理。欢迎留言讨论,关注我的公众号“软件求生”,获取更多技术干货!
57 3
|
3月前
|
Java 数据库连接 Maven
最新版 | 深入剖析SpringBoot3源码——分析自动装配原理(面试常考)
自动装配是现在面试中常考的一道面试题。本文基于最新的 SpringBoot 3.3.3 版本的源码来分析自动装配的原理,并在文未说明了SpringBoot2和SpringBoot3的自动装配源码中区别,以及面试回答的拿分核心话术。
最新版 | 深入剖析SpringBoot3源码——分析自动装配原理(面试常考)
|
3月前
|
存储 Java 索引
HashMap高频面试题,让你掌握青铜回答与王者级回答,你值得拥有
《HashMap高频面试题,让你掌握青铜回答与王者级回答,你值得拥有》介绍了HashMap的实现原理、数据存储、哈希冲突处理及扩容机制。文章通过对比JDK 1.7和JDK 1.8版本,详细解析了不同版本中的链表与红黑树结构、Entry与Node的区别,以及put()方法的具体实现。特别指出JDK 1.8引入红黑树优化查询性能,并采用尾插法避免多线程环境下的链表环问题。负载因子和扩容机制确保了HashMap在不同场景下的高效运行。
55 2
|
3月前
|
存储 缓存 算法
HashMap深度解析:从原理到实战
HashMap,作为Java集合框架中的一个核心组件,以其高效的键值对存储和检索机制,在软件开发中扮演着举足轻重的角色。作为一名资深的AI工程师,深入理解HashMap的原理、历史、业务场景以及实战应用,对于提升数据处理和算法实现的效率至关重要。本文将通过手绘结构图、流程图,结合Java代码示例,全方位解析HashMap,帮助读者从理论到实践全面掌握这一关键技术。
122 14
|
3月前
|
存储 SQL 关系型数据库
MySQL进阶突击系列(03) MySQL架构原理solo九魂17环连问 | 给大厂面试官的一封信
本文介绍了MySQL架构原理、存储引擎和索引的相关知识点,涵盖查询和更新SQL的执行过程、MySQL各组件的作用、存储引擎的类型及特性、索引的建立和使用原则,以及二叉树、平衡二叉树和B树的区别。通过这些内容,帮助读者深入了解MySQL的工作机制,提高数据库管理和优化能力。
|
4月前
|
安全 算法 网络协议
网易面试:说说 HTTPS 原理?HTTPS 如何保证 数据安全?
45岁老架构师尼恩在其读者交流群中分享了关于HTTP与HTTPS的深入解析,特别针对近期面试中常问的HTTPS相关问题进行了详细解答。文章首先回顾了HTTP的工作原理,指出了HTTP明文传输带来的三大风险:窃听、篡改和冒充。随后介绍了HTTPS如何通过结合非对称加密和对称加密来解决这些问题,确保数据传输的安全性。尼恩还详细解释了HTTPS的握手过程,包括如何通过CA数字证书验证服务器身份,防止中间人攻击。最后,尼恩强调了掌握这些核心技术的重要性,并推荐了自己的技术资料,帮助读者更好地准备面试,提高技术水平。
|
4月前
HashMap原理
1.HashMap在Jdk1.8以后是基于数组+链表+红黑树来实现的,特点是,key不能重复,可以为null,线程不安全 2.HashMap的扩容机制: HashMap的默认容量为16,默认的负载因子为0.75,当HashMap中元素个数超过容量乘以负载因子的个数时,就创建一个大小为前一次两倍的新数组,再将原来数组中的数据复制到新数组中。当数组长度到达64且链表长度大于8时,链表转为红黑树
46 2
|
4月前
|
存储 Java 索引
HashMap原理详解,包括底层原理
【11月更文挑战第14天】本文介绍了数据结构基础,重点讲解了哈希表的概念及其实现方式,包括数组与链表的特点及其在HashMap中的应用。详细分析了Java 7及Java 8版本中HashMap的底层存储结构,特别是Java 8中引入的红黑树优化。此外,还探讨了哈希函数的设计、哈希冲突的解决策略以及HashMap的重要方法实现原理,如put、get和remove方法,最后讨论了HashMap的容量与扩容机制。

热门文章

最新文章