学习 Java 中 HashMap 的源码是深入理解哈希表数据结构、Java 集合框架设计思想的重要途径。下面从核心原理、关键属性、核心方法实现等方面,带你逐步剖析 JDK 8 及以上版本的 HashMap 源码(注:JDK 8 是 HashMap 实现的重要转折点,引入了红黑树优化,以下分析以 JDK 8 为基础)。
一、HashMap 核心原理概览
HashMap 是基于 哈希表 实现的 Map 接口,存储键值对(key-value),允许 key 和 value 为 null(key 仅允许一个 null),且无序(插入顺序与遍历顺序不一致)。
其核心思想是:
- 哈希函数 :通过
key的hashCode()计算哈希值,确定元素在数组中的存储位置(桶索引)。 - 解决哈希冲突 :当多个
key计算出相同的桶索引时,JDK 8 采用 链表 + 红黑树 结合的方式:- 当链表长度 <= 8 时,用链表存储(查询时间复杂度 O(n))。
- 当链表长度 > 8 时,转为红黑树(查询时间复杂度 O(log n))。
- 动态扩容 :当元素数量(
size)超过 负载因子(loadFactor)× 数组长度(capacity) 时,触发扩容(数组长度翻倍),重新计算所有元素的存储位置(rehash)。
二、关键属性与常量
先看 HashMap 类中定义的核心属性和常量,理解其底层存储结构的基础:
java
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable {
// 1. 常量
// 默认初始容量(必须是 2 的幂):16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 16
// 最大容量(2^30)
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认负载因子:0.75(减少哈希冲突的概率)
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 链表转红黑树的阈值:当链表长度 > 8 时转树
static final int TREEIFY_THRESHOLD = 8;
// 红黑树转链表的阈值:当树节点数 < 6 时转链表(避免频繁转换)
static final int UNTREEIFY_THRESHOLD = 6;
// 最小树化容量:当数组长度 < 64 时,即使链表长度超 8,也先扩容而非转树
static final int MIN_TREEIFY_CAPACITY = 64;
// 2. 核心存储结构:哈希桶数组(数组 + 链表/红黑树)
// 类型是 Node<K,V>[],Node 是链表节点;若转树则为 TreeNode(继承 Node)
transient Node<K,V>[] table;
// 3. 其他关键属性
// 元素数量(key-value 对的个数)
transient int size;
// 结构修改次数(用于迭代器的快速失败机制)
transient int modCount;
// 扩容阈值(= capacity × loadFactor),当 size 超过此值时触发扩容
int threshold;
// 负载因子(可在构造方法中指定)
final float loadFactor;
}
table数组 :HashMap的核心存储容器,每个元素是一个Node节点(链表节点)或TreeNode节点(红黑树节点)。- 容量(capacity) :
table数组的长度,必须是 2 的幂(原因后面解释),默认 16。 - 负载因子(loadFactor):控制哈希表的疏密程度,默认 0.75(平衡空间和时间效率)。
- 阈值(threshold) :触发扩容的临界值,初始为
DEFAULT_INITIAL_CAPACITY × DEFAULT_LOAD_FACTOR = 12。
三、哈希函数与桶索引计算
HashMap 中,key 的存储位置(桶索引)由两步计算得出,目的是减少哈希冲突:
1. 计算 key 的哈希值(hash() 方法)
java
static final int hash(Object key) {
int h;
// 若 key 为 null,哈希值为 0;否则取 key 的 hashCode(),并将高 16 位与低 16 位异或
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
- 作用:将
key的hashCode()(32 位整数)的高 16 位与低 16 位进行异或,混合哈希值的高位和低位,减少因低位重复导致的哈希冲突(尤其在数组长度较小时,高位无法参与索引计算,通过异或让高位信息融入低位)。
2. 计算桶索引(i = (n - 1) & hash)
通过哈希值计算数组索引时,HashMap 没有用取模运算(hash % n),而是用:
java
int index = (table.length - 1) & hash;
- 原因:当
n(数组长度)是 2 的幂时,n - 1的二进制是全 1(如n=16时,n-1=15即1111),此时(n-1) & hash等价于hash % n,但位运算效率更高。 - 这也解释了为什么
capacity必须是 2 的幂:保证索引计算的均匀性,避免某些位置永远无法被使用。
四、核心方法解析
1. 构造方法
HashMap 有三个主要构造方法,用于初始化容量和负载因子:
java
// 1. 无参构造:使用默认容量(16)和默认负载因子(0.75)
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // 其他属性默认初始化
}
// 2. 指定初始容量:使用默认负载因子
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
// 3. 指定初始容量和负载因子
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);
// 初始容量最大不超过 MAXIMUM_CAPACITY(2^30)
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " + loadFactor);
this.loadFactor = loadFactor;
// 计算初始阈值:将 initialCapacity 向上调整为最接近的 2 的幂(如 initialCapacity=10 → 16)
this.threshold = tableSizeFor(initialCapacity);
}
// 辅助方法:返回 >= 给定值的最小 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;
}
- 注意:
table数组(哈希桶)并非在构造方法中初始化,而是在第一次插入元素时(put方法)才真正初始化(延迟初始化,节省空间)。
2. put() 方法(核心中的核心)
put 方法用于添加键值对,流程较复杂,可分为以下步骤:
java
public V put(K key, V value) {
// 调用 putVal 方法,传入 key 的哈希值、key、value,其他参数默认
return putVal(hash(key), key, value, false, true);
}
/**
* 真正执行插入的核心方法
* @param hash key 的哈希值
* @param key 键
* @param value 值
* @param onlyIfAbsent 若为 true,则仅当 key 不存在时才插入(不覆盖已有值)
* @param evict 用于 LinkedHashMap 的标志,HashMap 中无实际意义
* @return 旧值(若 key 已存在)或 null
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 步骤 1:若 table 未初始化(null 或长度 0),则初始化 table(resize())
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 步骤 2:计算桶索引 i = (n-1) & hash,若桶为空(p == null),直接新建节点放入
if ((p = tab[i]) == null)
tab[i] = newNode(hash, key, value, null);
else { // 步骤 3:桶不为空(存在哈希冲突)
Node<K,V> e; K k;
// 3.1 若桶中第一个节点的 hash 和 key 与当前 key 相同,记录该节点(e = p)
if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 3.2 若桶是红黑树(p 是 TreeNode),则调用树的插入方法
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 3.3 若桶是链表,遍历链表
else {
for (int binCount = 0; ; ++binCount) {
// 3.3.1 若遍历到链表尾部(e = p.next == null),新建节点插入尾部
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 若链表长度超过 TREEIFY_THRESHOLD(8),则尝试转红黑树(treeifyBin)
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 因为从 0 开始计数
treeifyBin(tab, hash);
break;
}
// 3.3.2 若链表中存在相同 key,跳出循环(e 已记录该节点)
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e; // 移动到下一个节点
}
}
// 步骤 4:若存在相同 key 的节点(e != null),则覆盖旧值
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e); // 空方法,供 LinkedHashMap 重写
return oldValue;
}
}
// 步骤 5:若插入了新节点,修改次数 +1,检查是否需要扩容
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict); // 空方法,供 LinkedHashMap 重写
return null;
}
put 方法核心流程总结:
- 若哈希桶数组未初始化,先调用
resize()初始化。 - 计算桶索引,若桶为空,直接插入新节点。
- 若桶不为空:
- 若第一个节点与当前
key相同,直接覆盖。 - 若桶是红黑树,调用树的插入方法。
- 若桶是链表,遍历链表:
- 若找到相同
key,覆盖旧值。 - 若未找到,插入链表尾部,若长度超 8 则尝试转红黑树(转树前会检查数组长度,若 <64 则先扩容)。
- 若找到相同
- 若第一个节点与当前
- 插入新节点后,若
size超过阈值,调用resize()扩容。
3. resize() 方法(扩容)
resize 是 HashMap 中最复杂的方法之一,负责初始化哈希桶或在容量不足时扩容(数组长度翻倍),并重新计算所有元素的存储位置(rehash)。
java
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
// 情况 1:旧数组已初始化(oldCap > 0)
if (oldCap > 0) {
// 若旧容量已达最大值(2^30),则阈值设为 Integer.MAX_VALUE,不再扩容
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 否则,新容量 = 旧容量 × 2(仍为 2 的幂),新阈值 = 旧阈值 × 2
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
// 情况 2:旧数组未初始化,但旧阈值 > 0(通常是通过带参构造方法指定了初始容量)
else if (oldThr > 0)
newCap = oldThr;
// 情况 3:旧数组未初始化,且旧阈值 = 0(无参构造),使用默认值
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;
// 创建新的哈希桶数组(容量为 newCap)
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
// 若旧数组不为 null,将旧数组中的元素转移到新数组中
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null; // 帮助 GC
// 若桶中只有一个节点,直接计算新索引并放入新数组
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 { // preserve order
Node<K,V> loHead = null, loTail = null; // 索引不变的链表(low 链)
Node<K,V> hiHead = null, hiTail = null; // 索引 = 旧索引 + 旧容量的链表(high 链)
Node<K,V> next;
do {
next = e.next;
// 核心:通过 hash & oldCap 判断新索引(旧容量是 2 的幂,二进制只有一位为 1)
// 若结果为 0,新索引 = 旧索引;否则新索引 = 旧索引 + 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);
// 将 low 链放入新数组的旧索引位置
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
// 将 high 链放入新数组的(旧索引 + oldCap)位置
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
resize 核心逻辑:
- 扩容后容量为原来的 2 倍(保证仍是 2 的幂)。
- 转移旧元素到新数组时,利用
hash & oldCap判断新索引:- 若结果为 0,新索引 = 旧索引(low 链)。
- 若结果非 0,新索引 = 旧索引 + 旧容量(high 链)。
- 无需重新计算所有节点的哈希,效率更高。
- 红黑树在转移时可能拆分为两个链表或保持树结构(若节点数不足则转链表)。
4. get() 方法
get 方法用于根据 key 获取对应的 value,流程相对简单:
java
public V get(Object key) {
Node<K,V> e;
// 调用 getNode 方法,传入 key 的哈希值和 key
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; // 未找到
}
get 流程:
- 计算
key的哈希值和桶索引。 - 若桶为空,返回
null。 - 若桶不为空:
- 检查第一个节点是否匹配,匹配则返回。
- 若为红黑树,调用树的查找方法。
- 若为链表,遍历链表查找匹配节点。
五、红黑树相关(TreeNode)
JDK 8 中,TreeNode 继承自 Node,并实现了红黑树的结构(平衡二叉树的一种),用于优化长链表的查询效率。核心方法包括:
putTreeVal:红黑树的插入。getTreeNode:红黑树的查找。split:扩容时红黑树的拆分。- 红黑树的旋转(
rotateLeft、rotateRight)和着色调整,保证树的平衡。
红黑树的实现较为复杂,核心是通过 节点颜色(红/黑) 和 旋转操作 维持树的平衡,确保查询、插入、删除的时间复杂度为 O(log n)。
六、总结与注意事项
- 线程不安全 :
HashMap是非线程安全的,多线程环境下可能导致死循环(扩容时)或数据不一致,需使用ConcurrentHashMap替代。 - 迭代器快速失败 :
modCount记录结构修改次数,迭代过程中若结构被修改,会抛出ConcurrentModificationException。 - key 的哈希值不可变 :若
key是自定义对象,需重写hashCode()和equals(),且保证key的哈希值在存储期间不变(否则可能无法找到元素)。 - 性能影响因素:初始容量和负载因子影响性能,容量过小会导致频繁扩容,过大则浪费空间;负载因子过高会增加哈希冲突概率,过低则空间利用率低。
通过阅读源码,不仅能理解 HashMap 的工作原理,还能学习到哈希表设计、冲突解决、动态扩容等经典算法思想,以及 Java 中的优化技巧(如位运算、延迟初始化等)。建议结合调试工具,跟踪 put、resize 等方法的执行过程,加深理解。