
1. 为什么我要啃HashMap这块硬骨头聊到Java集合框架HashMap几乎是绕不开的一个类。面试问、工作用、读源码绕不过甚至很多中间件的底层实现都直接或间接依赖它。我刚开始写Java那几年对HashMap的理解就停留在“数组加链表”这五个字上觉得知道个大概就行了。直到有一次线上排查一个性能问题发现某个服务在高峰期CPU飙高最后定位到是一个大HashMap在频繁扩容才意识到光知道概念远远不够。这篇文章我打算把HashMap的底层源码从头到尾拆一遍包括数据结构、哈希算法、扩容机制、红黑树转换、线程安全问题。适合有一定Java基础、想真正搞懂HashMap内部怎么运转的读者。不管你是准备面试还是工作中需要做性能调优这些内容都能直接用上。我会尽量用大白话把源码逻辑讲清楚同时给出关键参数的计算过程和实操验证方法让你看完能自己动手验证而不是停留在“背八股”的层面。2. HashMap整体结构设计与核心思路拆解2.1 从数组到哈希表的演进逻辑先说最基础的问题为什么需要HashMap数组查询快但插入删除慢链表插入删除快但查询慢。哈希表的核心思路是用空间换时间通过哈希函数把key映射到数组的某个位置理想情况下查询和插入都能做到O(1)。HashMap就是Java对这个思路的工程实现。但理想归理想实际工程中会遇到两个核心问题。第一个是哈希冲突不同的key可能算出同一个数组下标。第二个是扩容数组容量固定元素多了冲突概率就高必须扩容重新分布。HashMap的整个源码设计几乎都是围绕这两个问题在打转。我个人的理解是看HashMap源码要抓住一条主线它如何在时间和空间之间做权衡。负载因子0.75、链表长度到8转红黑树、扩容翻倍这些数字背后都有明确的数学和工程考量不是随便拍脑袋定的。2.2 底层数据结构的真实面貌很多人说HashMap是“数组链表红黑树”这话没错但太笼统。具体来说它的核心是一个Node数组叫table。每个Node包含hash、key、value、next四个字段。当多个key映射到同一个下标时就用next指针串成链表。当链表长度达到阈值且数组容量足够时链表会转换成红黑树把最坏情况下的查询从O(n)降到O(log n)。这里有个细节值得注意红黑树节点TreeNode是Node的子类它额外维护了parent、left、right、red等字段同时还保留了next指针。也就是说TreeNode既是一棵红黑树的节点也是一条链表的节点。这个设计是为了在扩容拆分时既能按树的方式处理也能按链表的方式遍历非常巧妙。2.3 关键参数的工程考量HashMap有几个核心参数我列个表对比一下它们的默认值和作用参数名默认值作用为什么是这个值DEFAULT_INITIAL_CAPACITY16数组初始容量2的幂方便用位运算取模DEFAULT_LOAD_FACTOR0.75负载因子空间与时间的折中泊松分布验证TREEIFY_THRESHOLD8链表转树阈值泊松分布下概率极低避免频繁转换UNTREEIFY_THRESHOLD6树转链表阈值留缓冲区间防止频繁来回转换MIN_TREEIFY_CAPACITY64转树最小容量容量太小时优先扩容而非转树负载因子为什么是0.75这个值是通过泊松分布计算出来的。在理想哈希下一个桶中元素个数服从参数为0.5的泊松分布当负载因子为0.75时桶中元素达到8个的概率约为千万分之六。也就是说正常情况下几乎不会触发红黑树转换转树是为了应对极端哈希冲突的兜底方案。3. 核心源码细节逐行拆解与实操要点3.1 哈希函数与下标计算HashMap的哈希函数不是直接用key的hashCode而是做了一次扰动static final int hash(Object key) { int h; return (key null) ? 0 : (h key.hashCode()) ^ (h 16); }为什么要异或高16位因为数组长度通常不大下标计算是(n - 1) hash只用到了hash的低位。如果直接用hashCode高位信息就浪费了。把高16位异或到低16位相当于让高位也参与运算减少冲突概率。下标计算用(n - 1) hash而不是hash % n是因为n是2的幂位运算比取模快得多。这也是为什么HashMap容量必须是2的幂。这个设计在扩容时也有妙用后面会讲。注意如果你自定义对象作为key一定要正确重写hashCode和equals方法。hashCode决定落在哪个桶equals决定在桶里找哪个元素两者缺一不可。3.2 put方法的完整流程put方法是HashMap最核心的方法我把它拆成几个关键步骤计算key的hash值如果table为空或长度为0先初始化计算下标如果该位置为空直接新建Node放入如果该位置不为空判断是链表还是红黑树链表则遍历找到相同key就覆盖没找到就尾插红黑树则调用putTreeVal插入后判断链表长度是否达到8达到则尝试转树元素总数加一判断是否需要扩容源码里有个细节链表插入用的是尾插法不是头插法。JDK 1.7用的是头插法1.8改成了尾插。为什么改因为头插法在多线程扩容时可能形成环形链表导致死循环。尾插法虽然也没解决线程安全问题但至少不会成环。3.3 扩容机制的数学原理扩容是HashMap最复杂的部分。触发条件是元素个数超过capacity * loadFactor。扩容后容量翻倍所有元素重新分布。这里有个精妙的设计扩容后元素的新下标要么是原下标要么是原下标加原容量。为什么因为容量是2的幂翻倍后相当于多了一个高位。判断这个高位是0还是1就能决定元素留在原位还是移到新位置。源码里用(e.hash oldCap) 0来判断非常高效。if ((e.hash oldCap) 0) { // 留在低位链表 } else { // 移到高位链表 }这个设计避免了重新计算每个元素的hash和下标大大提升了扩容效率。3.4 红黑树转换的触发条件链表转红黑树需要同时满足两个条件链表长度达到8且数组容量达到64。如果容量不够64优先扩容而不是转树。为什么因为容量小时冲突多是因为数组太短扩容能更有效地分散元素而转树的结构维护成本较高。转树的过程比较复杂涉及链表转双向链表、再逐步构建红黑树。这里不展开每一行代码但你要知道TreeNode保留了next指针所以树和链表可以互相转换。当扩容拆分后树节点少于6个会退化成链表。实操心得如果你发现某个HashMap频繁转树和退化说明哈希函数分布不好或者容量设置不合理。优先检查key的hashCode实现。4. 完整实操验证与关键环节复现4.1 用反射验证容量与阈值变化光看源码不够直观我写了一段反射代码来验证HashMap的容量和阈值变化import java.lang.reflect.Field; import java.util.HashMap; public class HashMapDebug { public static void main(String[] args) throws Exception { HashMapString, Integer map new HashMap(); // 第一次put触发初始化 map.put(a, 1); printInternal(map, after first put); // 插入到超过阈值触发扩容 for (int i 0; i 12; i) { map.put(key i, i); } printInternal(map, after 13 elements); } static void printInternal(HashMap?, ? map, String stage) throws Exception { Field tableField HashMap.class.getDeclaredField(table); tableField.setAccessible(true); Object[] table (Object[]) tableField.get(map); Field thresholdField HashMap.class.getDeclaredField(threshold); thresholdField.setAccessible(true); int threshold (int) thresholdField.get(map); System.out.println(stage - capacity: (table null ? 0 : table.length) , threshold: threshold); } }运行结果会显示第一次put后容量16、阈值12插入13个元素后容量32、阈值24。这验证了扩容触发条件是size threshold。4.2 模拟哈希冲突观察链表转树要观察链表转红黑树需要构造大量哈希冲突的key。我写了一个简单的Key类让它的hashCode总是返回同一个值class BadKey { int id; BadKey(int id) { this.id id; } Override public int hashCode() { return 1; } // 故意制造冲突 Override public boolean equals(Object o) { return o instanceof BadKey ((BadKey) o).id id; } }然后往HashMap里插入10个BadKey通过反射查看table[1]的类型。当容量达到64且链表长度到8时你会看到节点类型从Node变成TreeNode。这个实验能帮你直观理解转树条件。4.3 扩容时元素分布的验证扩容后元素要么留原位要么移到原位加oldCap这个结论可以用代码验证。插入若干key后触发扩容打印每个元素的下标变化。你会发现新下标和旧下标的关系完全符合hash oldCap的规律。注意验证时要用容量为2的幂的HashMap并且key的hash要分布均匀否则观察效果不明显。5. 常见问题与排查技巧实录5.1 线程安全问题与表现HashMap不是线程安全的多线程并发put可能导致数据丢失、size不准JDK 1.7还可能形成环形链表导致CPU 100%。我实际遇到过服务CPU飙高jstack看到大量线程卡在HashMap.get上最后定位到是共享HashMap被并发修改。解决方案很简单多线程场景用ConcurrentHashMap。它用CAS加synchronized保证线程安全而且锁粒度更细性能比Hashtable好得多。5.2 扩容导致的性能抖动如果提前知道要存多少元素最好在创建时指定初始容量。计算公式是expectedSize / 0.75 1。比如要存1000个元素初始容量设为1334向上取最近的2的幂就是2048。这样能避免多次扩容。我踩过的坑一个服务启动时加载配置到HashMap没指定容量结果加载过程中扩容了七八次启动时间多了好几百毫秒。后来指定容量后启动明显变快。5.3 常见问题速查表问题现象可能原因排查方法解决方案get返回null但key存在hashCode或equals没重写检查key类正确重写两个方法CPU飙高并发修改或哈希冲突严重jstack看堆栈换ConcurrentHashMap或优化hash内存占用大容量设置过大或负载因子低反射看table长度调整初始容量和负载因子遍历顺序不稳定HashMap无序正常现象需要有序用LinkedHashMap频繁转树退化哈希分布差统计桶中元素数优化hashCode实现5.4 几个容易被忽略的细节第一个HashMap允许一个null key放在table[0]位置。因为null的hash被定义为0下标计算后就是0。第二个keySet和values返回的是视图修改会反映到原map。第三个迭代过程中修改map会抛ConcurrentModificationException要用Iterator的remove方法。实操心得如果你需要频繁遍历HashMap考虑用entrySet而不是keySet。keySet遍历时每次都要get(key)多了一次查找开销。6. 从源码到实践的几点个人体会啃完HashMap源码后我最大的感受是好的工程实现不是追求理论最优而是在各种约束下找平衡。负载因子0.75、转树阈值8、容量必须是2的幂这些数字背后都是数学计算和实际测试的结果。另外看源码一定要动手验证。我上面给的反射代码和BadKey实验你花半小时跑一遍比看十遍文章都管用。很多细节只有自己打印出来才会真正记住。最后分享一个小技巧如果你在面试或工作中需要快速判断一个HashMap的健康状况可以用反射拿到table数组统计每个桶的元素个数。如果发现某个桶特别长说明哈希分布有问题优先检查key的hashCode实现。这个方法我在排查线上问题时用过好几次很直接。