一、HashMap 简介
HashMap 是 Java 集合框架中最常用的 Map 实现类之一,基于哈希表实现,用于存储键值对(Key-Value)。它提供了快速的查找、插入和删除操作,是许多 Java 应用程序的核心数据结构之一。
二、HashMap 核心特性
基本特性:
- 基于哈希表:数组 + 链表 + 红黑树(JDK 1.8+),通过哈希函数将键映射到存储位置。
- 键唯一性:不允许重复的键。
- 无序性:不保证元素的顺序。
- 允许 null:允许一个 null 键和多个 null 值。
- 非线程安全:多线程环境下需要外部同步。
- 快速访问:理想情况下 O(1) 时间复杂度。
性能参数:
- 初始容量:默认 16
- 负载因子:默认 0.75
- 扩容阈值:容量 × 负载因子
- 树化阈值:链表长度达到 8 时转为红黑树
- 链化阈值:树节点减少到 6 时转回链表
三、HashMap 内部实现原理
3.1 数据结构演进
JDK 1.7 及之前:数组 + 链表
// 简化版内部结构
transient Entry<K,V>[] table;
static class Entry<K,V> {
final K key;
V value;
Entry<K,V> next;
int hash;
}
JDK 1.8 及之后:数组 + 链表 + 红黑树

// 简化版内部结构
transient Node<K,V>[] table;
// 链表节点
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; // 键的哈希值(经过扰动计算后的)
final K key; // 键,final 修饰保证不可变
V value; // 值,可以修改
Node<K,V> next; // 指向下一个节点的引用,形成链表
}
// 树节点
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; // 节点颜色(true=红色,false=黑色)
}
从JDK 1.8 及之后其底层数据结构主要包括以下几个部分:
- 哈希表
- 使用一个数组(Node<K,V>[] table)作为哈希表,数组的每个元素称为桶(Bucket)。
- 每个桶存储一个链表或红黑树,用于解决哈希冲突。
- Node 节点
- 链表节点:当哈希表中某个桶(bucket)中的元素数量较少时(在JDK 1.8中,当链表长度小于8时),HashMap会使用链表来存储该桶中的元素。
- 树节点:当同一个桶中的元素数量达到一定阈值(在JDK 1.8中,当链表长度大于等于8且桶数组容量大于等于64时),链表会转换为红黑树,以提高查询效率。
3.2 Node 和 TreeNode 的核心作用
- Node(链表节点):
- 处理少量哈希冲突
- 内存占用小,插入快
- 查找性能随长度线性下降
- TreeNode(红黑树节点):
- 处理大量哈希冲突
- 保证最坏情况下 O(log n) 的性能
- 内存占用大,但查询稳定
3.3 哈希冲突
哈希冲突(Hash Collision)是指两个或更多不同的键(Key)经过哈希函数计算后,得到了相同的哈希值,从而映射到 HashMap 数组的同一个索引位置。
- 哈希冲突产生的原因
- 不同键产生相同哈希值。
- 不同哈希值映射到相同索引。
- 解决哈希冲突的方法
- 链地址法:HashMap 使用链地址法来解决冲突。每个数组元素是一个链表(或树)的头节点。当发生冲突时,新的键值对会被添加到链表中。在 JDK 1.7 及之前,新节点是插入到链表的头部(头插法)。在 JDK 1.8 及之后,改为插入到链表的尾部(尾插法)。
- 红黑树:在 JDK 1.8 中,当链表的长度超过 TREEIFY_THRESHOLD(默认为8)并且数组的长度达到 MIN_TREEIFY_CAPACITY(默认为64)时,链表会转换为红黑树。这样可以将最坏情况下的查找时间从 O(n) 降低到 O(log n)。当红黑树的节点数减少到 UNTREEIFY_THRESHOLD(默认为6)时,红黑树会退化为链表。
- 哈希冲突的影响
- 哈希冲突会影响 HashMap 的性能。如果冲突较多,会导致链表过长或树化,从而增加查找时间。理想情况下,哈希函数应该将键均匀地分布到数组中,使得每个索引位置上的元素尽可能少。
- 减少哈希冲突的方法
- 良好的哈希函数设计:键的 hashCode() 方法应该尽可能分布均匀。
- 使用不可变对象作为键:避免键状态变化导致的哈希值改变。
- 合适的初始容量和负载因子:根据预估的键值对数量设置初始容量和负载因子,减少扩容次数,从而减少冲突。
3.4 扩容机制
HashMap的扩容机制是为了保证其高效性。通过双倍扩容和重新哈希,使得元素分布更加均匀,减少哈希冲突。在Java 8中,通过链表拆分和红黑树的处理,进一步优化了扩容性能。
3.4.1 扩容的核心参数
public class HashMap<K,V> extends AbstractMap<K,V> {
// 核心参数
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 16 - 默认初始容量
static final int MAXIMUM_CAPACITY = 1 << 30; // 最大容量
static final float DEFAULT_LOAD_FACTOR = 0.75f; // 默认负载因子
// 实例变量
transient Node<K,V>[] table; // 哈希桶数组
int threshold; // 扩容阈值 = capacity * loadFactor
final float loadFactor; // 负载因子
transient int modCount; // 结构修改次数
transient int size; // 键值对数量
}
3.4.2 扩容的触发条件
HashMap扩容触发条件主要有以下几条:
- 首次添加元素时(table == null)
- 元素数量 > 容量 × 负载因子(size > threshold)
- 链表长度 ≥ 8,但数组长度 < 64(优先扩容而不是树化)
public class HashMapResizeCondition {
public void checkResizeConditions() {
HashMap<String, String> map = new HashMap<>();
// 条件1:首次put时触发初始化扩容
map.put("key1", "value1"); // 触发第一次resize()
// 条件2:元素数量超过阈值
for (int i = 2; i <= 13; i++) {
map.put("key" + i, "value" + i);
}
// 默认容量16,负载因子0.75,阈值12
// 当put第13个元素时:size=13 > threshold=12 → 触发扩容
// 条件3:链表过长但数组较小
// 当链表长度≥8且数组长度<64时,优先扩容而不是转换为红黑树
}
}
3.4.3 扩容核心方法 - resize()
final Node<K,V>[] resize() {
// 步骤1:计算新容量和新阈值
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
// 情况1:已经初始化过的HashMap
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab; // 已达最大容量,不再扩容
}
// 新容量 = 旧容量 × 2
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY) {
newThr = oldThr << 1; // 新阈值 = 旧阈值 × 2
}
}
// 情况2:使用指定容量构造的HashMap(第一次扩容)
else if (oldThr > 0) {
newCap = oldThr;
}
// 情况3:使用默认构造的HashMap(第一次扩容)
else {
newCap = DEFAULT_INITIAL_CAPACITY; // 16
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); // 12
}
// 计算新的阈值
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
// 步骤2:创建新数组
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
// 步骤3:迁移数据(如果旧表不为空)
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null; // 帮助GC
// 情况1:该位置只有一个元素
if (e.next == null) {
newTab[e.hash & (newCap - 1)] = e;
}
// 情况2:该位置是红黑树
else if (e instanceof TreeNode) {
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
}
// 情况3:该位置是链表
else {
// 链表拆分优化(Java 8 的重要优化)
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
// 关键判断:判断元素应该留在原位置还是移动到新位置
if ((e.hash & oldCap) == 0) {
// 留在原索引位置
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
} else {
// 移动到新位置:原索引 + oldCap
if (hiTail == null)
hiHead = e;
else
hiTail.next = 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;
}
3.4.4 扩容的基本步骤
扩容主要包括以下步骤:
- 创建一个新的数组(桶数组),容量为原数组的两倍。
- 重新计算每个元素在新数组中的位置,并将它们转移到新数组中。
扩容的详细过程:
- 计算新容量和新阈值
- 新容量 = 旧容量 * 2
- 新阈值 = 新容量 * 负载因子
- 转移元素(重新哈希)
- 遍历旧数组的每个桶,对于每个桶中的每个元素(可能是单个节点、链表或红黑树),重新计算其在新数组中的索引位置,并将其放置到新数组对应的桶中。
3.4.5 扩容中的优化
- 链表拆分优化:在Java 8中,扩容时对链表进行了优化。通过(e.hash & oldCap)的值(0或非0)将链表拆分成两个链表,其中一个链表放在新数组的原来索引位置,另一个放在原来索引+旧容量的位置。这样做的原因是:由于容量是2的幂,扩容后元素的位置变化只取决于新增的最高位是0还是1。
public class ResizeOptimization {
/**
* Java 7 与 Java 8 扩容对比:
*
* Java 7: 重新计算每个元素的hash,然后模新容量
* Java 8: 利用位运算快速判断元素位置
*/
public void explainOptimization() {
// 假设旧容量 = 16 (二进制: 10000)
int oldCap = 16;
// 新容量 = 32 (二进制: 100000)
int newCap = 32;
// 关键技巧:判断 (hash & oldCap) == 0
// 因为容量总是2的幂,所以扩容时只需要检查hash值在新增的bit位上是0还是1
// 示例:
int hash1 = 5; // 二进制: 00101
int hash2 = 21; // 二进制: 10101
System.out.println("hash1 & oldCap: " + (hash1 & oldCap)); // 0 → 留在原位置
System.out.println("hash2 & oldCap: " + (hash2 & oldCap)); // 16 → 移动到新位置
// 原位置计算:
int index1_old = hash1 & (oldCap - 1); // 5 & 15 = 5
int index2_old = hash2 & (oldCap - 1); // 21 & 15 = 5
// 新位置计算:
int index1_new = hash1 & (newCap - 1); // 5 & 31 = 5 (不变)
int index2_new = hash2 & (newCap - 1); // 21 & 31 = 21 = 5 + 16
}
}
- 红黑树处理:如果桶中的元素是红黑树,那么会调用红黑树的拆分方法。在拆分过程中,如果拆分后的链表长度小于等于6,则将红黑树转换为链表。
public class TreeResize {
/**
* 红黑树在扩容时的处理
*/
final void split(HashMap<K,V> map, Node<K,V>[] newTab, int index, int bit) {
TreeNode<K,V> loHead = null, loTail = null;
TreeNode<K,V> hiHead = null, hiTail = null;
int lc = 0, hc = 0;
// 遍历树节点,按 (e.hash & bit) 拆分为两个链表
for (TreeNode<K,V> e = this; e != null; e = e.next) {
if ((e.hash & bit) == 0) {
// 连接到lo链表
if ((e.prev = loTail) == null)
loHead = e;
else
loTail.next = e;
loTail = e;
++lc;
} else {
// 连接到hi链表
if ((e.prev = hiTail) == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
++hc;
}
}
// 将链表转换为树或保持为链表
if (loHead != null) {
if (lc <= UNTREEIFY_THRESHOLD) // 6
newTab[index] = loHead.untreeify(map);
else {
newTab[index] = loHead;
if (hiHead != null) // 确保重新树化
loHead.treeify(newTab);
}
}
if (hiHead != null) {
if (hc <= UNTREEIFY_THRESHOLD)
newTab[index + bit] = hiHead.untreeify(map);
else {
newTab[index + bit] = hiHead;
if (loHead != null)
hiHead.treeify(newTab);
}
}
}
}
3.4.6 扩容策略总结
| 版本 | 扩容策略 | 优点 | 缺点 |
|---|---|---|---|
| Java 7 | 头插法,重新hash所有元素 | 实现简单 | 多线程下可能产生死循环 |
| Java 8 | 尾插法,位运算优化定位 | 避免死循环,性能更好 | 实现相对复杂 |
四、HashMap常见问题
4.1 线程安全问题
- 问题:HashMap 不是线程安全的,在多线程环境下同时进行 put 操作可能导致数据不一致、死循环或元素丢失。
- 数据不一致:因为多个线程同时修改同一个HashMap,可能导致一个线程的修改被另一个线程覆盖,或者导致内部状态错误。
- 死循环:在JDK1.7及之前,HashMap在扩容时采用头插法转移节点,多线程同时扩容可能导致环形链表的产生,进而导致死循环。
- 元素丢失:多个线程同时put时,可能会因为覆盖而导致元素丢失。
- 解决方案:
- 使用 Collections.synchronizedMap 方法将 HashMap 包装成线程安全的 Map。
- 使用 ConcurrentHashMap,它提供了更好的并发性能。
4.2 哈希冲突导致的性能退化
- 问题:如果多个键的哈希值相同,它们会被存储在同一个桶中,如果冲突严重,会导致链表过长(在 Java 8 之前)或红黑树退化(在 Java 8 及以后),从而降低查询性能。
- 解决方案:
- 确保键的哈希函数分布均匀。对于自定义对象,重写 hashCode() 方法以产生分布均匀的哈希值。
- 在 Java 8 中,当链表长度超过 8 且数组长度大于等于 64 时,将链表转换为红黑树,以提高查询效率。
4.3 扩容导致的性能开销
- 问题:当 HashMap 中的元素数量达到阈值(容量 * 负载因子)时,会进行扩容,即创建一个新的数组并重新哈希所有元素,这个过程比较耗时。
- 解决方案:
- 在创建 HashMap 时指定初始容量和负载因子,以减少扩容次数。例如,如果预计要存储 1000 个元素,负载因子为 0.75,则初始容量可以设置为 1000 / 0.75 = 1333,然后取最近的 2 的幂次(2048)。
4.4 内存占用问题
- 问题:HashMap 可能会占用比实际存储元素所需更多的内存,因为数组的大小总是 2 的幂次,而且负载因子默认是 0.75,意味着有 25% 的空间是浪费的。
- 解决方案:根据实际需要调整负载因子。如果内存紧张,可以适当增加负载因子(如 0.9),但这会增加哈希冲突的概率;如果追求性能,可以适当降低负载因子(如 0.5)。
4.5 键的不可变性
- 问题:如果使用可变对象作为键,并且键的哈希值在存入 HashMap 后发生变化,那么将无法再通过该键获取值,因为哈希值变化后,键可能被映射到不同的桶中。
- 解决方案:使用不可变对象(如 String、Integer)作为键,或者确保键对象在存入 HashMap 后不会改变其哈希值相关的字段。
4.6 遍历时修改异常
- 问题:在遍历 HashMap 时,如果直接通过 HashMap 的方法修改结构(如添加或删除元素),会抛出 ConcurrentModificationException。
- 解决方案:
- 使用迭代器的 remove 方法进行删除。
- 或者使用 ConcurrentHashMap 来避免这个问题。
4.7 空键和空值
- 问题:HashMap 允许一个空键和多个空值,但在某些情况下,这可能会导致异常(例如,如果键的 hashCode() 方法在空键时抛出异常)。
- 解决方案:确保键的 hashCode() 和 equals() 方法能够处理空值,或者避免使用空键。
4.8 顺序不确定性
- 问题:HashMap 不保证元素的顺序,即使在同一环境中,多次运行顺序也可能不同(因为哈希种子随机化)。如果需要有序,HashMap 不适用。
- 解决方案:如果需要插入顺序,使用 LinkedHashMap;如果需要自然顺序或自定义顺序,使用 TreeMap。
4.9 Java 8 中的树化阈值
- 问题:在 Java 8 中,当链表长度超过 8 且数组长度大于等于 64 时,链表会转换为红黑树。但在某些情况下,如果哈希函数分布不均匀,会导致红黑树过深,影响性能。
- 解决方案:同样需要优化哈希函数,减少冲突。
4.10 HashMap 如何保证 Key 的唯一性
HashMap 通过一套精密的机制来保证 key 的唯一性,主要依赖于 hashCode() 和 equals() 方法的配合使用。HashMap 保证 key 唯一性的核心流程如下:
- 计算key的哈希值(通过key的hashCode()方法),然后通过哈希函数(通常是对数组长度取模)得到在数组中的索引位置。
- 如果该位置为空,则直接插入。
- 如果该位置不为空(即发生了哈希冲突),则遍历该位置上的链表或红黑树,使用key的equals()方法依次比较每个已存在节点的key。
- 如果找到相等的key(即equals返回true),则用新的value覆盖旧的value。
- 如果没有找到,则将新的键值对插入到链表或红黑树中。
- hashCode():决定key在哪个桶中。
- equals():决定是否与桶中存在的节点是相同的key。
4.11 为什么在重写equals方法时,必须同时重写hashCode方法
在Java的Object类中定义,并且所有Java对象都遵循这个契约:
- 如果两个对象根据 equals() 方法是相等的,那么调用这两个对象的 hashCode()必须返回相同的整数结果。
- 如果两个对象根据 equals() 方法是不相等的,不要求 hashCode() 返回不同的值。但是,为不相等的对象生成不同的哈希值可以提高哈希表的性能。
当我们将对象放入哈希表(如HashMap)时,哈希表使用对象的hashCode值来确定对象在表中的位置(桶)。当从哈希表中查找对象时,首先通过hashCode定位到桶,然后使用equals方法在桶中查找正确的对象。
如果只重写equals而不重写hashCode,那么两个相等的对象(根据equals方法)可能具有不同的hashCode值,这会导致它们被放入哈希表的不同桶中。这样,当我们尝试使用其中一个对象作为键来查找时,可能无法找到另一个相等的对象,因为哈希表会在不同的桶中查找。
举个例子:
假设我们有一个类Person,我们只重写了equals方法,认为两个Person对象的id相等则他们相等,但没有重写hashCode方法。
那么,两个id相同的Person对象,equals返回true,但是它们的hashCode(继承自Object的默认实现)可能不同。
现在,我们将其中一个Person对象放入HashMap中,然后尝试用另一个相等的Person对象来获取,由于hashCode不同,HashMap会在不同的桶中查找,因此找不到之前放入的对象。
1421

被折叠的 条评论
为什么被折叠?



