news 2026/10/20 6:04:57

彻底讲透HashMap:从哈希原理到源码与性能优化

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
彻底讲透HashMap:从哈希原理到源码与性能优化

给新手讲清楚HashMap,Java入门阶段的很多事情一下子就能通。这个数据结构核心就一句话:通过key找到value。登录系统里拿用户ID查用户信息、订单统计里按日期聚合金额、缓存场景里用请求URL换响应结果,背后全是同一套逻辑——给一个key,立刻得到对应的value。偏偏很多人项目里用了很久HashMap,一问为什么必须重写equals和hashCode、为什么容量默认16还必须是2的幂、为什么有时候写入几十万条数据就卡顿,就答不上来了。这篇文章就把这条从key到value的完整链路彻底掰开,顺便把源码、性能坑、排查方法一次说清。

适合谁看:正在啃集合源码的初学者、准备面试的人,以及写代码时觉得HashMap“时而快时而慢”但说不清原因的开发者。我会把几个关键概念用厨房、图书馆这种生活场景做类比,再配一套可直接落地的操作建议。

1. 先从“key找value”这个需求说起

1.1 数组的尴尬和链表的慢

假设你现在要做一个用户系统,用户数据有ID、姓名、手机号。最常见的需求是:给我一个用户ID,把那行用户信息取出来。如果数据用数组存,你得知道ID对应的下标,比如第0个格子里存ID=1001的用户,第1个格子存ID=1002的用户,那就只能强行为ID和下标建立映射关系,业务里根本做不到——用户ID是1001、1002这种连续值还好说,一旦变成订单号、手机号、短信验证码这种没有规律、甚至可能重复的键,数组下标立刻失效。

有人会说,那用链表不就行了。链表每个节点可以存两部分:key和value。查找的时候从头节点开始,逐个比较节点的key和你要找的key,相等就返回value。这个方案灵活,但代价是查找复杂度O(n),数据量到一万条还能忍,到百万条就完全没法用了。每次查找都得把链表从头摸到尾,最坏情况下要找满全场。

你真正需要的是一种结构:既不用关心下标,又不用遍历全场。这种需求就是哈希表要解决的。

1.2 哈希思想的雏形:算出一个位置来

哈希表的想法其实很朴素。你去图书馆借书,管理员不会带你从第一排书架开始找,而是先看索书号,索书号本身就包含了分类体系,走到对应分区,顺着编号一摸就拿到了。你去快递驿站取件,快递员也不会把所有包裹拆开挨个翻,他先按手机尾号分货架,再按包裹上的编号找。

哈希表做的就是同一件事:把任意一个key,通过一个计算过程,映射成数组里的某个下标。这个计算过程叫哈希函数,算出来的值叫哈希值,数组里的每个格子叫桶(bucket)。

理论上,只要哈希函数能把不同的key均匀散开,每次查找就能直接定位到目标桶,时间复杂度接近O(1)。那HashMap只不过是把这套思想在Java里实现了,并且加了处理冲突、自动扩容的机制。所以理解HashMap,核心就是理解两件事:key怎么变成一个桶下标,以及多个key落到同一个桶时怎么办。

2. HashMap的桶位怎么来的:hash()与下标定位

2.1 hash()为什么要异或高16位

先看JDK 8里HashMap计算哈希值的源码:

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

这里有两个动作:先调用key自身的hashCode()拿到一个int;然后把高16位和低16位做异或,得到的才是真正参与桶定位的hash值。

为什么要折腾这一步?因为后续定位桶的时候,用的是数组长度减一和hash值做按位与。默认数组长度16,对应的二进制是0000 0000 0000 1111,按位与会把高28位全部清零,只保留最低4位。如果两个key的hashCode在高位有差异、低位完全相同,那它们就会落到同一个桶,这就是冲突。而且很多对象的hashCode恰恰在高位差异较大,低位区分度不足。

把高16位异或到低16位,相当于让高位的特征也参与低位的运算,即使数组很短,散列效果也会好很多。这一步在源码注释里叫“扰动函数”,确实是经过权衡后性价比很高的一步。

2.2 为什么容量必须是2的幂,以及取模优化

定位桶下标的核心代码是这一行:

i = (n - 1) & hash

n是数组长度。这里用按位与代替取模%,原因是位运算比取模快得多。但要成立,前提是n必须是2的幂。当n=16时,n-1=15,二进制是1111;n=32时,n-1=31,二进制是11111,全都是低位连续的1,这样按位与的结果就等于hash对n取余,而且效率更高。

HashMap源码在构造时对容量做了一圈处理,就是为了保证这一点。你传入任何初始容量,它都会向上补成2的幂:

static final int tableSizeFor(int cap) { int n = cap - 1; n |= n >>> 1; n |= n >>> 2; n |= n >>> 4; n |= n >>> 8; n |= n >>> 16; return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1; }

你传3,它变成4;传10,变成16;传100,变成128。这套移位运算把最高位后面的所有位全部填成1,最后加个1,就是最接近且大于等于原值的2的幂。

还有一个好处。扩容时数组长度翻倍,从16变成32,原来长度16时下标只由hash低4位决定,现在要由低5位决定。新增的那一位如果是0,元素留在原地;如果是1,元素挪到“原下标+16”的位置。因为2的幂特性,扩容时不需要重新计算每个key的hash值,只需要看新增比特位就够了,这也是JDK 8里resize能高效拆分链表的原因。

2.3 冲突不可避免:链化和树化的阈值逻辑

哈希函数设计得再好,也不能保证每个key都算到不同桶,冲突是必然的。HashMap解决冲突的办法,就是把多个冲突元素放到同一个桶里,用链表串起来。JDK 8之前一直是这个方案,简单可靠,但极端情况下所有key都落在同一个桶,链表越来越长,查找就退化成了O(n)。

JDK 8改成了链表加红黑树的混合结构:同一个桶里节点数超过8,并且整个数组长度大于等于64,就会把链表转成红黑树。红黑树查找复杂度O(log n),在一棵深度几十的树里找一个元素,跟遍历几千个节点的链表是完全不同的量级。

为什么阈值是8?源码注释里给出过一个统计结论:在随机哈希码的情况下,桶内节点数达到8的概率大约只有千万分之六,对绝大多数正常业务来说几乎不可能触发树化。反而如果阈值设得太低,比如2,稍微有点冲突就树化,而树节点的内存占用是普通节点的两倍左右,性能反而下降。等红黑树节点数降到6以下,又会变回链表,这中间留了缓冲区间,避免频繁在两种结构之间来回切换。

3. put和get的完整源码链路

3.1 put(key, value)时发生了什么

JDK 8里put的入口很简单,但背后逻辑值得逐行看:

public V put(K key, V value) { return putVal(hash(key), key, value, false, true); } final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; int n; // 第一次put时整个表还是空的,先扩容初始化 if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; // 算出桶下标,如果槽位是空的,直接放进新节点 int i = (n - 1) & hash; if ((p = tab[i]) == null) tab[i] = newNode(hash, key, value, null); else { Node<K,V> e; K k; // 桶里已有节点:先比较hash,再比较key if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; else if (p instanceof TreeNode) // 桶里已是红黑树,走红黑树插入逻辑 e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); else { // 桶里是链表,遍历找尾部插入;如果中途遇到相同key,记录旧节点 for (int binCount = 0; ; ++binCount) { if ((e = p.next) == null) { p.next = newNode(hash, key, value, null); if (binCount >= TREEIFY_THRESHOLD - 1) treeifyBin(tab, hash); break; } if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; } } // e不为空说明存在相同key,覆盖旧value并返回旧value if (e != null) { V oldValue = e.value; e.value = value; return oldValue; } } ++modCount; // 元素数量超过阈值就扩容 if (++size > threshold) resize(); return null; }

注意几个细节。定位到桶以后,如果桶里已经有节点,先比hash,再比对key。比对key的时候用了两层判断:第一层是引用判断k == key,比如你用同一个String对象做key,直接引用相等就命中;第二层是key.equals(k),处理的是内容相同但引用不同的情况,比如两次都传入字符串"1001",它们不是同一个对象,但equals相等。

只有hash相同才有必要做equals比较,hash不同就直接认定不是同一个key。这能够大大减少equals的调用次数,毕竟哈希定位已经把绝大多数无关key挡在外面了。

3.2 get(key)时为什么能精准命中

get的链路和put高度对称:

public V get(Object key) { Node<K,V> e; return (e = getNode(hash(key), key)) == null ? null : e.value; } final Node<K,V> getNode(int hash, Object key) { Node<K,V>[] tab; Node<K,V> first, e; int n; K k; // 表非空且目标桶位置有节点,才可能找到 if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) { // 先检查第一个节点 if (first.hash == hash && ((k = first.key) == key || (key != null && key.equals(k)))) return first; // 第一个节点没命中,看是树还是链表 if ((e = first.next) != null) { if (first instanceof TreeNode) return ((TreeNode<K,V>)first).getTreeNode(hash, key); // 链表逐个比较 do { if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) return e; } while ((e = e.next) != null); } } return null; }

这个流程已经清晰:先通过hash(key)算出哈希值,再用(n - 1) & hash定位桶;桶里节点碰运气直接命中;命不中就在链表或红黑树里继续比较。之所以说HashMap查询是“近似O(1)”,就是因为大多数情况下桶里是空的或者只有一两个节点,一次定位加一两次比较就出结果;只有冲突严重时才会走到后面的链表或树遍历。

3.3 equals和hashCode的契约为什么是命中的前提

如果你只重写equals但不同时重写hashCode,会出大问题。假设你有一个订单类Order,equals比较的是orderNo,但hashCode还是Object的默认实现——基于对象内存地址。两个orderNo完全一样的订单对象,equals返回true,但hashCode不同。第一次put进去用的是对象A,第二次get时传入对象B,B的hash值跟A不同,桶下标直接不一样,连目标桶都找不到,equals根本没机会执行。这就是哈希表最核心的契约:equals相等的两个对象,hashCode必须相等。

反过来,hashCode相等不等于equals相等,这是允许的,这正是哈希冲突。两个不同key算到同一个桶,最终靠equals来区分谁是谁。

所以正确做法是:参与equals比较的字段,必须同时参与hashCode计算;反之,如果hashCode用了某个字段,equals也最好用上,否则可能出现两个对象hashCode相同但业务上含义不同,put时被当成同一个key覆盖的情况。后面第4部分我会给完整示例。

4. 实际开发中容易踩的三个性能坑

4.1 初始容量与加载因子的正确姿势

HashMap的默认容量是16,加载因子是0.75。加载因子的含义是:当元素数量超过容量 × 0.75时,触发resize扩容。也就是说,默认情况下,存到第12个元素,HashMap就会扩容到32,容量翻倍。

很多人不关心这个,导致写入数据量大时反复扩容。我实测过,往默认HashMap里塞100条数据,会触发至少4次resize,每次resize都要重新分配数组、重新计算所有已有节点的桶下标、移动节点。1亿条级别的数据,光是扩容的rehash成本就非常可观。

正确的初始化方式是按预计数据量反推。如果你预计要放100条数据:

int expectedSize = 100; Map<String, Object> map = new HashMap<>((int) (expectedSize / 0.75f) + 1);

计算下来初始容量设置成134,HashMap会自动向上取整为256,阈值变成256×0.75=192,远超100,整个过程不会触发扩容。如果直接写new HashMap<>(100),容量会向上取成128,阈值是96,存到第96个元素时还是得扩容一次,等于白打算。

加载因子0.75是JDK团队在时间成本和空间成本里取的平衡点。调小到0.5,桶更空、冲突更少、查询更快,但内存浪费严重;调大到1.0,空间利用率高但碰撞概率上升,链表变长,查询变慢。普通业务场景不要动这个值,真要调,先压测再调。

4.2 自定义对象当key,equals和hashCode怎么配

开发中最容易出问题的场景,是把自定义对象塞进HashMap当key,比如订单、账号、设备信息。先看一个会翻车的写法:

class Order { private String orderNo; private BigDecimal amount; @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; Order order = (Order) o; return Objects.equals(orderNo, order.orderNo); } // 忘记重写 hashCode }

两个orderNo相同的Order对象,equals返回true,但hashCode还是默认的地址哈希,意味着同一笔订单两个不同的对象实例放进Map,会被当成两个key。get的时候传入另一个同orderNo的对象,hash定位直接落到另一个桶,结果永远是null。排查了半天,最后根源就是hashCode没重写。

正确的版本是这样:

class Order { private final String orderNo; private BigDecimal amount; public Order(String orderNo) { this.orderNo = orderNo; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; Order order = (Order) o; return Objects.equals(orderNo, order.orderNo); } @Override public int hashCode() { return Objects.hash(orderNo); } }

equals只用orderNo比较,hashCode也只由orderNo计算,契约一致性满足。

还有一个进阶要求:key对象尽量不可变。如果你把key放进Map之后,又修改了某个参与hashCode计算的字段,这个key的hash值就变了,再调用get时按新hash值定位到另一个桶,旧桶里的数据就永远找不到了,等于内存里多了一份永远不会被访问的孤儿节点。String、Integer这类包装类天生不可变,所以是最常用的key类型。自定义类做key时,核心字段建议设成final,去掉setter,从根本上杜绝这个问题。

4.3 并发场景别拿HashMap硬扛

HashMap不是线程安全的。多线程同时put时,两个线程可能在同一桶位置写入,后写的覆盖先写的;更麻烦的是扩容阶段,多线程同时触发resize,JDK 7里甚至可能形成循环链表,get的时候直接死循环,这是当年的经典线上事故。JDK 8改成尾插法,循环链表问题基本被解决,但数据丢失、覆盖问题依然存在,因为HashMap的内部操作没有同步机制。

并发场景的正确选择是ConcurrentHashMap。JDK 8的ConcurrentHashMap放弃了分段锁,改用CAS加synchronized锁单个桶节点,锁粒度更细,并发读的时候基本无锁,写入时只锁住目标桶,性能远好于给整个HashMap加synchronized。

如果只是低并发的场景,读多写少,也可以用Collections.synchronizedMap(new HashMap<>()),但这是整表锁,写操作串行,高并发下吞吐量上不去。记住一个原则:共享可变Map,要么用ConcurrentHashMap,要么不要共享。

5. 常见问题排查速查

5.1 高频问题对照表

现象可能原因排查方法解决方案
查询越来越慢自定义key的hashCode写得差,大量key落同一桶打印桶内节点数分布,看是否有超长链表重写hashCode,让更多字段参与计算
同一对象get不到值只重写equals没重写hashCode,或key可变检查key的equals和hashCode实现的一致性;检查key是否put后被修改遵守契约;key字段设final;修改后重新放入Map
写入量大时明显卡顿频繁扩容,rehash成本高看日志中resize触发次数,或计算阈值与数据量的关系构造时用expectedSize / 0.75f + 1设置初始容量
多线程写入丢数据HashMap并发不安全高并发压测观察数据丢失换成ConcurrentHashMap
遍历结果顺序不稳定HashMap本身不保证顺序换用LinkedHashMap按写入顺序遍历用LinkedHashMap;按key排序用TreeMap
put一个null键允许null key,放在桶下标0这是设计行为业务上自行区分null和空字符串
Map内存只增不减key被外部引用,无法回收检查key生存周期必要时用WeakHashMap或手动remove

5.2 一个快速查看哈希分布的小工具

排查自定义对象哈希质量的时候,我经常写一小段代码打印桶分布,直观看到冲突是否严重:

public static void printBucketStats(Map<Object, Object> map) { try { java.lang.reflect.Field field = HashMap.class.getDeclaredField("table"); field.setAccessible(true); Object[] table = (Object[]) field.get(map); if (table == null) { System.out.println("table is empty"); return; } int total = 0; int maxBin = 0; int emptyBin = 0; for (Object node : table) { int count = 0; Object p = node; while (p != null) { count++; p = ((java.lang.reflect.Field) p.getClass().getDeclaredField("next")).get(p); } if (count == 0) emptyBin++; maxBin = Math.max(maxBin, count); total += count; } System.out.printf("table length=%d, total nodes=%d, empty bins=%d, max bin=%d%n", table.length, total, emptyBin, maxBin); } catch (Exception ignored) { // 反射失败时直接放弃 } }

这段代码用反射拿到HashMap内部的table数组,统计每个桶的节点数量。如果空桶占比很高、某个桶节点数特别多,反过来说明hashCode分布不够均匀。正常业务数据下,最大的桶节点数应该是个位数,如果出现几十个节点的长链,就得回头查key的hashCode实现了。

我自己的排查经验是:HashMap相关的诡异问题,90%最后都归结到key对象身上。要么equals和hashCode写得不一致,要么key是可变对象被改坏了,要么hashCode分布太差导致桶倾斜。把这三条路排查完,基本都能定位到根因。

6. 再分享一点个人的使用体会

文章最后,分享一个我实际操作中的体会:调试HashMap问题时,不要一上来就怀疑源码或者并发,先把key的hashCode和equals摆到桌面上看一遍。这两个方法加起来不到二十行,却是整个HashMap运转的基石,绝大多数“莫名找不到”“莫名覆盖”的问题都从这里来。

另外,初始容量这个参数,很多人觉得不设也没关系,量大了才知道代价。我在一个批量导入功能里,往Map里灌了大概几万条数据,最初没设容量,导入过程能明显感觉到后半程变慢。后来按expectedSize / 0.75f + 1初始化后,耗时直接降了一截,代码只改了一行。这种性价比极高的优化,项目里多多益善。

把从key到桶下标再到节点的这条链路琢磨透了,再回头看ConcurrentHashMap的分段锁演进、TreeMap的红黑树结构,甚至是Redis里的哈希表实现,都会觉得顺理成章。数据结构这种东西,不怕不熟悉API,就怕不理解那个“根据key找到value”的核心思想。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/20 6:04:53

Apache Answer接入微信扫码登录:插件设计与部署全解析

上手 Apache Answer 的那段时间&#xff0c;我一度觉得终于找到了足够顺手的开源问答社区方案&#xff1a;安装包不大&#xff0c;界面干净&#xff0c;部署也就是起一个容器的事。真正把站点丢给同事和用户用了两周&#xff0c;我却收到了同一个反馈——“能不能直接扫码登录&…

作者头像 李华
网站建设 2026/10/20 6:04:45

Diagram-Design:面向工程认知的系统化视觉表达方法论

1. “diagram-design”不是画图软件&#xff0c;而是一套系统性视觉表达方法论很多人第一次看到“diagram-design”这个词&#xff0c;下意识会把它当成某个新出的绘图工具——比如“是不是又一个在线流程图网站&#xff1f;”“是不是Figma插件&#xff1f;”“是不是PlantUML…

作者头像 李华
网站建设 2026/10/20 6:01:39

高仿QQ空间:CSS布局与前端实战技巧全解析

看到“JavaWEB作业&#xff1a;用CSS制作高仿QQ空间”这个题目&#xff0c;我第一反应是&#xff1a;这哪是CSS作业&#xff0c;这分明是一张前端基本功的综合考卷。QQ空间整个首页浓缩了一堆经典前端场景——渐变背景、卡片分区、圆角头像、导航高亮、多栏自适应布局&#xff…

作者头像 李华
网站建设 2026/10/20 6:01:38

CSS高仿QQ空间实战:从布局到交互的完整还原方案

做JavaWEB课程作业时选了个“用CSS高仿QQ空间”的题目&#xff0c;一开始觉得不就是照葫芦画瓢画个页面嘛&#xff0c;真正动手才发现&#xff0c;这活儿比想象中讲究得多。QQ空间那个界面看着花哨&#xff0c;实则布局规整、细节密集&#xff0c;对CSS的考察非常全面——定位、…

作者头像 李华
网站建设 2026/10/20 6:01:38

Gitee代码托管全攻略:从SSH密钥到日常提交的完整指南

为什么我折腾了一圈又把代码托管从国外迁回Gitee&#xff1f;说实话&#xff0c;GitHub再快也得看你本地网络脸色&#xff0c;有时候一条git push卡到怀疑人生。这不是道听途说&#xff0c;而是我实际踩坑的结果。Gitee作为国内代码托管平台&#xff0c;俗称码云&#xff0c;核…

作者头像 李华
网站建设 2026/10/20 6:01:37

Oracle多租户架构核心解析:CDB与PDB的容器化实践

1. 多租户架构的设计思路&#xff1a;数据库整合与资源利用Oracle多租户容器数据库&#xff0c;是Oracle在12c版本推出的核心架构变革&#xff0c;官方名称是Multitenant Architecture。很多人一开始听到“容器”这个词容易发懵——实际上可以把它理解为一种“合租”模式&#…

作者头像 李华