HashMap底层揭秘:数组、链表、红黑树的"合租"故事

294

说起HashMap,不少小伙伴第一反应是"能存键值对的容器",但它底层可不是单一结构在"单打独斗",而是数组、链表、红黑树组成的"合租天团"。今天就扒一扒这个天团是怎么分工协作,把数据存得又快又稳的——毕竟好的合租关系,核心就是"不挤、不吵、找东西快"。

画个重点:HashMap的底层逻辑,本质是"先按地址分房,人少凑活住,人多就规范排序"。对应的就是:数组负责划分基础区域(分房),链表负责解决区域冲突(凑活住),红黑树负责优化拥挤区域(规范排序)。咱们逐个介绍这三位"租客"。

1号租客:数组——底层的"承重墙"

数组在HashMap里,就像小区里的一栋栋单元楼,每栋楼有自己的编号(专业名叫"索引"),每栋楼里能住数据(键值对)。为什么选数组当承重墙?因为数组的优势太明显了:按编号找楼,速度是O(1),相当于你知道朋友住3号楼,直接过去就行,不用挨个问。

但问题也很直接:数组的"房间"是固定的,而且每间房理论上只能住一个数据。如果两个数据非要住同一间房(专业名叫"哈希冲突",比如两个键计算出的索引一样),总不能把人家赶走吧?这时候就需要2号租客救场了。

2号租客:链表——冲突时的"上下铺"

链表就是来解决"一房多客"问题的,它像个可伸缩的上下铺,一旦某间房(数组索引)被占了,新来的 data 就直接"挂"在后面,形成一条链。比如3号楼本来住了数据A,后来数据B也算出索引是3,就跟A说"兄弟挤挤",挂在A后面;数据C再来,就挂在B后面,以此类推。

这个设计确实解决了冲突,但缺点也很扎心:链表是"一根筋"的结构,找东西只能从头挨个问(遍历)。如果3号楼的上下铺挂了几十上百个数据,你要找最末尾的那个,就得从A问到Z,速度直接降到O(n),相当于在拥挤的出租屋里找东西,翻来翻去半天找不到,效率低得离谱。

所以链表只能解决"有没有地方住"的问题,解决不了"住得舒服、找得快"的问题。这时候,3号租客——红黑树就该登场了。

3号租客:红黑树——拥挤时的"电梯房改造"

红黑树的作用,就是把"挤爆了的上下铺"改造成"电梯房"。HashMap里有个规矩:当某条链表的长度超过8(默认阈值),而且数组的长度大于64时,这根"乱糟糟的链子"就会被自动改造成红黑树。

红黑树是一种平衡二叉搜索树,自带"排序+快速查找"buff,找数据的速度能降到O(logn)。相当于原来的上下铺被拆了,改成了有排序规则的电梯房,每个数据都有固定的位置,你要找某个数据,不用挨个问,直接按规则找楼层(二分查找思路),效率直接起飞。而且红黑树还会自动保持平衡,不会出现"一边楼很高,一边楼很矮"的情况,保证每个数据的查找速度都稳定。

天团协作全流程:用一个例子讲明白

咱们用一个生活化的例子串一遍流程,你就彻底懂了:

  1. 你要存一个键值对(“姓名”:“张三”),HashMap先给"姓名"算个哈希值,再通过哈希值算出对应的数组索引,比如是5——相当于张三被分配到5号楼。

  2. 如果5号楼没人住,张三直接住进去(数组元素直接存这个键值对)。

  3. 再存一个键值对(“昵称”:“张三”),计算后发现索引也是5——哈希冲突了,这时候就用链表,把"昵称:张三"挂在"姓名:张三"后面,两人凑活住。

  4. 接着又存了好几个键值对,都算出索引是5,链表长度涨到了9——超过阈值8,而且数组长度也够64,这时候HashMap就把这条链表改成红黑树,从"上下铺"升级成"电梯房"。

  5. 后来你删除了几个5号楼的数据,红黑树的节点数少于6,HashMap又会把红黑树改回链表——毕竟人少的时候,上下铺比电梯房更省空间,没必要搞这么复杂。

整个过程就像小区的动态管理:人少的时候怎么方便怎么来,人多了就规范管理,人少了再简化,主打一个"灵活高效"。

可视化一下:HashMap底层结构示意图

光说不练假把式,咱们用图表直观看看这个结构(数组为主体,部分索引挂链表,长链表转红黑树):

HashMap底层结构

数组(索引0)

数据A1

数据B1

数据B2

数据B3

数组(索引1)

数据C1

数据C2

数据C3

数据C4

数据C5

数据C6

数据C7

数据C8

数据C9

数组(索引2)

红黑树根节点D1

左子节点D2

右子节点D3

左子节点D4

右子节点D5

数组(索引3)

数据E1

数组(索引4)

红色:链表(长度>8前)

绿色:红黑树(链表长度>8后)

图中索引2对应的是链表(长度9,还没转树),索引3对应的是红黑树(链表长度超过8后转换),其他索引是单个数据,清晰体现了三者的共存关系。

补充知识点:不同JDK版本的HashMap差异

咱们前面聊的"数组+链表+红黑树"组合,其实是JDK 1.8及之后的"升级版合租方案"。在这之前,JDK 1.7的HashMap可是个"老古董",只靠"数组+链表"撑场面,就像个没有电梯房的老旧小区,住的人多了全是问题。下面咱们用表格直观看看核心差异,顺便唠唠这些差异背后的"优化思路":

对比维度 JDK 1.7 JDK 1.8及之后
底层结构 数组 + 链表(纯"上下铺"小区,没电梯) 数组 + 链表 / 红黑树(有"电梯房"改造方案)
哈希冲突解决 仅靠链表,冲突多了链表越长,找东西越慢(O(n)) 链表短的时候凑活住,超过8个节点自动转红黑树(O(logn)),人少了再拆回链表
扩容逻辑 先扩容再插入新数据,容易出现"无效扩容"(比如插完就不用了) 先插入数据再判断是否扩容,更合理高效,减少无效操作
并发问题 并发扩容时极易出现"链表环",就像上下铺连成了圈,找东西直接死循环,CPU飙升到100% 优化了扩容时的链表迁移逻辑,解决了链表环问题,但仍线程不安全(想线程安全还是得用ConcurrentHashMap)
插入方式 链表采用"头插法"(新来的人直接占第一个铺,把老人挤到后面) 链表采用"尾插法"(新来的人乖乖排到队尾,秩序井然)

这里重点说两个有意思的点:一是JDK 1.7的"头插法",为啥要改?因为头插法在扩容时容易搞反链表顺序,多线程情况下直接形成死循环,就像大家排队时有人插队还把队伍搅成了圆圈,谁也出不去。JDK 1.8改成尾插法,彻底解决了这个问题。二是红黑树的引入,不是为了"炫技",而是真的解决了JDK 1.7的"性能瓶颈"——当链表长度超过8,JDK 1.7找数据要从头问到尾,而JDK 1.8转成红黑树后,相当于有了电梯和索引,直接定位楼层,效率天差地别。

另外补充一句,不管哪个版本的HashMap,都有个共同特点:初始容量是16,扩容时都是翻倍(新容量=旧容量×2),而且容量必须是2的幂次方。这就像小区规划,每栋楼的编号都是2的倍数,方便快速找房,本质是为了通过"位运算"快速计算数据该住哪栋楼,提升定位效率。

最后总结:三个结构的核心使命

数组:负责"快速定位",是整个结构的基础,保证查找的初始效率;
链表:负责"解决冲突",用灵活的结构容纳同一索引的数据,避免数据溢出;
红黑树:负责"优化效率",在数据量大的时候提升查找、删除、修改的速度,避免链表的低效遍历。

这三者的组合,让HashMap在"空间"和"时间"之间找到了一个绝佳的平衡点——既不会像纯数组那样浪费空间,也不会像纯链表那样效率低下,这也是它能成为Java里最常用容器之一的核心原因。

目录