一、HashMap 核心数据结构(JDK1.8)
- JDK1.8 中 HashMap 的底层数据结构是什么?各组成部分的作用是什么?链表与红黑树的转换条件是什么?
• 核心回答:JDK1.8 中 HashMap 底层采用 数组+链表+红黑树 的组合结构。其中,数组(又称"桶数组")是基础存储容器,每个位置对应一个"桶",通过索引能快速定位元素;链表用于解决"哈希冲突"------当多个元素的哈希值映射到同一数组索引时,用链表将这些元素串联起来,避免冲突元素无法存储;红黑树则是为了优化长链表的查询效率,防止链表过长导致查询时需要遍历大量节点,拖慢速度。
链表与红黑树的转换有明确条件:①当链表长度 超过8个节点,且桶数组的总长度 ≥64 时,链表会自动转为红黑树;②当红黑树中的节点个数 少于6个 时,红黑树会转回链表。
• 思考+例子:用"社区便利店的零食货架"理解最贴切。桶数组就是货架上的"格子",每个格子有编号(比如1-16号),对应数组的索引;零食就是HashMap中的元素,店员会根据零食的条形码(对应元素的key)算一个"格子编号"(哈希运算→数组索引),把零食放进对应格子。
如果1号格子已经摆满了(多个零食的条形码算出同一个索引,发生哈希冲突),后续的零食就会用小挂钩串起来,挂在1号格子的侧面------这串零食就是"链表"。但如果挂钩上挂了9包零食(超过8个),而且便利店的货架总格子数有64个(不是16个,说明货架足够大,有优化必要),店员就会把这串挂钩换成一个"分层小货架"(红黑树):小货架分3层,每层放3包零食,顾客找零食时按"从上到下、从左到右"的顺序,不用像翻挂钩那样挨个捋,速度快很多。反之,如果小货架上的零食被买走只剩5包(少于6个),店员又会把小货架拆成挂钩(链表),因为零食少的时候,挂钩拿取更方便,不用特意维护分层结构。
这个设计的核心是"平衡效率与复杂度":链表适合少量冲突的场景,结构简单、不用额外维护;红黑树适合大量冲突的场景,查询效率高(时间复杂度O(logn),远优于链表的O(n)),但维护起来稍复杂。用"8"和"6"作为转换阈值,既能避免短链表用红黑树造成的资源浪费,也能避免长链表查询慢的问题,还能防止频繁转换(比如刚转红黑树又转回链表)。
二、红黑树相关问题
- 红黑树的核心规则是什么?为什么 HashMap 要用红黑树,而不是普通二叉树或平衡二叉树?
• 核心回答:红黑树是一种"自平衡的二叉查找树",为了保证查询效率,它有5条核心规则:①每个节点要么是红色,要么是黑色(没有其他颜色);②根节点必须是黑色;③所有"叶子节点"(实际是指向null的虚拟节点,比如树最底层的空位置)都是黑色;④红色节点的两个子节点必须是黑色(不能出现两个红色节点连续的情况);⑤从任意节点到其所有叶子节点的路径中,黑色节点的数量相同(称为"黑高一致")。
HashMap选择红黑树,是因为普通二叉树和平衡二叉树都有明显缺陷:①普通二叉树最坏会退化成"链表"------比如按1、2、3、4、5的顺序插入节点,树会变成一条直线,查询时需要从根节点遍历到最后一个节点,时间复杂度从O(logn)变成O(n),和链表一样慢;②平衡二叉树(如AVL树)的平衡要求太严格(左右子树高度差不能超过1),插入或删除节点时需要频繁旋转调整,比如插入一个节点后,可能要连续旋转3-4次才能保持平衡,维护成本高;红黑树的平衡要求宽松,只需满足5条规则,插入/删除时旋转次数少,既能保证查询效率(接近平衡二叉树),又能降低维护成本。
• 思考+例子:用"班级座位按身高排列"的场景理解三种树的差异。假设班级座位按"身高从矮到高"排列,每个学生就是一个节点,身高就是节点的值。
普通二叉树:如果按1.5m、1.6m、1.7m、1.8m、1.9m的顺序安排座位,座位会变成"1.5m在最左,1.6m坐在1.5m的右边,1.7m坐在1.6m的右边......"的一条直线,老师要找1.9m的学生,需要从1.5m开始,挨个问到最后,和叫学号排队一样慢。
平衡二叉树(AVL树):要求左右两侧的座位高度差不能超过1。比如先安排1.7m的学生坐中间(根节点),1.6m坐左边,1.8m坐右边,1.5m坐1.6m的左边------此时左边高度2,右边高度1,符合要求;但如果再安排1.9m坐1.8m的右边,右边高度变成2,还是符合;如果安排2.0m坐1.9m的右边,右边高度变成3,左边高度2,就必须调整座位:把1.8m移到1.7m的右边、1.9m的左边,2.0m坐1.9m的右边,1.7m坐1.8m的左边------调整次数多,像每次加人都要重新排座位,麻烦。
红黑树:就像"宽松版的平衡座位表",只要满足"没有两个戴红领巾的学生连坐、每条过道的黑衣服学生数量相同"就行(红色=戴红领巾,黑色=穿黑衣服)。比如安排1.5m(戴红领巾)坐1.6m(穿黑衣服)的左边,1.7m(戴红领巾)坐1.6m的右边,1.6m是中间(根节点,穿黑衣服)------没有连续戴红领巾的学生,且从1.6m到左右两边的"空座位"(叶子节点),黑衣服学生都只有1个(符合规则)。即使再安排1.8m(戴红领巾)坐1.7m的右边,也不用调整座位------虽然右边人数比左边多,但符合红黑树规则,不用像平衡二叉树那样频繁挪位置。
HashMap用红黑树,就是想在"查询快"和"维护简单"之间找平衡:既不想像普通二叉树那样查得慢,也不想像平衡二叉树那样维护起来费劲儿。
- 红黑树是通过什么方式保持平衡的?这两种方式分别起到什么作用?
• 核心回答:红黑树通过 旋转 和 染色 两种方式配合,保持自身的平衡,避免某一侧子树过度倾斜。①旋转:分为左旋和右旋两种,作用是调整节点的位置关系,改变子树的高度------比如某一侧子树太长,通过旋转把长的一侧"掰短",短的一侧"拉长",让整体更均匀;②染色:改变节点的颜色(红变黑或黑变红),作用是修复违反红黑树规则的情况(比如出现连续红节点、黑高不一致),减少旋转的次数------很多时候不用动节点位置,只改颜色就能符合规则,降低维护成本。
• 思考+例子:用"排队买奶茶"的场景理解这两种方式。假设排队的人按"身高从矮到高"排列(对应红黑树的节点值有序),每个人要么戴红领巾(红色节点),要么穿黑衣服(黑色节点),规则是"不能有两个戴红领巾的人连排""从队头到队尾的每条分支,穿黑衣服的人数相同"。
旋转:比如队伍中,A(戴红领巾,1.6m)站在B(穿黑衣服,1.7m)的右边,C(戴红领巾,1.8m)又站在A的右边------此时右边队伍比左边长,倾斜严重。这时候店员会让A"左旋":A走到B的左边,B变成A的右边,C还是A的右边------队伍从"B→A→C"变成"A→B,A→C",右边的长度缩短,左右更均匀。右旋则相反,比如A站在B的左边,C站在A的左边,左边太长,就让A右旋到B的右边,调整队伍长度。旋转的核心是"动位置",快速解决子树倾斜问题,就像把长的队伍"掰"到短的一侧。
染色:如果新加入的D(戴红领巾,1.75m)站在C的右边,此时A(红)→C(红)→D(红)出现连续戴红领巾的人,违反规则。这时候不用调整位置(旋转),而是让C换成黑衣服,A也换成黑衣服------变成A(黑)→C(黑)→D(红),既没有连续戴红领巾的人,穿黑衣服的人数也一致。染色的核心是"改标签",用简单的颜色变化修复规则,避免频繁移动位置(旋转),就像不用让排队的人换位置,只换衣服颜色就能符合要求,节省时间。
实际场景中,红黑树会先尝试染色修复,如果染色解决不了(比如染色后黑高不一致),再进行旋转------就像店员先让顾客换衣服颜色,换颜色不行再调整排队位置,尽量用简单的方式保持队伍平衡。
三、HashMap 的核心流程(put/查找)
- HashMap 的 put 方法(存储元素)流程是怎样的?请结合具体场景说明。
• 核心回答:HashMap 存储元素(put方法)的流程可概括为"算哈希→判数组→算下标→插节点→转结构→判扩容"6步,具体如下:①对元素的key进行"哈希扰动"(混合key的hashCode高16位和低16位),得到更随机的最终哈希值;②判断桶数组是否为空或长度为0,若是则先执行扩容(创建初始容量的数组,默认16);③用"哈希值 & (数组长度-1)"计算元素的数组下标,确定元素要存的"桶位置";④根据下标位置的节点类型插入元素:若位置为空,直接插入新节点;若为红黑树节点,按红黑树的插入规则把节点放进树中;若为链表节点,把新节点挂在链表的末尾;⑤插入后,检查链表长度:若链表长度≥8且数组长度≥64,将链表转为红黑树;⑥最后判断当前元素总数是否超过"阈值"(数组长度×负载因子,默认负载因子0.75),若是则执行扩容(数组长度翻倍)。
• 思考+例子:用"公司存储员工档案"的场景理解,比如要把"工号=2024001,档案=张三-技术部"的员工档案(key=工号,value=档案)存入HashMap。
-
哈希扰动:先获取工号2024001的hashCode(假设是87654321),然后将这个值右移16位(87654321 >>>16 = 1335),再和原hashCode做异或运算(87654321 ^ 1335 = 87653002),得到最终哈希值87653002。这一步是为了让哈希值更随机,避免不同工号算出同一个下标。
-
判数组:如果HashMap刚创建,桶数组还是空的,就先扩容到初始容量16(创建16个"档案格"的数组)。
-
算下标:数组长度16,用"87653002 & (16-1)"=87653002 &15=10(因为15是二进制1111,&运算取哈希值的最后4位),得到下标10,对应数组的第10个档案格。
-
插节点:①如果第10个档案格是空的,直接把张三的档案放进去;②如果格子里是红黑树节点(比如之前已经存了很多档案,链表转成了红黑树),就按红黑树的规则把张三的档案插入树中;③如果格子里是链表节点(比如已经存了3份档案,串成链表),就把张三的档案挂在链表的末尾。
-
转结构:如果插入后链表长度变成8,而且数组长度是16(不够64),不会转红黑树;如果数组长度已经扩容到64,就把这条8个节点的链表转为红黑树,方便后续查档案。
-
判扩容:假设当前数组长度16,阈值=16×0.75=12。如果插入张三的档案后,总档案数变成13,超过阈值12,就把数组扩容到32(16×2),并把原来的12份档案转移到新数组中,腾出空间存更多档案。
-
HashMap 的查找元素流程是怎样的?和 put 流程有什么关联?
• 核心回答:HashMap 查找元素的流程是 put 流程的"反向操作",步骤更简洁:①对要查找的key进行哈希扰动,得到与存储时完全相同的最终哈希值;②用"哈希值 & (数组长度-1)"计算下标,定位到桶数组的对应"桶位置";③根据该位置的节点类型查找元素:若节点的key与查找key一致,直接返回该节点的value;若为红黑树节点,在红黑树中按"二叉查找"规则(比当前节点小找左子树,比当前节点大找右子树)查找;若为链表节点,遍历链表逐一对比key,找到匹配节点后返回value;④若遍历结束未找到匹配key,返回null(表示元素不存在)。
两者的核心关联是"复用哈希逻辑"------查找时的哈希扰动、下标计算方式,与put时完全一致,确保同一个key的存储位置和查找位置相同,避免"存得进去、找不到"的问题。
• 思考+例子:还是用"公司查找员工档案"的场景,比如要查找"工号=2024001"对应的员工档案。
-
哈希扰动:和存档案时一样,对工号2024001计算hashCode=87654321,右移16位得1335,异或后得最终哈希值87653002------确保和存储时的哈希值完全相同,不会算错位置。
-
算下标:如果当前数组长度还是16,用87653002 &15=10,定位到数组第10个档案格------和存档案时的位置完全一致,不用乱翻其他格子。
-
找节点:①如果第10个档案格的节点key就是2024001,直接取出对应的"张三-技术部"档案;②如果格子里是红黑树,就从树的根节点开始比:根节点key是2024005(比2024001大),找左子树;左子树节点key是2024001,匹配成功,取出档案;③如果格子里是链表(比如有4份档案,key分别是2024005、2024003、2024001、2024007),就从链表头开始,逐个对比工号,找到2024001对应的档案;④如果第10个档案格是空的,或链表/红黑树里没有2024001的key,返回null(表示没找到该员工的档案)。
关联点:就像你把钥匙放在家里的"抽屉10"(put流程),找钥匙时也必须去"抽屉10"(查找流程),而且放钥匙和找钥匙时,判断"抽屉10"的方式完全一样------如果放的时候按"钥匙上的编号算抽屉",找的时候也按同样的编号规则,才能快速找到,不会浪费时间。
四、HashMap 的哈希设计(扰动函数/容量)
- HashMap 的哈希/扰动函数是怎么设计的?为什么要这样设计?
• 核心回答:HashMap 的哈希/扰动函数设计逻辑是"混合key的hashCode高16位和低16位",具体逻辑为:先判断key是否为null,若为null则哈希值为0;若不为null,先获取key的hashCode(一个32位的int值),然后将这个hashCode右移16位(得到高16位的数值),最后让右移后的高16位与原hashCode的低16位做"异或"运算,得到最终的哈希值。
这样设计的核心目的是"让哈希值的低16位带上高16位的信息",增加低16位的随机性------因为HashMap计算下标时,只用哈希值的低几位(由数组容量决定,比如容量16只用低4位),若低几位重复,就会导致哈希冲突;而混合高16位后,低几位的随机性更强,重复概率更低,从而减少冲突。
• 思考+例子:用"小区停车费缴费窗口分配"的场景理解。假设小区有16个缴费窗口(数组容量16),窗口编号0-15(对应数组下标),业主的停车编号是32位的数字(比如"1001202405010001",对应key的hashCode),分配窗口时只能看停车编号的最后4位(因为数组容量16是2^4,计算下标时用哈希值的低4位)。
如果两个业主的停车编号分别是"1001202405010010"(最后4位0010)和"1002202405010010"(最后4位0010),最后4位相同,就会分到同一个窗口(哈希冲突),排队时间变长。但如果用扰动函数:把第一个业主停车编号的前16位"10012024"(高16位)和后16位"05010010"(低16位)做异或,得到新的停车编号后4位可能变成"0110";第二个业主停车编号的前16位"10022024"和后16位"05010010"做异或,新的后4位可能变成"1010"------原本重复的后4位,混合高16位后变得不同,就不会分到同一个窗口,排队时间缩短。
简单说,32位的hashCode太长,而数组容量小,只能用低几位算下标。扰动函数就像"把停车编号的前半段和后半段混在一起",让低几位不仅包含后半段的信息,还带上前半段的信息,变得更随机------就像调果汁时把苹果汁(高16位)和橙汁(低16位)混在一起,避免只喝到单一味道(某几个窗口排队多)。
- 为什么哈希/扰动函数能降低哈希碰撞的概率?请结合数组容量的特点说明。
• 核心回答:哈希碰撞的本质是"数组容量小,计算下标时只能用哈希值的低几位,导致不同哈希值的低几位重复"。扰动函数能降低碰撞,关键和HashMap数组容量的特点(始终是2的倍数)密切相关:①数组容量是2的倍数时,计算下标用"哈希值 & (数组长度-1)",本质是"取哈希值的低k位"(k是数组容量的二进制位数,比如容量16是2^4,取低4位;容量32是2^5,取低5位);②若不做扰动,不同key的hashCode可能"高16位不同,但低k位相同"------比如两个hashCode分别是"12345678"(低4位0110)和"98765432"(低4位0110),低4位相同,会算到同一个下标(碰撞);③扰动后,高16位与低16位异或,低k位会带上高16位的信息------原本低k位相同的哈希值,混合后低k位可能变得不同,从而减少碰撞。
• 思考+例子:用"学校运动会分组"的场景理解。假设运动会有16个小组(数组容量16,2^4),小组编号0-15(对应数组下标),学生的参赛编号是32位的数字(对应hashCode),分组时看参赛编号的最后4位(低4位,因为16-1=15是二进制1111,&运算取低4位)。
如果两个学生的参赛编号分别是"2024050100010110"(低4位0110)和"2024050100020110"(低4位0110),低4位相同,会分到同一个小组(碰撞),导致小组人数过多。此时扰动函数发挥作用:第一个学生的hashCode=2024050100010110,右移16位得20240501,与原hashCode异或后,新的低4位可能变成"1010";第二个学生的hashCode=2024050100020110,右移16位得20240501,与原hashCode异或后,新的低4位可能变成"1100"------原本低4位相同的两个编号,扰动后低4位不同,就会分到不同小组,小组人数更均匀。
如果数组容量不是2的倍数,比如17,"数组长度-1"是16(二进制10000),&运算取低5位,但低5位中最高位始终是0,相当于只取低4位,反而浪费位数,还不如2的倍数能充分利用低k位,配合扰动函数减少碰撞。
- 为什么 HashMap 的数组容量必须是 2 的倍数?这对下标计算和扩容有什么好处?
• 核心回答:HashMap 强制数组容量为2的倍数,主要有两个关键好处:①简化下标计算,提升效率:当容量是2的倍数时,"数组长度-1"的二进制是"全1"(比如容量16是10000,16-1=15是1111;容量32是100000,32-1=31是11111),此时"哈希值 & (数组长度-1)"的结果,与"哈希值 % 数组长度"完全相同,但位运算(&)的执行速度远快于取余运算(%)------比如计算"123 & 15"和"123 % 16",结果都是11,但&运算不用做除法,速度更快;②方便扩容时元素转移:扩容后容量仍是2的倍数,元素的新下标要么与原下标相同,要么是原下标+原容量,无需重新计算哈希值,只需判断哈希值的某一位(新增的高位)是0还是1,就能快速确定新下标,大幅提升扩容效率。
• 思考+例子:
好处1:简化下标计算------比如要给参赛编号=123的学生分组,数组容量16(2^4)。用"123 & (16-1)"=123 &15=11(123的二进制最后4位是1011),和"123 %16=11"结果一样,但&运算比%运算快得多------就像算"100颗糖分给8个小朋友,每人分12颗,剩4颗",直接看100的最后3位(8是2^3,最后3位是100),余数就是4,不用做除法,速度快。如果容量是17(非2的倍数),17-1=16(二进制10000),"123 &16=0",而"123 %17=123-17×7=123-119=4",结果不同,还得用%运算,效率低。
好处2:方便扩容转移------比如数组从16(原容量)扩容到32(新容量),原下标是11的学生(哈希值&15=11),新下标要么是11,要么是11+16=27。具体怎么判断?因为新容量32是2^5,计算下标时取哈希值的低5位,比原容量多了1位(第5位)。如果哈希值的第5位是0,新下标=11(低5位是01011);如果是1,新下标=27(低5位是11011)。比如学生A的哈希值=27,原容量16时&15=11,扩容后&31=27(第5位是1),新下标27;学生B的哈希值=11,原下标11,扩容后&31=11(第5位是0),新下标11。
用"班级座位扩容"理解:原班级有16个座位,扩容到32个座位,原11号座位的学生,新座位要么是11号(第5位是0),要么是27号(11+16=27,第5位是1)。老师不用重新算每个学生的座位号,只需看学生编号的某一位是0还是1,就能快速安排新座位,不用挨个重新计算,效率极高。
- 如果初始化 HashMap 时传入的容量是 17(非 2 的倍数),HashMap 会怎么处理?背后的逻辑是什么?
• 核心回答:如果初始化 HashMap 时传入的容量是17(非2的倍数),HashMap 会自动"向上寻找最近的2的倍数",作为实际的数组容量------17对应的实际容量是32。背后的逻辑由专门的方法实现,具体步骤:①先将传入的容量减1(17-1=16);②通过多次右移和异或运算,将减1后的数值的二进制位全部置为1(16→16 | 8=24→24 | 4=28→28 | 2=30→30 | 1=31);③最后将结果加1(31+1=32),得到最小的、大于传入容量的2的倍数。
这样处理的目的是保证 HashMap 的数组容量始终是2的倍数,从而复用"位运算算下标""高效扩容转移"等核心优势,避免因容量非2的倍数导致的效率问题和哈希碰撞增多。
• 思考+例子:用"订外卖餐盒"的场景理解。你订外卖时,说"要装17份盒饭的餐盒"(传入容量17),但外卖店的餐盒只有2的倍数规格(2、4、8、16、32、64...)------16份装的餐盒放不下17份,下一个规格就是32份装的餐盒(最近的2的倍数),所以外卖店会给你32份装的餐盒(实际容量32),确保能装下所有盒饭。
背后的逻辑就像"找最小的能装下17份盒饭的2的倍数餐盒":①先减1(17-1=16)------这一步是为了避免传入的容量本身是2的倍数时,多扩一倍(比如传入16,减1=15,最后加1=16,正确;如果不减1,16会变成32,导致餐盒太大,浪费空间);②把16的二进制位全部置为1:16是二进制10000,右移1位得8(1000),16和8做"或"运算得24(11000);右移2位得4(100),24和4做"或"运算得28(11100);右移4位得2(10),28和2做"或"运算得30(11110);右移8位得1(1),30和1做"或"运算得31(11111)------此时二进制全是1,相当于把餐盒的"容量上限"拉到最近的2的倍数减1;③加1得32(100000),就是最小的2的倍数餐盒。
如果传入的容量是18,减1=17(二进制10001),经过右移和"或"运算后变成31(11111),加1=32;传入20,减1=19(二进制10011),最终也变成31,加1=32------无论传入什么非2的倍数容量,都会向上找到最近的2的倍数,确保 HashMap 的核心功能高效运行。