HashMap核心机制全解:从哈希冲突到红黑树,进阶必读
发布时间:2026/9/9 9:13:30 作者:尧图编辑部 阅读量:1,286

写HashMap写了快十年从JDK 1.6一路用到现在的JDK 17每次面试候选人我都会从HashMap切入因为它真的太能反映一个人的Java基本功了。数组、链表、红黑树、哈希、位运算、扩容、并发安全、Fail-Fast机制这些知识点在全景图里几乎是绕不开的硬骨头而HashMap这个数据结构把它们全串在了一起。很多刚入行的朋友一提HashMap就是背八股源码也看过但一问到为什么加载因子是0.75为什么数组长度一定要是2的幂JDK 1.8为什么从头插改成尾插就支支吾吾了。这篇博文我打算换个讲法不按说明书式的顺序把源码贴一遍而是从HashMap到底在解决什么问题出发把设计思路、核心机制、并发隐患、高频面试题和实际开发中的坑串起来讲清楚每个关键决策背后的为什么。无论你是刚学Java的新手还是准备跳槽刷面试题的选手或者工作中被线上OOM和HashMap分配不均折磨过的老哥这篇都能给你一点参考。1. 先聊清楚HashMap到底解决什么问题1.1 从数组和链表说起Java集合框架里最基础的两个数据结构就是数组和链表。数组在内存里是一段连续空间通过下标访问的时间复杂度是O(1)这点依靠硬件寻址几乎不消耗额外计算但它的短板也很明显——插入和删除需要整体搬移元素最坏情况下是O(n)而且创建时就得指定大小动态扩容要复制整个数组。链表则刚好反过来节点之间通过引用相连插入和删除只需要改前后的指针代价是O(1)但查询某个元素时必须从头节点一个一个往后找平均时间复杂度是O(n)数据量一大性能就崩。HashMap的核心设计目标就是想用哈希这个手段把两者优势结合起来用哈希函数把key映射到一个数组下标实现查找接近O(1)的效果同时用链表或红黑树来处理不同key映射到同一个下标的情况。用生活里的例子类比数组就像酒店前台的一张长桌子上面固定摆了几十本登记册哈希函数就是告诉你拿房号除以册数取余数去第几本册子查如果两个人分到了同一本册子那就翻册子里的小纸片一张一张比对房号名字。这个翻纸片的行为就是链表查找。1.2 整体设计数组、链表、红黑树怎么配合HashMap 1.8版本的底层结构是一维数组加链表加红黑树。数组的每个格子叫桶bucket默认大小是16。key进入HashMap后先通过扰动函数计算出一个hash值再根据hash值定位到具体桶位置。桶里如果只有一条数据查询就是O(1)如果发生过哈希冲突桶里存的是一个链表查询需要沿着链表逐一遍历时间复杂度变差。当链表长度达到8并且当前数组容量大于等于64时链表会转换成红黑树。为什么是8而不是其他数字源码注释里给出了一个基于泊松分布的计算在随机哈希的情况下一个桶里链表长度达到8的概率大约是千万分之六这个概率已经低到可以当成不可能发生的事件。也就是说转红黑树其实是一种极端情况下的兜底策略防止恶意构造哈希或糟糕的hashCode把HashMap退化成一个纯链表。红黑树是自平衡二叉查找树插入、删除、查找的时间复杂度都是O(log n)即使真出现大量冲突性能也不会彻底崩掉。但红黑树节点占用的空间大约是普通链表节点的两倍所以当红黑树节点数少于6时又会退化成链表避免不必要的空间浪费。这里阈值的8和6之间留了缓冲防止在边界上频繁转换这个设计在实际工程里也经常被借鉴。2. 核心机制拆解hash、put、扩容、get的源码逻辑2.1 hash值的计算为什么右移16位再异或HashMap在计算key的哈希时并不是直接用key.hashCode()的结果而是做了一次扰动处理。1.8版本的代码长这样static final int hash(Object key) { int h; return (key null) ? 0 : (h key.hashCode()) ^ (h 16); }这里把hashCode的高16位和低16位做了一个异或运算。为什么要多此一举关键在于HashMap定位桶时用的是(n - 1) hash这个操作其中n是数组长度。如果数组长度是16那么n - 1的二进制是0000 0000 0000 1111与hash做按位与实际上只有低4位参与了运算高28位就被浪费了。这意味着如果两个key的hashCode在高位完全不同、但低位恰好一样它们就会落到同一个桶里冲突率会明显上升。右移16位再异或本质上是把高16位的信息混入低16位让高位影响低位减少碰撞。这也是为什么这个方法叫扰动函数。我自己在写业务代码时自定义对象的hashCode方法也会注意这一点别只把关键字段的低位特征映射进去尽量让hash值在高位也有区分度否则一旦HashMap扩容或者数据量变大碰撞概率会急剧上升。2.2 put流程全解析从hash到插入的每一步HashMap的put方法在1.8版本中整体流程可以拆成以下几个关键步骤判断table是否为null或者长度为0是则先执行resize()初始化默认容量16。根据hash值计算索引i (n - 1) hash取出tab[i]。如果tab[i]为null说明桶是空的直接newNode插入。如果tab[i]不为null先判断key是否equals相等则直接替换value。否则判断tab[i]是否为TreeNode是则走红黑树的插入逻辑。否则就是普通链表遍历链表如果找到相同key就替换如果在链表末尾还没找到就在尾部追加新节点。追加完成后判断链表长度是否超过树化阈值8如果超过并且数组容量64调用treeifyBin把链表转成红黑树。插入完成后判断size是否超过threshold超过则扩容。这里有个容易忽略的细节hash冲突时先比较的是引用 **再比较的是equals。源码里是if (p.hash hash ((k p.key) key || (key ! null key.equals(k))))。先看hash值是不是一样这个O(1)就能判断如果hash都不一致基本可以排除同一个key虽然理论上hash一致时还要equals确认。这种短路式的判断方式在链表比较长的场景下可以省掉很多equals调用因为hash值不同的对象根本不可能equals相等前提是hashCode实现遵守约定。2.3 扩容机制为什么要设计成2次幂扩容是HashMap里最耗性能的操作之一。当元素个数超过threshold capacity * loadFactor时数组会扩大为原来的2倍然后所有元素都要重新计算桶位置这个过程叫rehash。为什么容量一定要是2的幂核心原因是定位操作(n - 1) hash只有在n是2的幂时才能做到和hash % n等价但性能更高。而且当n翻倍时某个元素在新数组中的位置只有两种情况保持原索引或者原索引oldCap。为什么会这样看一个例子假设oldCap是16hash是某个值那么hash (16 - 1)取的是hash的低4位扩容后n变成32hash (32 - 1)取的是hash的低5位。多出来的那一位如果hash的第5位是0位置不变是1位置就变成原索引16。所以1.8里resize过程不需要重新计算每个元素的hash值只需要看原来hash值的第5位是0还是1这个判断用(e.hash oldCap) 0一次位运算就搞定了效率非常高。1.7版本的扩容需要从头到尾重新计算hash并重新插入性能差而且容易出并发问题。1.8优化成基于旧hash的高位判断堪称一次教科书级别的位运算优化。2.4 get和removeHashMap如何高效取数据和删数据get的逻辑相对简单计算hash定位桶如果第一个节点就是目标key直接返回否则判断是不是树节点是就走红黑树查找不是就遍历链表。这段源码看起来没有太多坑但有一点值得注意HashMap允许key为nullnull的hash值固定为0所以null key永远放在table[0]这个桶里。remove的核心是removeNode方法逻辑比get多一步——找到节点后要处理链表的删改。如果删除的是链表中间节点只需把前一个节点的next指向被删节点的next如果是红黑树节点则走红黑树的删除逻辑并伴随自平衡操作。日常开发里我自己很少直接用迭代器边遍历边删除容易抛出ConcurrentModificationException更推荐用map.entrySet().removeIf(...)这种基于迭代器封装的删除接口从底层看它正确维护了modCount不会触发Fail-Fast机制。3. 并发场景下的HashMap为什么它不安全以及如何应对3.1 线程不安全的根源丢更新、扩缩容竞态HashMap在多线程环境下会出问题的原因有很多最直观的是数据覆盖。两个线程同时put key同时定位到同一个空桶然后各自newNode并赋给tab[i]最终只有一个节点的引用被保留另一个线程的数据悄无声息地丢了。这还不是最可怕的最严重的是扩容和并发put交叉执行时链表结构可能被破坏进而导致get访问到不存在的节点甚至直接死循环。有人会问加个HashTable不就完事了HashTable把所有方法都加了synchronized锁的是整个table任何读写操作串行执行并发性能非常差。而HashMap本身不保证线程安全所以它更适合单线程场景下的高效读写。如果明确有并发需求应该考虑ConcurrentHashMap而不是给HashMap方法级加锁。3.2 经典事故JDK 1.7头插法引发的死循环再提一个Java面试和社区里被讲烂的案例JDK 1.7的HashMap在并发扩容时可能出现链表环状结构导致get操作在遍历链表时永不终止CPU飙满。根因是1.7扩容时采用头插法转移链表节点转移过程中链表顺序会反转。举个例子原本链表的顺序是A - B - C线程1和线程2同时扩容。当线程1执行完某个步骤后被挂起线程2完成了完整的rehash链表顺序变成了C - B - A。线程1恢复后在新链表基础上继续转移因为头插法的原因A.next指向B但B.next已经被线程2改成了A于是形成了A - B - A循环。之后任何一次get走到这个链表就会在里面转圈。1.8版本改成尾插法新节点追加到链表尾部扩容时保持原有链表的相对顺序从根源上避免了循环链表的形成。这也是很多人被问到为什么1.7到1.8的头插改尾插时给出的标准答案。但注意1.8的HashMap在并发下仍存在数据丢失、size统计不准等问题它依然不是线程安全的。3.3 并发替代方案怎么选并发环境下我一般直接上ConcurrentHashMap。1.8的ConcurrentHashMap抛弃了1.7的Segment分段锁设计改用CAS synchronized锁粒度细化到单个桶。put时如果桶为空通过CAS直接把节点放入不抢占锁如果桶非空对桶中的头节点加synchronized保证同一桶内操作串行但不同桶之间互不阻塞并发度大幅提升。如果只是需要弱一致性的缓存场景也可以考虑Collections.synchronizedMap包一层但效率低一般只在读多写少的简单场景里过渡用。总之记住一句话没有并发需求用HashMap有并发需求看场景选ConcurrentHashMap别拿HashTable也别自己手工加全局锁模仿那个效果。4. 实际开发中经常踩的坑HashMap使用习惯纠正4.1 初始容量和扩容开销的账很多人在写代码时习惯直接new HashMap()也不管可能存多少数据等元素多了触发扩容才发现性能不对。扩容是一个重操作要创建新数组还要把原有元素全部rehash。如果业务场景里HashMap会存上万条数据建议创建时直接指定容量。但指定容量也有讲究。比如你确定会存1000条直接new HashMap(1000)够吗不够。因为HashMap在元素数量达到capacity * 0.75时就会扩容容量1000的map存到750条时就触发了扩容实际扩容后的容量变成2000左右。你预期的1000容量其实被浪费了一半。更合理的是new HashMap(1000 / 0.75F 1)也就是大约1334向上取2的幂最后实际容量是2048这样存1000条数据时不会触发中间扩容。这个公式网上传得很广但很多人不知道背后的原因这里补一句解释HashMap构造时并不会马上分配大数组它会在第一次put时根据initialCapacity计算thresholdthreshold capacity * loadFactor只有元素数量超过threshold才扩容。4.2 自定义对象作为Key时equals和hashCode的约定面试里常问为什么重写equals必须重写hashCode。放到HashMap场景里理解最直观HashMap先通过hashCode定位桶再用equals验证是否同一key。如果两个对象equals相等但hashCode不同它们会被定位到不同桶HashMap会认为它们是两个不同key产生重复存储反过来如果hashCode一样但equals不等它们会落在同一桶里链表变长性能下降。所以在设计自定义Key类时我会遵守三条准则为什么字段参与equals就参与hashCode且hashCode计算要包含这些字段。如果对象创建后key的字段会变化不要拿来当HashMap的key。因为哈希值变了原桶位置对不上了get的时候必然找不到。这块最好的实践是使用不可变对象比如String、Integer或者把自定义类设计成不可变。4.3 遍历方式的性能差异和删除陷阱HashMap的遍历方式有好几种keySet()然后get、entrySet()、forEachJDK 1.8的BiConsumer。keySet()只拿到key的Set如果你想同时拿value还需要再get一次这意味着又做了一遍哈希定位二次开销。而entrySet()一次遍历拿到所有键值对性能最好。删除元素时如果直接在for-each循环里调用map.remove(key)会触发ConcurrentModificationException因为remove操作把modCount改了但迭代器不知情。解决办法是用迭代器的remove或者用1.8新增的map.entrySet().removeIf(entry - ...)后者代码更简洁。这条规则同样适用于List我就见过不止一个同事在线上因为for-each里remove抛异常。4.4 不同Map的选型别只会HashMap实现类是否有序线程安全底层结构典型场景HashMap无序否数组链表红黑树大多数默认场景LinkedHashMap按插入顺序或访问顺序否HashMap基础上加双向链表实现LRU缓存TreeMap按key的自然顺序或比较器排序否红黑树需要排序、范围查找HashTable无序是数组链表全表锁已经不推荐使用ConcurrentHashMap无序是数组链表红黑树CASsynchronized并发读写的首选这里单独说下LinkedHashMap。它继承了HashMap但在每个节点上额外维护了前后指针形成一个双向链表所以它天然支持按插入顺序遍历。更妙的是它有个removeEldestEntry方法重写这个方法就能实现一个最简单的LRU缓存这个技巧在面试中经常被考察。4.5 从HashMap看JVM层的内存和类加载异常热搜词里有一条Uncaught exception java.lang.NoClassDefFoundError: java/applet/Applet虽然这个异常本身和HashMap没直接关系但值得说明一点HashMap是JDK自带的类如果运行环境里出现NoClassDefFoundError通常是类路径配置错误、JDK模块化裁剪导致某些类不存在或者Agent/Lombok等字节码工具干扰了类加载。遇到这种问题第一步不是去看HashMap源码而是检查JDK版本和启动参数再用-verbose:class看具体是哪个类加载失败。另一个和HashMap相关的JVM问题是内存占用。假设你往HashMap里存了几十万个复杂对象并且key的hashCode分布极度不均导致红黑树特别多节点对象本身会占用大量内存再加上数组扩容后的未使用桶位也占据空间堆内存压力就上来了。实际排查OOM时除了用jmap dump堆外也要留意是不是HashMap的加载因子被调得过高或过低导致扩容过度或者链表过长。5. 面试高频考点HashMap八股速成与延伸思考5.1 JDK 1.7和1.8的对比总结面试官最喜欢问的一句话就是聊一下HashMap底层。但真正的加分点在于你能说出版本演进。对比项JDK 1.7JDK 1.8数据结构数组链表数组链表红黑树树化阈值无链表长度8且数组长度64哈希扰动4次位运算5次异或1次异或右移16位链表插入方式头插法尾插法扩容后重哈希重新计算hash通过(e.hash oldCap)判断新位置并发问题扩容时可能形成循环链表仍不安全但不会因扩容死循环另外1.8源码中引入红黑树后把原来1.7里为了哈希分布而做的复杂扰动函数简化了因为即使hash分布稍微差一点冲突也可以用红黑树兜底这一取舍很多资料没细说面试时提出来会显得你真的读过源码。5.2 高频追问与回答思路我整理了面试中关于HashMap的几组连续追问并给出简要回答思路建议你把它当成一个思维导图来记忆问HashMap的哈希函数为什么用异或而不是直接取模答数组长度是2的幂(n - 1) hash和取模等价但位运算更快而hash值先和高位异或后可以让高位的特征也参与低位定位降低碰撞概率。问为什么加载因子是0.75而不是0.5或1.0答这是空间和时间的一个折中。太大会导致哈希冲突增多链表过长太小会导致频繁扩容浪费空间。0.75是JDK官方在大量测试后给出的默认值兼顾两者。问链表什么时候会转红黑树答链表长度达到8并且数组容量达到64。如果数组长度小于64即使链表超过8也只会扩容而不是树化因为扩容之后链表位置重新分布问题可能自然解决。问为什么不直接用红黑树替代链表答链表节点占用的内存更小插入性能更好红黑树节点更占内存维护平衡有额外开销。哈希碰撞在正常情况下的概率很低没有必要常态使用红黑树。问HashMap扩容时数据是怎么迁移的答遍历旧表的每个桶对桶内元素按是否(e.hash oldCap) 0拆分成两部分一部分留在原索引位置一部分移到原索引 oldCap依次尾插到对应链表。这些回答其实不需要死记硬背只要能理解底层原理每个人都可以用自己的话复述出来。怕的就是只背结论不问原因面试官一旦深挖就露馅。5.3 一道简单的实操验证题如果你也想验证自己对HashMap的理解可以试试这个练习写一个类重写hashCode方法让它永远返回同一个值比如1然后用这个类的对象作为key向HashMap循环put一万条数据观察它的性能和数据分布。再改成用String作为key对比两者的put耗时。我当年做这个实验时感受非常直观——一个糟糕的hashCode可以让HashMap的性能直线下滑这就是为什么工程上要求hashCode尽量分散。6. 最后再分享一点实际开发中的体会做了这么多年Java我越来越觉得HashMap是所有集合框架里最值得反复研读的一个类。它不只是一个会用就行的数据结构更像一本浓缩的Java入门教材——位运算、hash设计、链表操作、树化退化、扩容策略、Fail-Fast机制每一个概念都能在里面找到落点。如果你能把HashMap的源码逻辑完整讲清楚基础题这一关基本就稳了。实际开发过程中我给自己定了一条规矩凡是能预估数据量的场景初始化时就指定容量凡是自定义对象要做key先确认hashCode和equals是否规范且对象不可变凡是有并发入手的可能直接用ConcurrentHashMap绝不贪图一时方便。这些小习惯看起来不起眼但等到线上真出了性能事故再去排查代价往往是几个小时的脑细胞和一堆日志。如果这篇文章里的某个点帮你解决了一个bug或者让你在下次面试时多了一句因为数组长度是2的幂所以可以用(n - 1) hash代替取模那就值了。HashMap还有很多可以深挖的细节比如扰动函数的具体位运算、红黑树的左旋右旋过程、TreeNode的split逻辑感兴趣的话后续可以再单独写一篇深入源码的文章到时候我们继续聊。