发布时间:2026/8/29 3:56:39
HashMap 进阶篇:红黑树、多线程坑和 equals/hashCode 的那些事 上篇讲了 HashMap 的核心机制put、get、扩容、2 的 n 次方这些。当时留了个尾巴说还有几个进阶话题没聊完。这篇就来填那个坑。其实写完上篇之后我又翻了翻源码发现有几个细节我之前也是一知半解的。比如为什么选红黑树而不是平衡二叉树HashMap 在多线程下到底会出什么问题equals 和 hashCode 到底怎么重写才能不出 bug这些问题的共同点是平时写代码可能不太会碰到但面试必问而且理解了之后你对 HashMap 的认识会上一个台阶。为什么是红黑树不是平衡二叉树上篇提到 JDK 1.8 之后HashMap 在链表太长的时候会转成红黑树。但我当时就有个疑问红黑树是一种平衡二叉树那为啥不用更平衡的 AVL 树呢先说结论不是 AVL 树不好而是对 HashMap 的场景来说红黑树更合适。AVL 树的严格和平常觉得不划算AVL 树对平衡的要求很严格任何节点的左右子树高度差不能超过 1。这个要求保证了树的查找效率非常高最坏情况也是 O(log n)。但代价是每次插入、删除都可能破坏这个平衡一旦破坏了就要通过左旋、右旋、双旋来修复而且有时候要一路旋转到根节点。红黑树就不一样。它不要求严格的高度平衡只要求黑色高度平衡最长路径不超过最短路径的 2 倍就行了。所以它的旋转次数比 AVL 树少得多。我当时想那 AVL 树查找更快啊为什么不用后来想明白了HashMap 不是纯树形结构。它的主体是数组加链表树只是哈希冲突严重后的补救措施。而且链表转树的阈值是 8也就是说一个桶里的元素得超过 8 个才会触发树化。这是一个低频事件。你想想看一个低频发生的事发生后你还要频繁地做插入删除操作如果每次插入删除都要触发大量旋转那就不划算了。红黑树的旋转次数少综合下来更划算。再一个HashMap 里对一棵树的操作不是只读不改的。你往里 put 的时候要插入节点往里 get 的时候要查找节点往里 remove 的时候要删除节点增删改查都有。AVL 树的查找是快一点但插入删除的代价太高了。红黑树的增删改查都是 O(log n)虽然查找比 AVL 慢一点点但增删快得多。在 HashMap 的场景下这个取舍是对的。我当时自己画了一个对比表AVL 树红黑树平衡程度严格平衡高度差≤1弱平衡最长≤2×最短查找快严格平衡稍慢一点点插入/删除旋转频繁开销大旋转少开销小适用场景查找多、增删少的场景增删改查均衡的场景HashMap 显然是增删改查都有的场景所以选了红黑树。哈希冲突不只有一种解法上篇主要讲了拉链法链表红黑树这种解决哈希冲突的方式。但其实哈希冲突的解决办法不止这一种。我当初学的时候只知道链表法后来发现还有好几种。1. 拉链法就是 HashMap 用的这种冲突的元素用链表串起来挂在同一个桶里。JDK 1.8 之后还加了红黑树做优化链表太长就升级成树。这个方法优点是实现简单而且桶可以无限扩容一个桶里的链表理论上可以无限长虽然实际不会。缺点是每个节点要额外存一个 next 指针空间上多一点点开销。2. 开放寻址法这个思路不一样如果算出来的位置被人占了就在附近再找一个空位放进去。找空位的方式有好几种线性探测被占了就往下一位放再被占就继续往下直到找到空位。优点是简单缺点是容易产生聚集——一旦有一个地方堵了后面一串都会被影响。二次探测不是挨个往后找而是按 1²、2²、3² 的步长跳着找。比线性探测分散一些聚集问题好一点。双重散列用两个哈希函数第一个算出位置被占了就换第二个哈希函数再算。这个方法最均匀但也最复杂。我当时听说 ThreadLocalMap 用的就是开放寻址法。它的结构是一个数组没有链表。如果算出来位置被人占了就用线性探测往后找空位。3. 再哈希法这个更好理解准备多个哈希函数第一个冲突了就换第二个算还冲突就换第三个直到找到空位为止。4. 扩容冲突严重了直接扩数组把元素重新分散开。HashMap 就是用的这种思路链表太长先试着扩容扩完还长再转树。所以 HashMap 其实组合了好几种策略拉链法为基础 尾部插入 树化升级 扩容降级。这是看了源码之后才意识到的——它没有只靠一种方法解决问题而是打了一套组合拳。为什么 String 是最适合做 Key 的类型我刚学的时候看到别人用 HashMap 基本上都是用 String 做 key。我心想为什么大家都用 String用 Integer 不行吗用自定义对象行不行答案是当然行但 String 是最安全的。原因在于 String 的不可变性。String 对象一旦创建就不能被修改。“hello永远都是hello”不会变成world。这就保证了 String 的 hashCode 值也是固定的只要内容一样hashCode 就一样。如果用一个可变对象做 key会出什么问题我举个例子。假设我写了一个类classUser{Stringname;intage;// 省略 getter/setter}然后我用这个 User 对象做 key存到 HashMap 里MapUser,StringmapnewHashMap();UserusernewUser(张三,20);map.put(user,一些数据);这个时候一切都好。但如果后面我改了 user 的年龄user.setAge(25);问题就来了——user 的 hashCode 可能变了如果 hashCode 里用到了 age。但它在 HashMap 里的位置还是根据旧的 hashCode 算出来的。这个时候用map.get(user)去获取算出来的新位置和存数据时的旧位置不一样就找不到了。更糟的情况是你修改了 key 之后原来的那个桶里还挂着这个 entry但它已经失效了——桶还在数据还在但你已经取不出来了。这就造成了内存泄漏。String 不会出现这个问题因为它不可变。你拿到一个 String 对象不用担心它哪天突然变了。所以如果你问我最安全的 key 是什么类型我会说是不可变类型。String、Integer、Long 这些都行。但非要用自定义对象做 key那就必须保证它是不可变的——所有字段都是 final 的不提供修改方法。equals 和 hashCode一个面试必问的话题这个话题我一直觉得有点虚平时写代码很少会自己去重写 equals 和 hashCodeIDE 会自动生成。但面试就是爱问。不过后来有一次我真的踩坑了才意识到这里面确实有门道。它俩的约定Java 对 equals 和 hashCode 有一个约定如果两个对象 equals 相等它们的 hashCode 一定相等。如果两个对象 hashCode 相等equals 不一定相等。这就是哈希冲突第二条好理解。第一条是重点——如果你重写了 equals 但没有重写 hashCode就可能打破这个约定。我当时试过一个很简单的例子。先写一个 Student 类只重写 equals不重写 hashCodeclassStudent{Stringid;Stringname;Student(Stringid,Stringname){this.idid;this.namename;}Overridepublicbooleanequals(Objecto){if(thiso)returntrue;if(onull||getClass()!o.getClass())returnfalse;Students(Student)o;returnid.equals(s.id);}}然后我往 HashSet 里放两个 “学号相同” 的对象SetStudentsetnewHashSet();set.add(newStudent(001,张三));set.add(newStudent(001,李四));System.out.println(set.size());你猜输出是 1 还是 2我当时以为会是 1——因为 equals 认为学号相同就是同一个学生。但实际输出是2。因为 HashSet 的底层是 HashMap它先比较 hashCode 再比较 equals。两个 Student 对象没有重写 hashCode所以用的是 Object 的默认 hashCode——每个对象的内存地址不同hashCode 自然不同。HashSet 把它们放到了不同的桶里根本没机会去调 equals。正确做法重写 equals 的时候一定要同时重写 hashCode。而且 hashCode 的计算逻辑要跟 equals 里用到的字段保持一致。equals 里根据 id 判断相等那 hashCode 里也要用 id 来计算OverridepublicinthashCode(){returnid.hashCode();}这样学号相同的两个 Student 对象hashCode 就一样了。HashSet 会把它们放到同一个桶里然后通过 equals 发现是同一个学生就不会重复存储。IDE 自动生成的重写一般都是正确的——它会把 equals 里比较的所有字段都拿去算 hashCode。但核心是你要理解它为什么要这么做保证 equals 相等的对象hashCode 也相等这样才能在哈希表里正常工作。重写不当的后果重写 equals 不重写 hashCode 会导致集合行为异常——两个明明 equals 相等的对象却被认为不相等地存到了 HashSet 或 HashMap 里。但如果反过来——hashCode 写得太粗糙比如所有对象都返回同一个 hashCode 值OverridepublicinthashCode(){return1;}这不会影响正确性equals 仍然能正确区分但会导致所有对象都挤到同一个桶里HashMap 退化成链表性能从 O(1) 掉到 O(n)。这就叫哈希冲突严重。所以好的 hashCode 要尽量让不同的对象返回不同的值分布越均匀越好。IDE 生成的 hashCode 方法用的 31 这个质数就是出于这个目的——31 * 某个字段 另一个字段能产生比较均匀的分布。get 方法调用真的安全吗上篇讲了 get 方法的工作流程但有一个问题我当时没有仔细想调用 get 方法会不会抛异常先说说最常见的误解。有人觉得用 null 调 get 会抛空指针但其实不会。HashMap 是明确支持 null key 的——它会直接把 null 的 hash 值设为 0放到 0 号桶里。所以在 HashMap 上调get(null)是安全的。但有个前提HashMap 对象本身不能是 null。如果你写了HashMapString,Stringmapnull;map.get(key);那当然会抛 NPE——你都还没 new 对象呢拿一个 null 引用调任何方法都会抛。另外就是线程安全问题。get 方法本身不抛异常但如果另一个线程同时在往同一个 HashMap 里 put而且触发了扩容那 get 的时候可能会读到不完整的数据或者读到一半被修改了的链表结构。这就是并发修改异常 ConcurrentModificationException 的根源。我之前在面试里被问到过这个问题——“HashMap 是线程安全的吗get 方法安全吗”我当时回答“不抛异常但读到的不一定对。”面试官后来说这个回答还行。因为 get 本身不会破坏 HashMap 的结构它只是读。但多线程环境下你读到的数据可能不是你想要的——因为你读的时候别人正在写。所以后来我在需要多线程的地方要么自己加锁要么直接用 ConcurrentHashMap。HashMap 在多线程下到底会出什么问题这是一个老生常谈的问题了也是面试的高频题。我刚开始准备面试的时候把HashMap 线程不安全这个结论背得滚瓜烂熟。但面试官一问具体怎么不安全我就卡住了。后来我去翻了源码才搞清楚细节。JDK 1.7 的时候环形链表JDK 1.7 的 HashMap 用的是头插法——每次往链表中插入元素的时候新元素插到头部。这种实现在单线程下没问题但多线程并发扩容的时候可能会形成一个环形链表。为什么会有环形链表因为扩容的时候要把旧数组的元素搬到新数组。如果两个线程同时做这件事线程 A 搬了一部分元素线程 B 也搬了一部分而且它们对链表的引用操作互相交叉就可能让链表的最后一个节点指回前面的节点形成一个环。环形链表意味着什么你下次 get 某个 key 的时候遍历这个环形链表就会一直转圈死循环。CPU 跑到 100%程序卡死。我当时读到这个的时候心想这个 bug 确实很严重。因为这不仅仅是数据不对的问题是程序直接崩溃的问题。JDK 1.8 的改进JDK 1.8 把头插法改成了尾插法——新元素插到链表尾部。同时修改了扩容时元素的迁移逻辑。这样就不会再出现环形链表了。但 JDK 1.8 的 HashMap 就线程安全了吗不是。多线程同时 put 的时候还是会出现数据覆盖的问题。举个例子线程 A 和线程 B 同时调 put恰好它们算出来的 hash 位置是一样的。线程 A 先检查位置是空的正要插入的时候让出了 CPU。线程 B 也检查了这个位置发现也是空的就直接插入了。然后 A 恢复执行又把它的数据插入到同一个位置——这样就覆盖了 B 的数据。更隐蔽的问题是 size 的计算。HashMap 里维护了一个 size 变量记录存放了多少个元素。size 在 Java 里不是原子操作它可以拆成读取 size、size1、写回 size三步。两个线程同时做 size 的时候可能两个都读到了同样的旧值加完之后写回去就少了一个。那怎么办如果你要在多线程环境下用 HashMap有几种选择Collections.synchronizedMap()把 HashMap 包一层所有方法加同步锁。但锁的粒度是整个对象性能不太行。Hashtable老牌的线程安全哈希表但也是整个对象加锁跟 synchronizedMap 差不多。ConcurrentHashMap这是最推荐的方式。JDK 1.7 用分段锁JDK 1.8 进一步缩小锁粒度到每个桶的头节点并发性能最好。所以我的习惯是单线程用 HashMap多线程用 ConcurrentHashMap。Hashtable 基本不碰了——历史遗留产物。写在最后这篇聊了几个 HashMap 的进阶话题红黑树和 AVL 树的取舍、哈希冲突的多种解法、String 做 key 的原因、equals 和 hashCode 的规范、get 的安全性、多线程下的问题。写这些的时候我一直想到的是当初背面试题的自己。那时候 HashMap 相关的题目背得滚瓜烂熟但问到为什么就说不清楚了。后来去翻源码、自己写 demo 验证才慢慢把这些答案变成了理解。两篇下来HashMap 的核心机制和进阶原理都聊得差不多了。下一篇聊聊并发场景下的解决方案——ConcurrentHashMap 和 Hashtable看看它们是怎么解决 HashMap 的线程安全问题的。如果你也跟我一样在学这些东西建议看完之后去翻翻源码一行一行对着文章看。纸上得来终觉浅源码里面才有真东西。

相关新闻

2026/8/26 13:43:01

佛山水泥自流平施工正规公司推荐

这次整理针对的是佛山区域输送设备领域的一些主体,信息主要来自公开检索和企业自己披露的内容,只做基础信息整理,不涉及排名、推荐或优劣判断,所有内容仅供参考。整理时间是2025年3月,数据截止到2月底。主测主体佛山市…

2026/8/27 11:06:13

Jetson AGX Thor部署GROOT N1.5:具身智能模型端侧推理实战指南

1. 项目概述:为什么要在Jetson AGX Thor上跑GROOT N1.5? GROOT N1.5不是个普通模型——它是专为具身智能(Embodied AI)设计的下一代视觉-语言-动作联合推理模型,核心目标是让机器人真正“看懂环境、理解指令、规划动作…

2026/8/29 3:51:47

代码图谱 RAG:从图结构到智能问答的完整落地指南

那段时间我刚好在做一个遗留系统的重构评估。代码仓库不大,但调用关系很绕:订单状态变更会触发库存锁定、优惠券核销、消息推送,中间还隔了两个 RPC 服务。我把仓库里的 Java 文件按函数切块、向量化,然后接上一个常规的 RAG 流程…

2026/8/29 3:51:47

128个AI Agent协作编码:从任务拆分到并发合入的工程实践

AI coding Agent 工作流最近最热的话题,已经从单个 Agent 能不能写代码,变成了几十个甚至上百个 Agent 能不能同时在同一个代码库上协作。128 个 AI 智能体同时运行,这是 Cursor 与 Baseten 对谈编码 Agent 下一步时最吸引人的数字之一。对多…

2026/8/29 3:51:47

基于Java Spring Boot的车位租赁管理系统设计与实现

简介:在城市化进程加速的背景下,停车位资源错配问题日益凸显,空闲时段的车位与急切寻找车位的用户之间缺乏高效的连接桥梁。借助Web开发技术构建线上租赁平台,成为提升资源利用率的有效路径。Spring Boot作为Java领域主流的微服务…

2026/8/29 3:51:47

Python环境搭建与PyCharm安装配置指南:合规稳定从零开始

Python 环境搭建和 PyCharm 安装配置,是很多初学者进入 Python 开发遇到的第一个完整闭环。很多人下载 Python 后不知道为什么要配置 PATH,也不知道 PyCharm 和 Python 解释器到底是什么关系;还有一部分人看到网上流传的“永久授权”“2099 年…

2026/8/29 3:51:47

平面磁件设计实战:从原理到应用,提升电源功率密度的关键

1. 从立体到平面:重新认识电磁元件的形态革命做电力电子这么多年,有个现象我感触特别深:功率半导体的进步几乎是按年翻番的,从Si到SiC再到GaN,开关频率和损耗表现让人眼花缭乱。但与之配套的磁性元件——变压器、电感、…

2026/8/29 3:46:46

37小时拆解数据分析四件套:Excel/SQL/PowerBI/Python从入门到求职

这次我们来看一份很硬核的数据分析学习路线: 37 小时拆解 Excel / SQL / PowerBI / Python 四件套,从入门直接打到求职、简历和面试 。标题里的“爆肝两个月”不是噱头,实际上它对应的是数据分析岗位最常用的一整套工具链,而不是…

2026/8/28 16:16:17

[光学原理与应用-521]:对光的错误理解与纠偏

首先光是一种能量的载体和形态,宏观上观察到的光是由无数个微观的光量子组成的,每个光子在产生的瞬间,其在真空的空间中以确定不变的速度沿着一个初始的方向一直向前,在微观层面,每个光量子的运动轨迹是以波函数所展现…

2026/8/28 16:16:21

SIP通话转接原理与REFER方法实战解析

1. 通话转接不是“挂断再拨号”,而是SIP会话的动态重定向你有没有遇到过这样的场景:客服坐席A正在和客户通电话,突然需要把这通对话无缝转给专家坐席B,客户完全感知不到中间的断连——既没听到忙音,也没被要求重新拨号…

2026/8/28 16:16:22

Kolla-ansible单节点OpenStack部署实战:从环境准备到排坑指南

1. 为什么选择Kolla-ansible来部署单节点OpenStack?如果你正在寻找一种能把OpenStack从“概念”快速变成“可用的实验环境”的方法,那么Kolla-ansible几乎是当前最主流、最省心的选择。我见过太多人卡在手动编译依赖、配置服务、处理版本冲突的泥潭里&am…

2026/8/29 0:01:10

etc目录下的profile.d文件目录设置环境变量和全局脚本shell

一、设置环境变量etc目录下的profile.d文件目录 /etc/profile.d1、编写 vi test.sh文件内容# jdk变量 export ZHK_HOME/root export PATH$PATH:$ZHK_HOME/test # 可以取出来ZHK_HOME变量给ZZZ_HOME赋值 export ZZZ_HOME${ZHK_HOME}/test2、刷新 执行source /etc/profile 命令使…

2026/8/29 0:01:10

【JavaScript】内存管理-垃圾回收机制-内存泄露

内存管理 C 语言这样的底层语言一般都有底层的内存管理接口,比如 malloc()和free()。 而 JavaScript 是在创建变量(对象,字符串等)时自动进行了分配内存,并且在不使用它们时“自动”释放。释放的过程称为垃圾回收。 整…

2026/8/29 0:01:10

Labgrid-MCP:为嵌入式硬件实验室接入AI Agent操控能力

Labgrid-MCP 的目标是把 MCP(Model Context Protocol)能力延伸到真实嵌入式硬件实验室:AI Agent 通过一个标准化的 MCP Server,就能查看目标板状态、控制上电断电、复位开发板、读取串口日志,甚至执行镜像刷写。对于经…

2026/8/28 16:16:48

实测才敢推 AI论文网站 2026最新测评与推荐

2026年真正好用的AI论文网站,核心看生成的论文质量、低AI味、格式正确、学术适配四大指标。综合实测,千笔AI、ThouPen、豆包、DeepSeek、Grammarly 是当前最值得推荐的梯队,覆盖从免费到付费、从中文到英文、从文科到理工的全场景需求。一、综…

2026/8/28 16:16:50

2026必备!AI论文网站测评:最新推荐与深度对比

2026年真正好用的AI论文网站,核心看生成的论文质量、低AI味、格式正确、学术适配四大指标。综合实测,千笔AI、ThouPen、豆包、DeepSeek、Grammarly 是当前最值得推荐的梯队,覆盖从免费到付费、从中文到英文、从文科到理工的全场景需求。 一、…

2026/8/28 11:06:45

摆脱论文困扰!盘点2026年全网爆红的的AI论文写作工具

一天写完毕业论文在2026年已不再是天方夜谭。2026年最炸裂、实测能大幅提速的AI论文写作工具,覆盖选题构思、文献整理、内容生成、格式排版等核心场景,真正帮你高效搞定论文难题。 一、全流程王者:一站式搞定论文全链路(一天定稿首…