在没有排序或额外索引的数组、链表中,按值查找最坏需要遍历全部元素。哈希表(hash table)通过哈希值定位桶,在哈希分布良好时,基本查找可以接近常数时间;这不代表所有输入下都能保证 O(1)。
HashMap是基于哈希表的 Map 接口的实现。此实现提供所有可选的映射操作,并允许使用 null 值和 null 键。(除了非同步和允许使用 null 之外,HashMap 类与 Hashtable 大致相同。)此类不保证映射的顺序,特别是它不保证该顺序恒久不变。
本文按源码顺序看 HashMap:数组与链表的数据结构、构造函数、put 和扩容、get,最后是迭代器如何用 modCount 检查结构修改。末尾补充后续 JDK 的变化。
版本说明(2026-10-13 核查):本文保留 2015 年的历史源码分析,以下实现按 OpenJDK
jdk7u21-b11核对。构造时分配数组、Entry链表和备用哈希等细节已不适用于所有后续版本,连 Java 7 的更新版也有差异。源码片段用于阅读 JDK 内部实现,不是可直接复制运行的独立类;Android 的实现也需按对应系统的 libcore 源码核对。
1. HashMap的数据结构:
这个版本的 HashMap 使用数组和链表:数组保存各个桶的链表头,空桶为 null。下面先列出 Entry 的字段,方法见链接中的完整源码。

1 | static class Entry<K,V> implements Map.Entry<K,V> { |
Entry 是 HashMap 中包级私有的静态嵌套类,实现 Map.Entry<K,V>,next 指向下一个节点。key 引用是 final,不等于 key 对象不可变;作为键存入后,不应修改会影响其 hashCode() 或 equals() 的状态。
2.构造函数
HashMap的构造函数有四个:
HashMap()— 构造一个具有默认初始容量 (16) 和默认加载因子 (0.75) 的空 HashMap。HashMap(int initialCapacity)— 构造一个带指定初始容量和默认加载因子 (0.75) 的空 HashMap。HashMap(int initialCapacity, float loadFactor)— 构造一个带指定初始容量和加载因子的空 HashMap。HashMap(Map<? extends K,? extends V> m)— 构造一个映射关系与指定 Map 相同的新 HashMap
就两种,一个是指定初始容量和加载因子,一个是用一个给定的映射关系生成一个新的HashMap。说一下第一种。
1 | /** |
initialCapacity 是请求的初始桶数,不是可无扩容容纳的键值对数量。此版本在构造时分配数组:负数被拒绝,超过 MAXIMUM_CAPACITY 的请求先被截到 1 << 30,然后取不小于请求值的最小 2 的幂;请求 0 时数组长度为 1。默认容量为 16,默认加载因子为 0.75。
构造时按 capacity * loadFactor 等表达式计算 threshold,但不能把 (1 << 30) + 1 当成整个生命周期的阈值上限:resize 到达最大容量后会改设为 Integer.MAX_VALUE。1 << 30 也只是实现允许的桶数上限,不保证实际堆内存能分配这么大的数组。
下面的 addEntry 只有在新增键、插入前 size >= threshold,且目标桶非空时才调用扩容。因此不能简化成“数量一到阈值就立刻扩容”。
当加载因子小于等于 0,或者为 NaN 时,构造函数会抛出 IllegalArgumentException。NaN 是浮点类型中的特殊值。
3.put操作
1 | /** |
HashMap 允许 null 键,也允许 null 值。put 先判断 key 是否为 null,是则走专门的处理路径。
1 | /** |
null 键位于 table[0] 的链表中;只有找到 key 为 null 的节点才替换其 value,否则新增节点。并不是只要第一个桶非空就替换它。稍后会看到 addEntry 函数。
PS:null 键放在 table[0],为什么还要遍历这条链表?
回到 put:key 非空时先调用 hash(key)。这里不仅使用 key 的 hashCode(),还会混合高位与低位;启用备用哈希时另有种子和 String 专用路径。补上这个版本的完整方法:
1 | final int hash(Object k) { |
useAltHashing、hashSeed 和 sun.misc.Hashing 属于此版本的内部实现,不是应用代码应调用的公共 API。此标签的备用哈希默认阈值为 Integer.MAX_VALUE,不能假定一般 map 已启用该分支。得到哈希值后,indexFor 决定桶下标:
1 | /** |
indexFor 虽然只有一行,写得却很巧妙。还记得前面数组长度的限制吗?length 是正的 2 的幂,length - 1 的低位全是 1,按位与便能取出这些低位,得到 0 到 length - 1 的下标。不能不加条件地把它等同于 Java 的 h % length:h 为负数时,% 的结果可能为负,而这里的下标不会。若长度不是 2 的幂,这个掩码也不能均匀覆盖所有桶。是否更快则要看具体运行时,不能仅凭运算符作性能承诺。
前面的问题在这里就有答案了。因为indexFor函数返回值的范围是0到(length-1),所以可能会有key值不是null的Entry存到table[0]中,所以前面还是需要遍历链表的。
得到桶下标后遍历链表:哈希值相同仍不代表 key 相等,还要比较对象身份或 equals()。找到已有 key 就替换 value 并返回旧值,这条路径不会执行 modCount++。只有没找到 key、准备新增映射时,才增加结构修改计数并调用 addEntry。后面的 fail-fast 就用到这个计数。
1 | /** |
addEntry 接收 hash、key、value 和桶下标,最后一个参数来自 indexFor(hash, table.length),不是传入 table.length - 1。只有 size >= threshold 且目标桶非空时才尝试把容量翻倍;目标桶为空时,新增节点不会延长已有链表,因此这个版本可以推迟扩容。扩容后会重新计算当前待插入键的 hash 和桶下标,再调用 createEntry。
1 | /** |
resize 先检查是否已到最大容量 1 << 30。到达后只把阈值设为 Integer.MAX_VALUE,不再扩大数组。否则,在这里的 addEntry 调用路径上创建容量翻倍的新数组;由于原容量是 2 的幂且不超过上限,翻倍不会越过该上限。接着调用 transfer 迁移已有节点。
1 | /** |
transfer 把旧节点迁移到新数组。如果扩容时备用哈希从关闭变为开启,rehash 才为 true,已有非空 key 才会重新调用 hash(key);否则使用节点缓存的 hash 计算新下标。
容量翻倍不代表每个节点都会换桶:hash 不变时,新下标要么仍是旧下标,要么是旧下标加旧容量。这里采用头插法,迁移过程中可能反转同一链表中的节点次序。回到 resize 后再替换 table 并更新阈值。无论是否扩容,应用都不应依赖 HashMap 的迭代顺序。
按预期数据规模设置容量可以减少迁移,但盲目放大也会增加内存和遍历成本;遍历要扫描桶数组及节点,不只取决于 size。
回到函数addEntry 中,判断完是不是需要resize后就需要创建一个新的Entry了。
1 | /** |
调用createEntry函数,参数跟addEntry一样,第一个是key的hash值,第二个第三个分别是key和value,最后一个是这个key在table中的位置。这里的操作与Entry的构造函数有关系。
1 | /** |
构造函数中传入一个Entry对象,并把它当做这个新生成的Entry的next。所以createEntry函数中的操作相当于把table[bucketIndex]上的链表拿下来,放在新的Entry后面,然后再把新的Entry放到table[bucketIndex]上。

到这里整个put函数算是结束了。如果新插入的K,V则会返回null。
4.get操作
1 | /** |
也是先判断key是不是null,做特殊处理。直接上代码,不赘述。
1 | /** |
key不是null则会调用getEntry函数,并返回一个Entry对象,如果不是null,就返回entry的value。
1 | /** |
getEntry 计算 hash 和桶下标,再沿链表查找;匹配则返回节点,否则返回 null。外层 get 返回节点的 value。因此 get(key) == null 可能是 key 不存在,也可能是它映射到 null;需要区分时再检查 containsKey(key)。这两次调用本身不构成并发下的原子操作。
5. Fail-Fast机制
1 | /** |
HashMap 不是线程安全的。fail-fast 用来发现迭代期间的结构修改,例如通过 map 新增或删除映射;同一个线程在迭代器之外修改 map,也可能触发检查,并不需要另一个线程。仅替换已有 key 的 value 不属于这里的结构修改。
modCount 是普通的 int 字段,不是 volatile,也没有提供同步;transient 只与默认序列化有关,不能保证线程间可见性。迭代器初始化时保存 expectedModCount,后续在 nextEntry() 和 remove() 中比较计数。补上完整的内部迭代器类:
1 | private abstract class HashIterator<E> implements Iterator<E> { |
可以看到,hasNext() 只判断还有没有节点,并不检查计数。迭代器自己的 remove() 删除节点后会更新 expectedModCount,因此这种允许的删除方式不会因为自己刚完成的修改而失败。
注意:fail-fast 只是尽力检测程序错误,不能保证发现所有未同步的并发修改,也不能依赖 ConcurrentModificationException 保证正确性。共享读写必须有适当的同步或并发容器设计;替换 value 不增加计数,也不意味着这类跨线程写入自动安全。
6. 后续 JDK 的变化(2026 年补充)
OpenJDK jdk7u80-b15:普通空 map 的构造函数不再直接分配桶数组,
table先指向EMPTY_TABLE,首次put调用inflateTable。不能把 7u21 的分配时机推广到所有 Java 7 更新版。OpenJDK jdk8-b132:节点改为
Node,碰撞桶可转换为红黑树;TREEIFY_THRESHOLD = 8、MIN_TREEIFY_CAPACITY = 64,但是否树化还取决于插入路径及容量,不能说“第 8 个节点必定树化”。容量不足时先扩容。普通链表扩容按高低两组拆分,并保留各组内的相对次序,已不是本文的迁移头插法。Java SE 19 起的 API:新增
HashMap.newHashMap(expectedMappings),参数是预期映射数量,使用默认加载因子;与构造函数的初始桶容量参数区分。此处按 Java SE 25 API 文档核对。
这些实现变化没有把 HashMap 变成有序或线程安全的容器。阅读现代 JDK 时,应按对应版本的 putVal、resize、treeifyBin 继续追踪;Android 项目还要核对实际平台 API 和 libcore,不能只用编译所选 JDK 的版本推断设备上的行为。
参考
- 原文参考:《Core Java》(原稿未注明版次)
- OpenJDK 7u21-b11 HashMap.java
- OpenJDK 7u80-b15 HashMap.java
- OpenJDK 8-b132 HashMap.java
- Java SE 25 HashMap API
关于我 && 博客
下面是个人的介绍和相关的链接,期望与同行的各位多多交流,三人行,则必有我师!
- 博主个人介绍 :里面有个人的微信和微信群链接。
- 本博客内容导航 :个人博客内容的一个导航。
- 个人整理和搜集的优秀博客文章 - Android 性能优化必知必会 :欢迎大家自荐和推荐 (微信私聊即可)
- Android性能优化知识星球:付费社群已于 2026 年停止运营,不再招收新成员;此处保留历史介绍。
一个人可以走的更快 , 一群人可以走的更远
