1. 项目概述
1.1 核心需求解析
我最早被 Map 折磨,是在一次用户量暴涨的线上事故里。当时一个统计模块在高峰期忽然 CPU 飙满,日志刷出大量 ConcurrentModificationException,排查到头发现是一个看似无害的 HashMap 在一个全局方法里被多线程同时读写。那次之后我把 Java 集合框架里最常用的几个类源码从头到尾读了一遍,才发现很多背过的“八股”结论,底层其实藏着一整套精巧的设计逻辑。
这篇内容我想完整地梳理 Map 集合从顶层接口设计到 key 实现类的源码逻辑,重点放在 HashMap 上,因为它是面试、开发、线上问题排查中出现频率最高的类。同时也会把 LinkedHashMap、TreeMap、Hashtable、ConcurrentHashMap 的差异和适用场景讲透。
内容适合三类读者:一是刚学完 Java 基础、想深入集合框架源码的初学者;二是准备面试、需要系统理解 Map 底层原理的求职者;三是在实际项目中遇到过 HashMap 并发问题、或者被诡异 hashCode 坑过的开发者。读完之后,你不仅能把 Map 的“面试题”答明白,还能在真正的生产环境里做出合理的技术选型和问题排查。
1.2 原理解读思路
我打算按照“接口设计 -> 数据结构 -> 单个方法源码 -> 多线程场景 -> 实战调优”这条线来讲。先看 Map 接口定义了哪些契约,再看 HashMap 如何用数组加链表加红黑树实现这些契约,然后把 put、get、resize 这几个核心方法的源码逐行拆解,最后扩展到并发容器和日常使用中的坑。
整个过程中,我会刻意强调几个高频考点背后的推导过程:比如为什么链表长度到 8 就转红黑树,为什么扩容阈值是负载因子乘容量,为什么 Map 的容量强制是 2 的幂次。这些不是靠记忆就能拿下的知识点,理解了设计动机,写代码和排查问题都会顺手很多。
2. 整体架构:从接口到实现的层级设计
2.1 Map 接口的顶层契约
先看 Map 接口本身。它在 Java 集合框架中和 Collection 是平级的两大分支,Collection 管单列元素,Map 管键值对映射。Map 接口定义了三个核心视图方法:keySet() 返回所有键的集合,values() 返回所有值的集合,entrySet() 返回键值对实体的集合。
这三个视图是整个 Map 遍历体系的基础。实际开发里,如果你只需要遍历键,用 keySet() 就好;如果键值对都要用,直接遍历 entrySet() 更高效,因为避免了每次循环都去 map.get(key) 再做一次哈希查找的额外开销。
Map 接口还规范了几个基本操作:put(k, v) 存放键值对,get(k) 根据键取值,containsKey(k) 判断键是否存在,remove(k) 删除键值对。另外还有一个容易忽略但对源码阅读很重要的方法:putVal 之前的 hash(k),这是决定元素落在哪个桶位置的入口。
2.2 三大实现类的能力对比
Map 接口下有多个实现类,各自侧重点完全不同。这里我先给一张能力对比表,后面每个类的章节再展开细节:
| 实现类 | 底层结构 | 是否有序 | 线程安全 | 适用场景 |
|---|---|---|---|---|
| HashMap | 数组 + 链表 + 红黑树 | 无序 | 否 | 通用键值存储 |
| LinkedHashMap | HashMap + 双向链表 | 保持插入顺序或访问顺序 | 否 | 需要有序遍历、LRU 缓存 |
| TreeMap | 红黑树 | 按键自然序或自定义比较器排序 | 否 | 需要范围查询、排序遍历 |
| Hashtable | 数组 + 链表 | 无序 | 是(全表锁) | 遗留代码,不推荐使用 |
| ConcurrentHashMap | 数组 + 链表 + 红黑树 + CAS/synchronized | 无序 | 是(分段粒度锁) | 高并发场景 |
注意 LinkedHashMap 实际上是 HashMap 的子类,它重写了少量钩子方法来实现顺序记录。TreeMap 则完完全全是另一套基于红黑树的实现,和哈希完全无关。
2.3 HashMap 的底层结构设计
HashMap 在 JDK 1.8 之后的核心结构是“数组 + 链表 + 红黑树”。这个数组叫 table,每个位置叫桶(bucket),桶里要么是空的,要么是一个链表节点,要么是一棵红黑树的根节点。
为什么用这种混合结构?纯数组的问题在于 key 的哈希值范围很大,直接开一个超大数组不现实。纯链表的问题是当冲突严重时查找退化为 O(n)。所以设计思路是:先用哈希函数把 key 映射到数组下标,冲突少的场景链表就够了;一旦某个桶冲突过多,就把链表转成红黑树,把查找复杂度从 O(n) 降到 O(log n)。
数组的初始容量默认是 16,负载因子默认是 0.75。这两个参数决定了扩容阈值,threshold = capacity * loadFactor。当 size 超过 threshold 时,数组容量翻倍,所有元素重新分配位置,这个过程就是 resize。
3. 核心源码逐行拆解
3.1 hash 方法与扰动算法
先看 HashMap 里最容易被忽略但非常关键的一段代码,也就是它内部的 hash 方法。这个过程实际上是对 key 的 hashCode 做了一次二次扰动,目的是让高位的信息也能参与数组下标的计算,从而减少碰撞。
static final int hash(Object key) { int h; // key 为 null 时哈希值取 0,这是 HashMap 允许 null 键的原因 return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }关键点是h ^ (h >>> 16)这一行。假设某个 key 的 hashCode 是 32 位,当数组长度比较小(比如 16)时,计算下标用的是(n - 1) & hash,也就是只取最低的 4 位。如果多个 key 的 hashCode 高 16 位完全不同、低 16 位恰好一致,冲突率就会很高。
扰动算法把高 16 位异或到低 16 位,让高位特征也混入下标计算,冲突概率显著下降。很多人在解释 HashMap 时把这一步跳过了,但它恰恰是哈希分布均匀度的第一道保障。
3.2 put 方法的完整流程
HashMap 的 put 操作最后会进入 putVal 方法,这一步的核心逻辑可以用下面这段代码来展示。我先贴出源码,再逐段解释。
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; // 如果 table 为空,先执行 resize 初始化 if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; // 计算桶位:i = (n - 1) & hash if ((p = tab[i = (n - 1) & hash]) == null) // 当前桶为空,直接放入新节点 tab[i] = newNode(hash, key, value, null); else { Node<K,V> e; K k; // 桶中第一个节点的 hash 和 key 都和当前插入的处理相等,说明是同一个 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 { for (int binCount = 0; ; ++binCount) { if ((e = p.next) == null) { // 没找到相同 key,在链表尾部追加新节点 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 并返回旧值 if (e != null) { V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) e.value = value; afterNodeAccess(e); return oldValue; } } // 结构性修改计数加一,用于 fail-fast 迭代器检测 ++modCount; // 超过阈值就扩容 if (++size > threshold) resize(); afterNodeInsertion(evict); return null; }几个容易忽略的细节:
- put 方法对于“相同 key”的判断,是先比较 hash,再用
==或 equals 确认。也就是说,如果两个对象 hashCode 相同而 equals 不相等,它们会进入同一个桶,但不会互相覆盖。 - 树化条件并不是“链表一长就变树”,而是先检查整个 Map 的容量,如果 table 长度小于 64,会优先选择扩容而不是转树。这个设计很巧妙,因为当哈希表整体容量很小时,真正的问题是容量不够,而不是链表太长。
- afterNodeAccess 和 afterNodeInsertion 是给 LinkedHashMap 留的钩子方法。HashMap 里它们是空实现,但 LinkedHashMap 重写后用来维护按访问顺序排序的双向链表。
3.3 扩容 resize 机制拆解
扩容是所有哈希表最关键的过程。HashMap 在两种情况下会触发 resize:初始化时 table 为空,或者 size 超过 threshold。扩容的核心逻辑分两步:计算新容量和新的阈值,然后把旧数组中的每个元素重新分配到新数组。
final Node<K,V>[] resize() { Node<K,V>[] oldTab = table; int oldCap = (oldTab == null) ? 0 : oldTab.length; int oldThr = threshold; int newCap, newThr = 0; // 旧容量大于 0,说明是正常的扩容 if (oldCap > 0) { if (oldCap >= MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return oldTab; } // 新容量 = 旧容量 << 1,阈值也翻倍 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) newThr = oldThr << 1; } // 旧容量为 0 但阈值大于 0,说明是初始化时传了有界容量 else if (oldThr > 0) newCap = oldThr; // 完全默认初始化 else { newCap = DEFAULT_INITIAL_CAPACITY; newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); } // 计算新阈值 if (newThr == 0) { float ft = (float)newCap * loadFactor; newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE); } threshold = newThr; // 创建新数组 @SuppressWarnings({"rawtypes","unchecked"}) Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; table = newTab; // 旧数组中的元素迁移 if (oldTab != null) { for (int j = 0; j < oldCap; ++j) { Node<K,V> e; if ((e = oldTab[j]) != null) { oldTab[j] = null; if (e.next == null) // 只有一个节点,直接重新计算下标 newTab[e.hash & (newCap - 1)] = e; else if (e instanceof TreeNode) // 红黑树拆分成两棵子树或转换成链表 ((TreeNode<K,V>)e).split(this, newTab, j, oldCap); else { // 链表拆分成两条链,利用 oldCap 的二进制位判断 Node<K,V> loHead = null, loTail = null; Node<K,V> hiHead = null, hiTail = null; Node<K,V> next; do { next = e.next; // 关键判断:hash 与 oldCap 做位运算 if ((e.hash & oldCap) == 0) { if (loTail == null) loHead = e; else loTail.next = e; loTail = e; } else { if (hiTail == null) hiHead = e; else hiTail.next = e; hiTail = e; } } while ((e = next) != null); // 低位链留在原位置 if (loTail != null) { loTail.next = null; newTab[j] = loHead; } // 高位链移动到 j + oldCap 位置 if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; } } } } } return newTab; }链表拆分是扩容里最有意思的设计。因为数组长度从旧容量扩大到新容量时,一个元素在数组中的下标是hash & (newCap - 1)。由于 newCap 是 oldCap 的两倍,newCap - 1 相当于在 oldCap - 1 的基础上多了一个最高位的 1。所以一个元素要么待在原下标 j,要么移动到 j + oldCap,判断依据就是 hash 的对应二进制位是 0 还是 1。
这个优化让扩容时不需要重新计算每个 key 的 hash,而是用一次位运算直接分组,这也是 HashMap 容量必须保持 2 的幂次方的原因之一。
在实际业务中,扩容是非常耗时的操作,特别是 Map 中已有大量元素时。曾经有一个报表系统,启动时需要加载几十万条配置到 Map 中,默认情况会触发十几次扩容,每次扩容都涉及全量 rehash。后来改成预估容量后直接创建,启动时间从 6 秒缩短到 2 秒。
3.4 树化与退化:红黑树的引入逻辑
链表转红黑树是 JDK 1.8 引入的优化,treeifyBin 方法做了两件事:先检查容量,决定是扩容还是真正转树;如果决定转树,就把链表节点替换成 TreeNode,然后按照红黑树的规则重新组织节点。
为什么阈值是 8 而不是其他数字?HashMap 的源码注释里给了概率说明。在随机哈希且负载因子为 0.75 的情况下,一个桶中链表长度达到 8 的概率大约是千万分之六。也就是说,正常情况下几乎不可能出现这么长的链表,一旦出现了,说明哈希函数质量很差或者 key 的分布极不均衡。
红黑树的引入也带来了一个代价:树节点 TreeNode 的大小是普通链表节点的两倍左右,占用更多内存。所以当树中节点数量减少到阈值以下时,会退化为链表。退化逻辑在 remove 节点后触发,具体由 untreeify 和 split 方法配合完成。这样就形成了一套动态平衡机制:冲突多时树化保证性能,冲突少时链表化节省内存。
3.5 get 与 remove 的查找路径
get 方法的核心逻辑非常直接:根据 key 算出 hash,找到桶位,然后区分三种情况:桶为空直接返回 null,桶中第一个节点就是要找的 key 直接返回,否则遍历链表或树查找。
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; }这里有个实践启发:equals 方法是否高效直接影响链表的查找速度。如果 equals 方法写得复杂,比如在 String 之外又比较多个字段,优势不明显,但 get 效率会变差。项目里如果自定义对象作为 key,equals 和 hashCode 一定要谨慎设计,这一点后面专门讲。
remove 方法的流程和 get 高度相似,找到目标节点后根据链表还是树结构执行删除操作,同时维护 modCount 和 size。TreeMap 的 remove 则是标准的红黑树删除,涉及一系列旋转和染色操作来保持平衡。
4. 三兄弟对比:HashMap、LinkedHashMap、TreeMap
4.1 LinkedHashMap 的钩子方法与 LRU 缓存
LinkedHashMap 继承了 HashMap,核心差异是它维护了一条双向链表记录节点的插入顺序或访问顺序。它是通过重写 HashMap 的 afterNodeAccess、afterNodeInsertion 和 newNode 等钩子方法来实现的,这些方法在 HashMap 里都是空实现。
afterNodeInsertion 方法里有一个 removeEldestEntry 判断,当它返回 true 时会移除链表头部的节点。默认这个判断永远返回 false,所以 LinkedHashMap 表现得和普通 HashMap 无异。但如果你继承 LinkedHashMap 并重写 removeEldestEntry,就可以实现一个天然的 LRU 缓存:
class LRUCache<K, V> extends LinkedHashMap<K, V> { private final int capacity; LRUCache(int capacity) { super(capacity, 0.75f, true); this.capacity = capacity; } @Override protected boolean removeEldestEntry(Map.Entry<K, V> eldest) { return size() > capacity; } }注意构造方法的最后一个参数 accessOrder,传 true 表示按照访问顺序维护链表,每次 get 会把访问的节点移到链表尾部,最久未访问的节点自然集中在链表头部,当容量超限时被淘汰。
4.2 TreeMap 的排序与范围查询
TreeMap 基于红黑树实现,所有键值对按照键的自然顺序或者构造时传入的 Comparator 进行排序。它的优势在于有序性相关的操作,firstKey、lastKey、subMap、headMap、tailMap 这些方法都是 HashMap 不具备的能力。
很多场景下,TreeMap 能替代“先存入 Map 再排序”的笨办法。比如统计商品销量排名,直接用 TreeMap 并按销量降序排序,遍历一次就能拿到 TOP N 的结果。但 TreeMap 的 put 和 get 时间复杂度是 O(log n),而 HashMap 平均是 O(1),如果只是普通存取不涉及范围查询,优先选 HashMap。
4.3 Hashtable 为何被淘汰
Hashtable 是 JDK 1.0 就存在的遗留类,所有方法都用 synchronized 修饰,锁的粒度是整个哈希表。在多线程环境下,不同线程对任意键的读写都会互相竞争同一把全局锁,并发性能很差。ConcurrentHashMap 出现后,Hashtable 基本只存在于面试题中。除非是维护老系统,否则新代码里没有理由再用它。
5. 并发场景:从死循环到分段设计
5.1 HashMap 在多线程下的问题
JDK 1.7 的 HashMap 在扩容时采用头插法迁移链表,多线程并发扩容可能导致链表形成环,一旦发生环,后续的 get 操作就会陷入无限循环。这是经典的生产故障案例,很多老程序员都踩过这个坑。
JDK 1.8 改成尾插法,避免了环形链表,但 HashMap 仍然不是线程安全的。多线程同时 put 时,可能发生数据覆盖:两个线程同时计算出同一个桶位为空,都执行了 tab[i] = newNode,后写入的覆盖先写入的;或者两个线程同时触发 resize,扩容过程中旧数据迁移出现错乱。另外前面提到的 ConcurrentModificationException,是迭代过程中发现 modCount 被修改而抛出的快速失败机制。
如果只是简单地在方法上加一个外部锁,比如 synchronized(map),在某些应用场景下性能还不如直接用专门为并发设计的 Map。
5.2 ConcurrentHashMap 的锁粒度演进
JDK 1.7 的 ConcurrentHashMap 采用 Segment 分段锁,把整个哈希表分成多个 Segment,每个 Segment 内部是一张独立的哈希表,不同的 Segment 可以并发写入,锁粒度是 Segment 级别。
JDK 1.8 的 ConcurrentHashMap 放弃了 Segment,直接对桶位节点使用 synchronized 加锁,锁粒度细化到单个桶,并发度更高。结构上和 HashMap 一样是数组加链表加红黑树,同时借助 CAS 机制完成一些无锁操作。
在 JDK 1.8 的实现中,如果桶位为空,线程会通过 CAS 尝试直接放入节点,不需要加锁;只有当桶位已有节点时,才对这个桶的头节点加 synchronized 锁。扩容时利用 ForwardingNode 标记正在迁移的桶,其他线程看到这个标记会协助完成迁移,实现了多线程并发扩容。
实际项目中有个很典型的例子:一个订单服务需要缓存用户最新订单状态,请求量达到每秒数千次,最初用了 HashMap 加 synchronized 锁,TPS 上不去。换成 ConcurrentHashMap 后,锁竞争从整个 Map 缩小到单个桶,吞吐量提升非常明显。
5.3 并发容器的选型建议
选用 Map 时,根据并发度分三层判断:
- 单线程环境:直接用 HashMap,性能最高,代码最简洁。
- 读多写少、需要保持有序:用 ConcurrentSkipListMap,它是有序的并发 Map,基于跳表实现。
- 高频读写、无需排序:选 ConcurrentHashMap,锁粒度最细,支持高并发。
还有一个容易被忽略的坑:ConcurrentHashMap 不允许 null 键和 null 值,原因是它的 get 方法在并发环境下无法通过返回 null 来判断 key 是否存在,因为 null 值本身就是合法情况下不该出现的结果。这是它的一个故意设计,使用时需要注意。
6. 常见问题与排查技巧实录
6.1 问题速查表
| 问题表现 | 可能原因 | 排查办法 |
|---|---|---|
| 迭代时报 ConcurrentModificationException | 遍历过程中结构被修改 | 检查是否有其他线程在 put/remove,改用迭代器或 ConcurrentHashMap |
| CPU 100%、get 卡死 | JDK 1.7 扩容成环 | 升级到 JDK 1.8,并避免在并发环境使用 HashMap |
| 内存占用过高 | 容量设置过大或负载因子过小 | 根据实际数据量估算初始容量,避免频繁扩容 |
| key 明明存在却 get 不到 | equals 和 hashCode 方法不一致 | 检查重写规则,两个对象相等时 hashCode 必须相同 |
| 树化后性能仍差 | hashCode 严重分布不均 | 检查 key 的 hashCode 设计,避免低质量散列 |
| put 后 size 小于预期 | 不同 key 哈希碰撞合并 | 属正常现象,但若过度碰撞需优化 key 的 hashCode |
6.2 定位 Map 相关性能瓶颈的实战路径
排查 Map 性能问题时,第一步观察是否存在慢请求或 CPU 飙升。用 jstack 打印线程栈,如果看到大量线程阻塞在 Map.get 或 resize 上,基本可以锁定 Map 是瓶颈。
接着看 Map 的规模。通过 jmap 或 dump 堆导出来确认 Map 的元素数量和数组长度,如果 size 和 capacity 的比很高,说明频繁在扩容边界附近操作。此时可以打印 key 的 hashCode 分布,工具上可以写一段代码把所有 key 的哈希后 4 位做个直方图,如果集中在少数几个值,就是 hashCode 分布严重不均衡。
曾经在排查一个优惠券系统的缓存问题时,发现所有 key 是某个字符串拼接出来的,而拼出来的字符串前缀完全相同,导致 hashCode 的低位非常相似,大量 key 扎堆在少数几个桶里。后来在拼接时加了一个随机盐,碰撞率立刻降下来了。
6.3 避坑清单:自定义 key 的设计要点
自定义对象作为 Map 的 key,是最容易出现诡异 Bug 的地方,这里整理几个重要原则:
- 重写 equals 时必须重写 hashCode。两个对象 equals 为 true,hashCode 必须相等,否则会出现 get 不到、containsKey 为 false 的诡异现象。
- hashCode 不要依赖可变字段。如果用 key 的某个字段计算 hashCode,而这个字段在存入 Map 后被修改了,哈希值变化会导致元素无法被正确找到。
- String 和 Integer 是最安全的 key。它们是 final 类型且本身实现了良好的 hash 算法,大多数场景下不需要自定义 key。
- 自定义 key 时,equals 要保证对称性。也就是 a.equals(b) 和 b.equals(a) 必须一致,如果违反这条,在 Map.containsValue 时会非常难排查。
6.4 对 Map 集合学习的个人体会
读 Map 源码的最佳方式不是从头到尾硬背,而是带着问题去看。我当时是把几个核心问题列出来逐个攻破:HashMap 的容量为什么是 2 的幂、树化的条件为什么是 8、扩容时数据如何不重算哈希完成迁移、ConcurrentHashMap 的锁到底加在哪一层。问题越具体,源码读起来越有方向感。
读完源码后建议自己动手写一个简化版 HashMap,不需要支持红黑树,只需要实现数组加链表、自动扩容、get 和 put 方法。写完之后你才会真正理解 resize 为什么如此烧脑、hash 扰动为什么重要、modCount 又是如何在迭代器中发挥作用的。这套理解比背下十道面试题更重要,因为它是可以迁移到其他语言和框架的底层通用思维。