HashMap:源码
HashMap 作为 Java 集合框架中最常用的键值对存储结构,其高效的查找、插入性能背后是精妙的哈希表设计与动态扩容机制。本文将从基础概念出发,深入解析 JDK 1.8 前后 HashMap 的底层结构差异、核心源码实现及关键机制(如哈希计算、冲突解决、扩容策略等),带你全面掌握 HashMap 的工作原理。
一、HashMap 核心概念
1. 基本定义与特性
HashMap 是基于哈希表实现的 Map
接口实现类,主要用于存储键值对(key-value),其核心特性包括:
- 非线程安全:多线程环境下并发修改可能导致数据不一致(如扩容时的链表循环),需通过
Collections.synchronizedMap()
或ConcurrentHashMap
保证线程安全; - 允许 null 值:支持
null
作为 key(仅允许一个,因 key 唯一)和 value(允许多个); - 无序性:存储顺序与插入顺序无关,底层依赖哈希值计算存储位置;
- 高效性:平均查找、插入、删除时间复杂度为 O (1),最坏情况(哈希冲突严重)为 O (n)(JDK 1.8 后优化为 O (logn))。
2. JDK 1.8 前后的结构演进
HashMap 的底层数据结构在 JDK 1.8 发生了重大优化,核心差异如下:
版本 | 底层结构 | 冲突解决方式 | 极端情况性能 |
---|---|---|---|
JDK 1.7 及之前 | 数组 + 链表 | 拉链法(头插法) | 链表过长时 O (n) |
JDK 1.8 及之后 | 数组 + 链表 + 红黑树 | 拉链法(尾插法)+ 红黑树转换 | 红黑树 O (logn) |
为什么引入红黑树?
当哈希冲突频繁时,链表长度会急剧增加,导致查找效率从 O (1) 退化到 O (n)。红黑树作为一种自平衡二叉查找树,可将查找时间复杂度优化至 O (logn),显著提升大规模数据下的操作性能。
3. 核心参数与设计决策
HashMap 的性能与几个关键参数密切相关,这些参数在源码中以常量或变量定义,决定了哈希表的容量、负载及结构转换时机:
参数 | 含义 | 默认值 | 设计意义 |
---|---|---|---|
DEFAULT_INITIAL_CAPACITY | 初始容量(哈希表数组长度) | 16(2⁴) | 2 的幂次方设计,确保 (n-1) & hash 等价于模运算(更高效) |
MAXIMUM_CAPACITY | 最大容量 | 2³⁰ | 避免容量过大导致内存溢出,同时保证为 2 的幂次方 |
DEFAULT_LOAD_FACTOR | 负载因子 | 0.75f | 平衡空间利用率与哈希冲突率:值过高易冲突,值过低浪费空间 |
TREEIFY_THRESHOLD | 链表转红黑树的阈值 | 8 | 基于泊松分布:链表长度为 8 的概率极低(≈0.0000001),此时转换为树更高效 |
UNTREEIFY_THRESHOLD | 红黑树转链表的阈值 | 6 | 避免频繁在树与链表间转换(滞后于转换阈值,减少抖动) |
MIN_TREEIFY_CAPACITY | 链表转红黑树的最小数组容量 | 64 | 数组较小时优先扩容而非转树(避免小容量下树结构的额外开销) |
二、底层数据结构深度解析
1. 哈希表基础:数组 + 链表(JDK 1.7)
JDK 1.7 中,HashMap 底层是数组(哈希桶)+ 链表的组合结构:
- 数组(
Entry[] table
):每个元素是一个链表的头节点,数组长度为 2 的幂次方(初始 16); - 链表(
Entry
节点):用于解决哈希冲突 —— 当多个 key 的哈希值映射到同一数组索引时,通过链表串联这些节点。
哈希冲突与拉链法
哈希冲突指不同 key 经过哈希计算后得到相同的数组索引。HashMap 采用拉链法解决冲突:将冲突的 key-value 对以链表形式存储在同一数组索引位置。
哈希值计算:扰动函数的作用
为减少哈希冲突,HashMap 并非直接使用 key 的 hashCode()
作为哈希值,而是通过扰动函数(hash()
方法)对 hashCode()
进行二次处理:
JDK 1.7 扰动函数:
static int hash(int h) {h ^= (h >>> 20) ^ (h >>> 12); // 高位扰动return h ^ (h >>> 7) ^ (h >>> 4); // 低位扰动
}
JDK 1.8 扰动函数:
static final int hash(Object key) {int h;// key 为 null 时哈希值为 0;否则将 hashCode 高 16 位与低 16 位异或return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
扰动目的:
hashCode()
返回的是 32 位整数,直接使用时高位信息可能被忽略(因数组长度通常较小)。通过将高 16 位与低 16 位异或,可将高位特征融入低位,增加哈希值的随机性,减少冲突。
索引计算
数组索引由哈希值与数组长度计算得出:
// n 为数组长度(2 的幂次方),(n-1) & hash 等价于 hash % n(更高效)
int index = (n - 1) & hash;
2. 红黑树优化(JDK 1.8)
JDK 1.8 引入红黑树(TreeNode
),当链表长度**≥ TREEIFY_THRESHOLD(8)** 且数组长度**≥ MIN_TREEIFY_CAPACITY(64)** 时,链表会转换为红黑树;当节点数**≤ UNTREEIFY_THRESHOLD(6)** 时,红黑树会转回链表。
红黑树的优势
- 自平衡:通过旋转和变色维持树的平衡,确保查找、插入、删除时间复杂度为 O (logn);
- 高效性:相比链表,在节点数较多时(如 10 个以上),红黑树的查找效率提升显著。
树化与反树化条件
- 树化(链表转红黑树):
- 链表长度 ≥ 8;
- 数组长度 ≥ 64(否则优先扩容数组)。
- 反树化(红黑树转链表):
- 树中节点数 ≤ 6;
- 扩容或删除节点时触发。
三、核心源码解析
1. 类属性与节点结构
HashMap 的核心属性定义了哈希表的基本配置与状态,节点类则是存储数据的基本单元。
核心属性
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable {// 序列化版本号private static final long serialVersionUID = 362498820763181265L;// 初始容量(16 = 2^4)static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;// 最大容量(2^30)static final int MAXIMUM_CAPACITY = 1 << 30;// 默认负载因子static final float DEFAULT_LOAD_FACTOR = 0.75f;// 链表转红黑树阈值static final int TREEIFY_THRESHOLD = 8;// 红黑树转链表阈值static final int UNTREEIFY_THRESHOLD = 6;// 树化的最小数组容量static final int MIN_TREEIFY_CAPACITY = 64;// 存储节点的数组(哈希桶)transient Node<K,V>[] table;// 键值对集合视图transient Set<Map.Entry<K,V>> entrySet;// 实际存储的键值对数量transient int size;// 结构修改计数器(用于快速失败)transient int modCount;// 扩容阈值(capacity * loadFactor)int threshold;// 负载因子final float loadFactor;
}
节点结构
普通节点(
Node
):用于链表存储static class Node<K,V> implements Map.Entry<K,V> {final int hash; // 哈希值(经扰动后)final K key; // 键(不可变)V value; // 值Node<K,V> next; // 下一个节点(链表指针)Node(int hash, K key, V value, Node<K,V> next) {this.hash = hash;this.key = key;this.value = value;this.next = next;}// getKey()、getValue()、setValue() 等方法略 }
树节点(
TreeNode
):用于红黑树存储static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {TreeNode<K,V> parent; // 父节点TreeNode<K,V> left; // 左子节点TreeNode<K,V> right; // 右子节点TreeNode<K,V> prev; // 前驱节点(用于双向链表)boolean red; // 节点颜色(红/黑)TreeNode(int hash, K key, V val, Node<K,V> next) {super(hash, key, val, next);}// 红黑树旋转、插入、查找等方法略 }
2. 构造方法:初始化容量与负载因子
HashMap 提供 4 种构造方法,核心是初始化负载因子(loadFactor
)和扩容阈值(threshold
)。
无参构造
public HashMap() {this.loadFactor = DEFAULT_LOAD_FACTOR; // 仅初始化负载因子为 0.75f,table 延迟初始化
}
指定初始容量
public HashMap(int initialCapacity) {this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
指定初始容量与负载因子
public HashMap(int initialCapacity, float loadFactor) {if (initialCapacity < 0)throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);// 初始容量不超过最大容量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 的幂次方,暂存到 thresholdthis.threshold = tableSizeFor(initialCapacity);
}
从其他 Map 初始化
public HashMap(Map<? extends K, ? extends V> m) {this.loadFactor = DEFAULT_LOAD_FACTOR;putMapEntries(m, false); // 将 m 中的键值对插入当前 HashMap
}
关键方法 tableSizeFor
:
将初始容量转换为最接近的 2 的幂次方(如输入 10 则返回 16),确保数组长度始终为 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. 核心方法:put 与哈希冲突处理
put
方法是 HashMap 最核心的操作,负责将键值对插入哈希表,其底层依赖 putVal
实现。
put 方法入口
public V put(K key, V value) {// 计算 key 的哈希值,调用 putVal 插入return putVal(hash(key), key, value, false, true);
}
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;// 1. 初始化哈希表(若未初始化)if ((tab = table) == null || (n = tab.length) == 0)n = (tab = resize()).length;// 2. 计算索引,若桶为空则直接插入新节点if ((p = tab[i = (n - 1) & hash]) == null)tab[i] = newNode(hash, key, value, null);else {// 3. 桶不为空,处理哈希冲突Node<K,V> e; K k;// 3.1 若桶中首节点与插入 key 相同(hash + equals),直接覆盖if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))e = p;// 3.2 若为红黑树节点,调用树插入方法else if (p instanceof TreeNode)e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);// 3.3 若为链表,遍历链表查找或插入else {for (int binCount = 0; ; ++binCount) {// 遍历到链表尾部,插入新节点if ((e = p.next) == null) {p.next = newNode(hash, key, value, null);// 若链表长度达到树化阈值,触发树化if (binCount >= TREEIFY_THRESHOLD - 1) // -1 因从 0 开始计数treeifyBin(tab, hash);break;}// 找到相同 key,跳出循环准备覆盖if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))break;p = e; // 移动到下一个节点}}// 4. 若找到相同 key,根据 onlyIfAbsent 决定是否覆盖if (e != null) {V oldValue = e.value;if (!onlyIfAbsent || oldValue == null)e.value = value;afterNodeAccess(e); // 访问后回调(LinkedHashMap 用)return oldValue; // 返回旧值}}// 5. 插入新节点,更新结构计数器与 size++modCount;if (++size > threshold)resize(); // 若超过阈值,触发扩容afterNodeInsertion(evict); // 插入后回调(LinkedHashMap 用)return null; // 无旧值,返回 null
}
JDK 1.7 与 1.8 的 put 差异:
- JDK 1.7 采用头插法插入链表节点(新节点放在链表头部),扩容时可能因链表逆序导致循环链表;
- JDK 1.8 采用尾插法(新节点放在链表尾部),避免了扩容时的循环问题,且引入红黑树优化长链表。
4. 核心方法:get 与查找逻辑
get
方法用于根据 key 获取 value,底层依赖 getNode
实现高效查找。
get 方法入口
public V get(Object key) {Node<K,V> e;// 计算 key 的哈希值,调用 getNode 查找return (e = getNode(hash(key), key)) == null ? null : e.value;
}
getNode 核心逻辑
final Node<K,V> getNode(int hash, Object key) {Node<K,V>[] tab; Node<K,V> first, e; int n; K k;// 1. 哈希表非空且索引位置有节点if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) {// 2. 检查首节点是否匹配if (first.hash == hash && ((k = first.key) == key || (key != null && key.equals(k))))return first;// 3. 首节点不匹配,遍历后续节点if ((e = first.next) != null) {// 3.1 若为红黑树,调用树查找if (first instanceof TreeNode)return ((TreeNode<K,V>)first).getTreeNode(hash, key);// 3.2 若为链表,遍历查找do {if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))return e;} while ((e = e.next) != null);}}// 4. 未找到匹配节点return null;
}
5. 核心方法:resize 与扩容机制
当哈希表中键值对数量超过阈值(threshold = capacity * loadFactor
)时,会触发 resize
方法进行扩容,将数组长度翻倍(保证为 2 的幂次方),并重新分配节点。
resize 核心逻辑
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. 处理旧容量 > 0 的情况(已初始化)if (oldCap > 0) {// 若超过最大容量,不再扩容if (oldCap >= MAXIMUM_CAPACITY) {threshold = Integer.MAX_VALUE;return oldTab;}// 容量翻倍(左移 1 位),阈值也翻倍(若旧容量 ≥ 16)else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)newThr = oldThr << 1;}// 2. 处理旧阈值 > 0 的情况(构造方法中指定了初始容量)else if (oldThr > 0)newCap = oldThr;// 3. 未初始化(无参构造),使用默认值else {newCap = DEFAULT_INITIAL_CAPACITY;newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);}// 4. 计算新阈值(若未确定)if (newThr == 0) {float ft = (float)newCap * loadFactor;newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY) ? (int)ft : Integer.MAX_VALUE;}threshold = newThr;// 5. 创建新数组(容量为 newCap)@SuppressWarnings({"rawtypes","unchecked"})Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];table = newTab;// 6. 将旧数组节点迁移到新数组if (oldTab != null) {for (int j = 0; j < oldCap; ++j) {Node<K,V> e;if ((e = oldTab[j]) != null) {oldTab[j] = null; // 释放旧节点引用// 6.1 若节点无后续节点,直接放入新数组if (e.next == null)newTab[e.hash & (newCap - 1)] = e;// 6.2 若为红黑树,拆分树节点else if (e instanceof TreeNode)((TreeNode<K,V>)e).split(this, newTab, j, oldCap);// 6.3 若为链表,拆分链表(关键优化)else {// 低位链表:索引不变(原索引)Node<K,V> loHead = null, loTail = null;// 高位链表:索引 = 原索引 + 旧容量Node<K,V> hiHead = null, hiTail = null;Node<K,V> next;do {next = e.next;// 通过哈希值与旧容量的位运算判断新索引(无需重新计算 hash)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;}hiTail = e;}} while ((e = next) != null);// 放入新数组if (loTail != null) {loTail.next = null;newTab[j] = loHead;}if (hiTail != null) {hiTail.next = null;newTab[j + oldCap] = hiHead;}}}}}return newTab;
}
扩容的核心优化:
JDK 1.8 中,链表迁移时通过 (e.hash & oldCap) == 0
判断节点新索引(无需重新计算哈希),将链表拆分为 “低位链表”(原索引)和 “高位链表”(原索引 + 旧容量),避免了 JDK 1.7 中重新计算所有节点哈希的开销,提升扩容效率。
四、HashMap 常用方法实践
以下通过示例代码演示 HashMap 的常用操作(如插入、遍历、删除等),并分析其效果:
import java.util.*;public class HashMapDemo {public static void main(String[] args) {// 初始化 HashMapHashMap<String, String> map = new HashMap<>();// 1. 插入键值对(key 重复时会覆盖)map.put("name", "张三");map.put("age", "20");map.put("gender", "男");map.put("name", "李四"); // 覆盖 "name" 的值System.out.println("插入后:" + map); // 输出:{name=李四, age=20, gender=男}// 2. 遍历方式// 2.1 遍历 key(效率较低,需二次查找 value)System.out.println("\n遍历 key:");Set<String> keys = map.keySet();for (String key : keys) {System.out.println(key + " -> " + map.get(key));}// 2.2 遍历 value(无法获取 key)System.out.println("\n遍历 value:");Collection<String> values = map.values();for (String value : values) {System.out.println(value);}// 2.3 遍历 entry(推荐,一次获取 key 和 value)System.out.println("\n遍历 entry:");Set<Map.Entry<String, String>> entries = map.entrySet();for (Map.Entry<String, String> entry : entries) {System.out.println(entry.getKey() + " -> " + entry.getValue());}// 3. 其他常用方法System.out.println("\n大小:" + map.size()); // 3System.out.println("是否为空:" + map.isEmpty()); // falseSystem.out.println("获取 age:" + map.get("age")); // 20System.out.println("是否包含 key 'gender':" + map.containsKey("gender")); // trueSystem.out.println("是否包含 value '20':" + map.containsValue("20")); // true// 4. 删除与替换map.remove("gender");System.out.println("删除 gender 后:" + map); // {name=李四, age=20}map.replace("age", "21");System.out.println("替换 age 后:" + map); // {name=李四, age=21}}
}