
给新手讲清楚HashMapJava入门阶段的很多事情一下子就能通。这个数据结构核心就一句话通过key找到value。登录系统里拿用户ID查用户信息、订单统计里按日期聚合金额、缓存场景里用请求URL换响应结果背后全是同一套逻辑——给一个key立刻得到对应的value。偏偏很多人项目里用了很久HashMap一问为什么必须重写equals和hashCode、为什么容量默认16还必须是2的幂、为什么有时候写入几十万条数据就卡顿就答不上来了。这篇文章就把这条从key到value的完整链路彻底掰开顺便把源码、性能坑、排查方法一次说清。适合谁看正在啃集合源码的初学者、准备面试的人以及写代码时觉得HashMap“时而快时而慢”但说不清原因的开发者。我会把几个关键概念用厨房、图书馆这种生活场景做类比再配一套可直接落地的操作建议。1. 先从“key找value”这个需求说起1.1 数组的尴尬和链表的慢假设你现在要做一个用户系统用户数据有ID、姓名、手机号。最常见的需求是给我一个用户ID把那行用户信息取出来。如果数据用数组存你得知道ID对应的下标比如第0个格子里存ID1001的用户第1个格子存ID1002的用户那就只能强行为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) hashn是数组长度。这里用按位与代替取模%原因是位运算比取模快得多。但要成立前提是n必须是2的幂。当n16时n-115二进制是1111n32时n-131二进制是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) { NodeK,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 { NodeK,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 ((TreeNodeK,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) { NodeK,V e; return (e getNode(hash(key), key)) null ? null : e.value; } final NodeK,V getNode(int hash, Object key) { NodeK,V[] tab; NodeK,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 ((TreeNodeK,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会出大问题。假设你有一个订单类Orderequals比较的是orderNo但hashCode还是Object的默认实现——基于对象内存地址。两个orderNo完全一样的订单对象equals返回true但hashCode不同。第一次put进去用的是对象A第二次get时传入对象BB的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; MapString, Object map new HashMap((int) (expectedSize / 0.75f) 1);计算下来初始容量设置成134HashMap会自动向上取整为256阈值变成256×0.75192远超100整个过程不会触发扩容。如果直接写new HashMap(100)容量会向上取成128阈值是96存到第96个元素时还是得扩容一次等于白打算。加载因子0.75是JDK团队在时间成本和空间成本里取的平衡点。调小到0.5桶更空、冲突更少、查询更快但内存浪费严重调大到1.0空间利用率高但碰撞概率上升链表变长查询变慢。普通业务场景不要动这个值真要调先压测再调。4.2 自定义对象当keyequals和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时两个线程可能在同一桶位置写入后写的覆盖先写的更麻烦的是扩容阶段多线程同时触发resizeJDK 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排序用TreeMapput一个null键允许null key放在桶下标0这是设计行为业务上自行区分null和空字符串Map内存只增不减key被外部引用无法回收检查key生存周期必要时用WeakHashMap或手动remove5.2 一个快速查看哈希分布的小工具排查自定义对象哈希质量的时候我经常写一小段代码打印桶分布直观看到冲突是否严重public static void printBucketStats(MapObject, 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”的核心思想。