一、HashMap 概述
HashMap 的基本定义
什么是 HashMap?
HashMap 是 Java 集合框架(Java Collections Framework)中的一个重要类,位于 java.util
包下。它实现了 Map
接口,用于存储键值对(key-value pairs)。HashMap 基于哈希表(Hash Table)实现,允许使用 null
作为键或值,并且是非线程安全的(非同步)。
核心特性
- 键值对存储:HashMap 存储的是
key-value
对,其中key
是唯一的,而value
可以重复。 - 无序性:HashMap 不保证元素的顺序(插入顺序或自然顺序),迭代顺序可能会随时间变化。
- 允许 null:HashMap 允许一个
null
键和多个null
值。 - 非线程安全:HashMap 不是线程安全的,如果在多线程环境下使用,可能会导致数据不一致。如果需要线程安全,可以使用
ConcurrentHashMap
或通过Collections.synchronizedMap
包装。
底层数据结构
HashMap 的底层实现主要包括:
- 数组(桶数组):用于存储键值对的容器,数组的每个位置称为一个“桶”(bucket)。
- 链表或红黑树:当多个键的哈希值映射到同一个桶时,HashMap 会将这些键值对以链表形式存储。当链表长度超过阈值(默认为 8)时,链表会转换为红黑树(Java 8 引入),以提高查询效率。
哈希函数
HashMap 通过 hashCode()
方法计算键的哈希值,然后通过哈希函数(通常是取模运算)将哈希值映射到桶数组的索引位置。理想情况下,哈希函数应均匀分布键,以减少哈希冲突。
示例代码
import java.util.HashMap;
public class HashMapExample {
public static void main(String[] args) {
// 创建一个 HashMap
HashMap<String, Integer> map = new HashMap<>();
// 添加键值对
map.put("Alice", 25);
map.put("Bob", 30);
map.put("Charlie", 35);
// 获取值
System.out.println("Alice's age: " + map.get("Alice")); // 输出 25
// 遍历 HashMap
for (String key : map.keySet()) {
System.out.println(key + ": " + map.get(key));
}
}
}
注意事项
- 键的唯一性:如果插入的键已存在,新值会覆盖旧值。
- 初始容量和负载因子:可以通过构造函数指定初始容量和负载因子(默认容量为 16,负载因子为 0.75)。负载因子决定了何时扩容。
- 性能:在理想情况下(哈希冲突少),HashMap 的
get
和put
操作的时间复杂度为 O(1)。最坏情况下(所有键哈希冲突),时间复杂度为 O(n) 或 O(log n)(红黑树优化后)。
HashMap 的主要特点
键值对存储结构
HashMap 采用键值对(Key-Value)的方式存储数据,每个键(Key)对应一个值(Value)。这种结构类似于字典,可以通过键快速查找对应的值。
基于哈希表实现
HashMap 底层通过哈希表(Hash Table)实现,利用哈希函数将键映射到哈希表的特定位置(桶),从而实现快速的数据存取。
允许 null 键和 null 值
HashMap 允许键(Key)和值(Value)为 null,但只能有一个 null 键(因为键的唯一性)。如果插入多个 null 键,后面的值会覆盖前面的值。
非线程安全
HashMap 不是线程安全的,如果在多线程环境下使用,可能会导致数据不一致或死循环问题。如果需要线程安全,可以使用 ConcurrentHashMap
或通过 Collections.synchronizedMap
包装。
无序性
HashMap 不保证元素的顺序,即插入顺序和遍历顺序可能不一致。如果需要有序的 Map,可以使用 LinkedHashMap
(保持插入顺序)或 TreeMap
(按键排序)。
动态扩容
HashMap 的容量(Capacity)会根据存储的键值对数量动态调整。当元素数量超过负载因子(Load Factor,默认为 0.75)与当前容量的乘积时,HashMap 会自动扩容(通常是原来的 2 倍),并重新哈希所有元素。
冲突解决方式
HashMap 使用链地址法(拉链法)解决哈希冲突。当多个键映射到同一个桶(Bucket)时,这些键值对会以链表或红黑树(JDK 8 后优化)的形式存储。
高效的查找、插入和删除
在理想情况下(哈希冲突较少),HashMap 的查找、插入和删除操作的时间复杂度为 O(1)。但在最坏情况下(如所有键都冲突),时间复杂度可能退化到 O(n)(链表)或 O(log n)(红黑树)。
示例代码
import java.util.HashMap;
public class HashMapExample {
public static void main(String[] args) {
// 创建 HashMap
HashMap<String, Integer> map = new HashMap<>();
// 添加键值对
map.put("Alice", 25);
map.put("Bob", 30);
map.put(null, 0); // 允许 null 键
map.put("Charlie", null); // 允许 null 值
// 获取值
System.out.println(map.get("Alice")); // 输出: 25
System.out.println(map.get(null)); // 输出: 0
// 遍历 HashMap
for (String key : map.keySet()) {
System.out.println(key + ": " + map.get(key));
}
}
}
HashMap 的继承关系
基本继承结构
HashMap 在 Java 中的继承关系如下:
java.lang.Object
↳ java.util.AbstractMap<K,V>
↳ java.util.HashMap<K,V>
实现接口
HashMap 实现了以下接口:
Map<K,V>
:定义了键值对映射的基本操作(如put
,get
,remove
)Cloneable
:允许使用clone()
方法进行浅拷贝Serializable
:支持序列化
关键继承特点
-
从 AbstractMap 继承:
- 继承了通用的 Map 操作实现(如
toString()
、equals()
等方法) - 避免了所有 Map 实现类重复编写基础代码
- 继承了通用的 Map 操作实现(如
-
不继承 Dictionary 类:
- 与 Hashtable 不同,HashMap 没有继承过时的 Dictionary 类
- 采用了更现代的 Map 接口设计
与相关类的对比
-
与 Hashtable 的关系:
- 都实现了 Map 接口
- 但无直接继承关系(属于平行实现)
-
与 LinkedHashMap 的关系:
- LinkedHashMap 继承自 HashMap
- 添加了维护插入顺序/访问顺序的功能
设计意义
这种继承结构体现了:
- 接口与实现分离的原则
- 代码复用的思想(通过 AbstractMap)
- 框架扩展性(为 LinkedHashMap 等子类预留空间)
典型方法继承
来自 AbstractMap 的重要方法:
public Set<Map.Entry<K,V>> entrySet()
public void putAll(Map<? extends K, ? extends V> m)
public boolean isEmpty()
注意:虽然继承了这些方法,但 HashMap 都进行了重写以提供更高效的实现。
HashMap 实现的接口
Map 接口
HashMap 是 Java 中最常用的哈希表实现之一,它实现了 java.util.Map
接口。Map
接口定义了键值对(Key-Value)存储的基本操作,包括:
- put(K key, V value):将指定的键值对存入 Map。
- get(Object key):根据键获取对应的值。
- remove(Object key):移除指定键对应的键值对。
- containsKey(Object key):判断是否包含指定的键。
- containsValue(Object value):判断是否包含指定的值。
- size():返回 Map 中键值对的数量。
- keySet():返回所有键的集合。
- values():返回所有值的集合。
- entrySet():返回所有键值对的集合(
Map.Entry
对象)。
Map
接口是 Java 集合框架的核心接口之一,HashMap 通过实现该接口提供了高效的键值对存储和查询能力。
Cloneable 接口
HashMap 实现了 java.lang.Cloneable
接口,表明它可以被克隆(浅拷贝)。通过 clone()
方法,可以复制一个 HashMap 实例,但键值对对象本身不会被复制(即键和值仍然是原对象的引用)。
示例代码:
HashMap<String, Integer> map1 = new HashMap<>();
map1.put("A", 1);
map1.put("B", 2);
HashMap<String, Integer> map2 = (HashMap<String, Integer>) map1.clone();
System.out.println(map2); // 输出:{A=1, B=2}
Serializable 接口
HashMap 实现了 java.io.Serializable
接口,表明它可以被序列化和反序列化。这使得 HashMap 可以被写入文件或通过网络传输,并在需要时恢复为内存中的对象。
注意事项:
- 如果 HashMap 的键或值对象未实现
Serializable
接口,序列化时会抛出NotSerializableException
。 - 序列化时,HashMap 会保存其内部结构(如桶数组、加载因子等),反序列化时会重建相同的哈希表。
AbstractMap 抽象类
HashMap 继承自 java.util.AbstractMap
抽象类,该类提供了 Map
接口的骨架实现,减少了实现 Map
接口所需的工作量。AbstractMap
已经实现了部分通用方法(如 toString()
、equals()
、hashCode()
),HashMap 只需实现核心方法(如 put()
、get()
、entrySet()
)。
总结
HashMap 实现的接口和继承的类包括:
Map
接口:提供键值对存储的基本操作。Cloneable
接口:支持浅拷贝。Serializable
接口:支持序列化和反序列化。AbstractMap
抽象类:提供Map
接口的默认实现。
二、HashMap 核心数据结构
HashMap 的内部结构:数组 + 链表 + 红黑树
HashMap 是 Java 中最常用的集合类之一,它基于哈希表实现,采用了 数组 + 链表 + 红黑树 的结构来存储键值对。这种结构的设计目的是为了在保证高效查找的同时,解决哈希冲突的问题。
数组(哈希桶数组)
HashMap 的核心是一个 Node<K,V>[] table
数组,每个数组元素称为一个 桶(bucket) 或 哈希槽。
- 作用:通过键的
hashCode()
计算哈希值,再经过扰动函数((n - 1) & hash
)映射到数组下标,直接定位到对应的桶。 - 特点:
- 数组长度始终是 2 的幂(如 16、32、64),方便通过位运算快速计算下标。
- 默认初始容量为 16,负载因子为 0.75(当元素数量超过
容量 × 负载因子
时触发扩容)。
链表(解决哈希冲突)
当不同的键通过哈希计算后映射到同一个桶(即发生 哈希冲突)时,HashMap 会以 链表 的形式存储这些键值对。
- 实现:每个
Node<K,V>
包含key
、value
、hash
和next
指针(指向下一个节点)。 - 查找过程:
- 先通过哈希定位到数组下标。
- 遍历链表,通过
equals()
方法比较键是否相等。
- 缺点:链表过长时(如哈希分布不均匀),查找效率会退化为 O(n)。
红黑树(优化长链表)
在 JDK 8 中,HashMap 引入了红黑树优化长链表的性能问题:
- 触发条件:当链表长度超过 8 且 数组长度 ≥ 64 时,链表会转换为红黑树。
- 目的:将查找时间复杂度从 O(n) 优化为 O(log n)。
- 退化为链表:当红黑树节点数 ≤ 6 时,会退化为链表(避免频繁转换的开销)。
示例代码(结构演示)
// HashMap 的 Node 内部类(链表节点)
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next; // 链表指针
}
// TreeNode 内部类(红黑树节点,继承自 Node)
static final class TreeNode<K,V> extends Node<K,V> {
TreeNode<K,V> parent; // 父节点
TreeNode<K,V> left; // 左子树
TreeNode<K,V> right; // 右子树
boolean red; // 颜色标记
}
常见误区
- 哈希冲突一定会降低性能?
- 不一定。合理的哈希函数和扩容机制可以分散冲突,而红黑树能有效缓解长链表的性能问题。
- 链表长度超过 8 就转红黑树?
- 还需满足数组长度 ≥ 64,否则优先扩容数组。
使用场景
- 高频查询:如缓存、索引等场景。
- 键的唯一性:需要快速判断键是否存在(如去重)。
- 注意:线程不安全,多线程环境下应使用
ConcurrentHashMap
。
Node 节点的定义
在 Java 的 HashMap
中,Node
是一个静态内部类,用于表示 HashMap
中的一个键值对(Entry)。它是 HashMap
内部存储数据的基本单元,每个 Node
对象包含键(key)、值(value)、哈希值(hash)以及指向下一个 Node
的引用(next)。
基本结构
Node
类的定义如下(以 Java 8 为例):
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(), equals(), hashCode() 等)
}
核心字段说明
hash
:存储键(key
)的哈希值,由HashMap
计算得出。这个值用于确定Node
在哈希表中的位置(桶的位置)。key
:存储键对象,final
修饰表示键不可变(防止哈希值变化导致位置错误)。value
:存储值对象,可以修改。next
:指向下一个Node
的引用。当发生哈希冲突时(即多个键的哈希值映射到同一个桶),HashMap
会通过链表将这些Node
连接起来,next
字段用于维护链表结构。
实现接口
Node
实现了 Map.Entry<K,V>
接口,因此它必须提供以下方法:
getKey()
:返回键。getValue()
:返回值。setValue(V value)
:设置值。equals()
和hashCode()
:用于比较两个Entry
是否相等。
使用场景
- 存储键值对:
HashMap
通过数组(Node[] table
)存储Node
,每个Node
对应一个键值对。 - 处理哈希冲突:当多个键的哈希值映射到同一个桶时,
HashMap
会将这些Node
以链表形式连接(Java 8 后可能转为红黑树)。 - 遍历操作:
HashMap
的迭代器(如entrySet().iterator()
)会遍历所有Node
。
注意事项
- 不可变性:
key
是final
的,因此键对象一旦存入HashMap
,不应再修改其内容(否则可能导致哈希值变化,无法正确查找)。 - 链表结构:在哈希冲突较多时,链表可能过长,影响性能(Java 8 引入了红黑树优化)。
- 线程不安全:
Node
本身不提供线程安全机制,多线程环境下需额外同步或使用ConcurrentHashMap
。
示例代码
以下是一个简单的 Node
使用示例(模拟 HashMap
的存储逻辑):
// 模拟一个简单的 HashMap 存储
Node<String, Integer>[] table = new Node[16];
// 计算 key 的哈希值并确定桶的位置
String key = "hello";
int hash = key.hashCode();
int index = (table.length - 1) & hash; // 取模运算
// 创建 Node 并存入 table
Node<String, Integer> node = new Node<>(hash, key, 42, null);
table[index] = node;
// 获取值
Node<String, Integer> retrievedNode = table[index];
System.out.println(retrievedNode.value); // 输出: 42
总结
Node
是 HashMap
实现键值对存储的核心数据结构,通过哈希值和链表(或红黑树)高效处理数据的插入、查找和删除。理解 Node
的结构有助于深入掌握 HashMap
的工作原理。
TreeNode 节点的定义
基本概念
TreeNode
是 HashMap
内部用于实现红黑树结构的节点类(在 JDK 1.8 及之后引入)。当哈希冲突严重时,HashMap
会将链表转换为红黑树以提高查询效率(默认阈值是链表长度超过 8 且桶数组长度 ≥ 64)。TreeNode
继承自 HashMap.Node
,但扩展了红黑树所需的属性和方法。
核心属性
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; // 标记节点颜色(红/黑)
}
关键特点
-
双重角色:
即使转换为红黑树,仍通过prev
和next
维护链表结构,便于退化为链表(如删除节点后树节点数 ≤ 6)。 -
红黑树规则:
- 每个节点非红即黑
- 根节点为黑
- 红色节点的子节点必须为黑
- 从任意节点到叶子节点的路径包含相同数量的黑色节点
示例代码片段
// TreeNode 的查找方法(简化版)
final TreeNode<K,V> find(int h, Object k, Class<?> kc) {
TreeNode<K,V> p = this;
do {
int ph, dir;
K pk;
TreeNode<K,V> pl = p.left, pr = p.right, q;
if ((ph = p.hash) > h)
p = pl;
else if (ph < h)
p = pr;
else if ((pk = p.key) == k || (k != null && k.equals(pk)))
return p;
else if (pl == null)
p = pr;
else if (pr == null)
p = pl;
else if ((dir = compareComparables(kc, k, pk)) != 0)
p = (dir < 0) ? pl : pr;
else if ((q = pr.find(h, k, kc)) != null)
return q;
else
p = pl;
} while (p != null);
return null;
}
注意事项
-
内存开销:
每个TreeNode
占用空间约为普通Node
的 2 倍,仅在冲突严重时使用。 -
退化机制:
扩容或删除节点时,若树节点数 ≤ 6,会退化为链表。 -
线程不安全:
红黑树的旋转操作在并发环境下可能导致结构破坏,需外部同步。
哈希桶数组 table
概念定义
table
是 HashMap
内部用于存储键值对的核心数据结构,它是一个 Node<K,V>[]
类型的数组。每个数组元素称为一个哈希桶(bucket),用于存放哈希冲突时通过链表或红黑树组织的多个 Node
节点。
核心特性
-
初始化时机
- 首次调用
put()
方法时通过resize()
初始化(默认长度 16) - 构造方法中指定初始容量时会计算最近的 2 次幂值(如输入 10 会初始化为 16)
- 首次调用
-
扩容机制
final Node<K,V>[] resize() { Node<K,V>[] oldTab = table; // 计算新容量(旧容量*2)和新阈值 newCap = oldCap << 1; // 数据迁移... }
触发条件:
- 元素数量 > 容量 × 负载因子(默认 0.75)
- 链表长度 > 8 且桶数组长度 < 64 时优先扩容
-
索引计算
通过哈希码与数组长度的模运算确定桶位置:(n - 1) & hash // n=table.length
等价于
hash % n
,但位运算效率更高
存储结构演变
场景 | 存储结构 | 转换条件 |
---|---|---|
正常情况 | 链表 | 默认 |
哈希冲突严重 | 红黑树 | 链表长度 ≥ 8 且桶数组长度 ≥ 64 |
扩容后分散 | 可能退化为链表 | 树节点数 ≤ 6 |
线程安全问题示例
// 线程不安全的操作示例
if (map.get(key) == null) {
map.put(key, value); // 可能覆盖其他线程的值
}
设计优化点
-
长度取 2 的幂次
- 使
(n-1) & hash
均匀分布 - 扩容时节点的新位置=原位置或原位置+旧容量
- 使
-
延迟初始化
避免创建未使用的HashMap
占用内存 -
树化阈值
防止哈希碰撞攻击导致链表过长(时间复杂度从 O(n) 降为 O(log n))
负载因子 (loadFactor)
概念定义
负载因子(loadFactor)是 HashMap 中一个重要的性能参数,用于衡量哈希表的填充程度。它表示哈希表中元素数量与容量(capacity)的比值。公式为:
loadFactor = size / capacity
其中:
size
是当前 HashMap 中存储的键值对数量capacity
是哈希表数组的长度
默认值
在 Java 的 HashMap 实现中,默认负载因子是 0.75。这个值是在时间和空间成本之间做出的折中选择。
作用原理
当 HashMap 中的元素数量达到 capacity * loadFactor
时,HashMap 会自动进行扩容(通常是当前容量的 2 倍),并重新哈希所有元素到新的桶数组中。
为什么需要负载因子
- 空间效率:较低的负载因子会浪费空间
- 时间效率:较高的负载因子会增加哈希冲突的概率
- 平衡点:0.75 的默认值在大多数情况下提供了良好的性能
性能影响
-
较低负载因子(如 0.5):
- 查找速度更快(减少冲突)
- 内存使用率较低(更多空桶)
-
较高负载因子(如 0.9):
- 内存使用率更高
- 但会增加哈希冲突,降低查找性能
使用示例
// 创建HashMap时指定初始容量和负载因子
Map<String, Integer> map = new HashMap<>(16, 0.5f);
// 添加元素直到触发扩容
for (int i = 0; i < 8; i++) { // 16 * 0.5 = 8
map.put("key"+i, i);
}
// 第9个put操作会触发扩容
map.put("key9", 9);
注意事项
- 不要设为1.0以上:这会导致哈希表在完全满时才扩容,性能急剧下降
- 特殊场景调整:
- 对查询性能要求高 → 使用较低负载因子
- 内存紧张 → 可以使用较高负载因子(但不建议超过0.9)
- 与初始容量配合:设置负载因子时应考虑初始容量,避免频繁扩容
底层实现细节
在Java 8的HashMap实现中,当桶中的链表长度超过8时,会转换为红黑树,这在一定程度上缓解了高负载因子带来的性能问题。
常见误区
- 认为负载因子越小越好(忽略了内存浪费)
- 认为负载因子只影响扩容时机(实际上也直接影响哈希冲突概率)
- 忽视负载因子与初始容量的配合关系
扩容阈值 threshold
概念定义
threshold
是 HashMap 中一个关键属性,表示当前 HashMap 触发扩容的临界值。当 HashMap 中存储的键值对数量(size
)超过 threshold
时,HashMap 就会自动进行扩容(resize)操作。
计算公式
threshold = capacity * loadFactor
其中:
capacity
:当前 HashMap 的数组(table)长度loadFactor
:负载因子(默认 0.75)
默认值示例
- 默认初始容量为 16,负载因子 0.75
threshold = 16 * 0.75 = 12
即当 HashMap 中元素超过 12 个时就会触发扩容
扩容过程
- 新建一个 2 倍大小的数组(newCap = oldCap << 1)
- 重新计算所有元素的哈希位置(rehash)
- 重新计算新的 threshold:
newThr = newCap * loadFactor
源码示例(JDK 8)
final Node<K,V>[] resize() {
// ...省略部分代码...
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
// ...省略部分代码...
threshold = newThr;
// ...省略部分代码...
}
注意事项
- 扩容代价高:需要重新计算所有元素的位置,应尽量避免频繁扩容
- 初始化设置:可以通过构造方法指定初始容量和负载因子
// 预估100个元素,避免频繁扩容 Map<String, String> map = new HashMap<>(128, 0.75f);
- 容量总是 2 的幂:实际 threshold 会取最接近的 2 的幂次方值
常见误区
- 误以为 size == capacity 时才扩容
实际是 size > threshold 时就会扩容(未达到 capacity 就会扩容) - 忽略负载因子的影响
负载因子越小,threshold 越小,空间利用率越低但查询效率越高
三、HashMap 关键字段
size:键值对数量
概念定义
size
是 HashMap
类中的一个属性,表示当前 HashMap
实例中存储的键值对(key-value
)的数量。它是一个 int
类型的变量,用于记录 HashMap
中实际存储的条目数。size
的值会随着 put()
和 remove()
等操作的执行而动态变化。
使用场景
-
判断
HashMap
是否为空
可以通过size
是否为0
来判断HashMap
是否为空。例如:if (map.size() == 0) { System.out.println("HashMap 为空"); }
-
遍历或操作
HashMap
时检查容量
在遍历或批量操作HashMap
时,可以通过size
提前判断是否需要执行某些逻辑。例如:if (map.size() > 100) { System.out.println("HashMap 中的键值对数量超过 100"); }
-
性能优化
在初始化HashMap
时,如果预先知道size
的大致范围,可以通过指定初始容量来减少扩容次数,提升性能。例如:int expectedSize = 100; HashMap<String, Integer> map = new HashMap<>(expectedSize);
常见误区或注意事项
-
size
与capacity
的区别size
表示当前存储的键值对数量。capacity
表示HashMap
底层数组的容量(即桶的数量)。
两者没有直接关系,size
可以大于capacity
(因为哈希冲突时,一个桶可能存储多个键值对)。
-
size
的线程安全问题
HashMap
是非线程安全的,如果在多线程环境下直接调用size()
方法,可能会导致数据不一致。建议使用ConcurrentHashMap
或通过同步机制保证线程安全。 -
size
的计算开销
size
是通过遍历所有桶统计的,虽然HashMap
的实现会缓存size
的值,但在高并发场景下仍需注意性能影响。
示例代码
import java.util.HashMap;
public class HashMapSizeExample {
public static void main(String[] args) {
HashMap<String, Integer> map = new HashMap<>();
// 添加键值对
map.put("A", 1);
map.put("B", 2);
map.put("C", 3);
// 获取 size
System.out.println("HashMap 的 size: " + map.size()); // 输出: 3
// 移除键值对
map.remove("B");
System.out.println("移除后 size: " + map.size()); // 输出: 2
// 清空 HashMap
map.clear();
System.out.println("清空后 size: " + map.size()); // 输出: 0
}
}
modCount:结构修改次数
概念定义
modCount
是 HashMap
中的一个 transient
修饰的 int
类型字段,用于记录 HashMap
结构被修改的次数。结构修改指的是那些会影响 HashMap
内部结构(如数组扩容、链表转红黑树等)的操作,例如 put
、remove
、clear
等。而仅仅更新某个键值对的值(不改变结构)不会增加 modCount
。
使用场景
modCount
的主要作用是实现 快速失败(fail-fast) 机制。当多个线程并发修改 HashMap
,或者在使用迭代器遍历时修改 HashMap
,modCount
会发生变化,此时迭代器会抛出 ConcurrentModificationException
异常,防止数据不一致。
实现原理
- 迭代器初始化时:迭代器会记录当前的
modCount
值(expectedModCount
)。 - 每次操作前检查:在调用
next()
、remove()
等方法时,迭代器会比较当前的modCount
和expectedModCount
。 - 不一致则抛出异常:如果发现
modCount != expectedModCount
,说明HashMap
被其他操作修改,抛出ConcurrentModificationException
。
示例代码
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
public class ModCountExample {
public static void main(String[] args) {
Map<String, Integer> map = new HashMap<>();
map.put("A", 1);
map.put("B", 2);
Iterator<Map.Entry<String, Integer>> iterator = map.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<String, Integer> entry = iterator.next();
System.out.println(entry.getKey() + ": " + entry.getValue());
map.put("C", 3); // 修改结构,导致 modCount 增加
}
}
}
运行结果:
A: 1
Exception in thread "main" java.util.ConcurrentModificationException
at java.util.HashMap$HashIterator.nextNode(HashMap.java:1445)
at java.util.HashMap$EntryIterator.next(HashMap.java:1479)
at java.util.HashMap$EntryIterator.next(HashMap.java:1477)
at ModCountExample.main(ModCountExample.java:13)
注意事项
- 线程不安全:
HashMap
不是线程安全的,即使有modCount
机制,也不能替代并发容器(如ConcurrentHashMap
)。 - 迭代器删除安全:使用迭代器的
remove()
方法删除元素不会触发ConcurrentModificationException
,因为迭代器内部会同步expectedModCount
。 - 单线程也可能触发:即使在单线程环境下,如果在遍历过程中直接调用
HashMap
的修改方法(如put
、remove
),也会抛出异常。
DEFAULT_INITIAL_CAPACITY 默认初始容量
概念定义
DEFAULT_INITIAL_CAPACITY
是 HashMap
类中的一个静态常量,表示 HashMap
在创建时默认的初始容量(即哈希表数组的初始大小)。在 Java 8 及以后的版本中,其默认值为 16。定义如下:
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 16
作用
- 初始容量:决定了
HashMap
在创建时内部哈希表数组的初始大小。 - 性能优化:合理的初始容量可以减少扩容(
resize
)操作的次数,从而提高性能。
扩容机制
HashMap
在元素数量超过 容量 * 负载因子(默认 0.75)
时会触发扩容。例如:
- 默认初始容量为 16,负载因子为 0.75,则当元素数量超过
16 * 0.75 = 12
时,HashMap
会扩容至32
(即当前容量的 2 倍)。
自定义初始容量
可以通过构造函数指定初始容量(必须是 2 的幂次方,如果不是,HashMap
会自动调整为最接近的 2 的幂次方值):
HashMap<String, Integer> map = new HashMap<>(32); // 初始容量为 32
注意事项
- 初始容量过小:如果预估元素数量较多但初始容量设置过小,会导致频繁扩容,影响性能。
- 初始容量过大:如果初始容量远大于实际元素数量,会浪费内存空间。
- 2 的幂次方:
HashMap
要求容量必须是 2 的幂次方,以便通过位运算优化哈希计算和索引定位。
示例代码
import java.util.HashMap;
public class HashMapExample {
public static void main(String[] args) {
// 使用默认初始容量(16)
HashMap<String, Integer> defaultMap = new HashMap<>();
System.out.println("Default initial capacity: " + defaultMap.size()); // 输出 0(注意:size() 返回的是元素数量,不是容量)
// 自定义初始容量(32)
HashMap<String, Integer> customMap = new HashMap<>(32);
customMap.put("Key1", 1);
System.out.println("Custom initial capacity map size: " + customMap.size()); // 输出 1
}
}
MAXIMUM_CAPACITY 最大容量
概念定义
MAXIMUM_CAPACITY
是 Java 中 HashMap
类定义的一个静态常量,表示 HashMap
所能容纳的最大桶(bucket)数量。其值为 1 << 30
,即 2 的 30 次方(1,073,741,824)。
为什么是这个值?
- 位运算优化:
HashMap
的容量必须是 2 的幂次方,以便通过位运算(hash & (capacity - 1)
)快速计算键的哈希桶位置。 - Java 数组限制:Java 数组的最大长度是
Integer.MAX_VALUE - 8
(约 21 亿),但HashMap
选择1 << 30
是为了避免极端情况下的内存问题和性能开销。 - 实际需求:绝大多数场景下,
1 << 30
已经足够大,几乎不会成为瓶颈。
源码中的体现
static final int MAXIMUM_CAPACITY = 1 << 30;
在 HashMap
的扩容逻辑中,如果计算出的新容量超过 MAXIMUM_CAPACITY
,则会直接设置为 MAXIMUM_CAPACITY
:
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
注意事项
- 不会自动扩容到最大值:
HashMap
的默认初始容量是 16,扩容因子是 0.75,只有显式指定容量时才会接近MAXIMUM_CAPACITY
。 - 内存占用:即使容量设置为
MAXIMUM_CAPACITY
,实际占用内存也会远小于理论值(因为哈希表是稀疏的)。 - 性能问题:过大的容量会导致哈希冲突增加和遍历效率下降,应避免不必要的超大容量设置。
示例代码
// 显式指定 HashMap 的初始容量为最大值
HashMap<String, Integer> map = new HashMap<>(1 << 30);
// 尝试放入元素(实际不会真正占用 1 << 30 的内存)
map.put("key", 1);
常见误区
- 误认为
MAXIMUM_CAPACITY
是Integer.MAX_VALUE
:实际上它是 2^30,比Integer.MAX_VALUE
(2^31 - 1)小。 - 混淆容量和大小:容量是桶的数量,大小是实际存储的键值对数量。
DEFAULT_LOAD_FACTOR 默认负载因子
概念定义
DEFAULT_LOAD_FACTOR
是 HashMap
中的一个静态常量,表示哈希表的默认负载因子(Load Factor),其值为 0.75
。负载因子是哈希表在扩容前允许达到的填充比例,用于衡量哈希表的空间利用率。
负载因子的作用
- 扩容触发条件:当哈希表中的元素数量超过
容量 × 负载因子
时,哈希表会触发扩容(通常是容量翻倍)。- 例如:默认初始容量为 16,负载因子为 0.75,当元素数量超过
16 × 0.75 = 12
时,哈希表会扩容至 32。
- 例如:默认初始容量为 16,负载因子为 0.75,当元素数量超过
- 平衡性能与空间:
- 负载因子较高(如 0.75):减少扩容次数,节省内存,但哈希冲突概率增加,查询效率降低。
- 负载因子较低(如 0.5):减少哈希冲突,提高查询效率,但会频繁扩容,浪费内存。
为什么默认值是 0.75?
0.75
是 Java 设计者在时间和空间成本上的折中选择:
- 通过数学统计(泊松分布)和实验验证,0.75 能在哈希冲突和空间占用之间取得较好的平衡。
- 更高的值(如 0.8)会导致冲突显著增加,更低的值(如 0.6)会浪费过多内存。
使用场景
- 自定义负载因子:
- 如果对查询性能要求极高且内存充足,可以设置更低的负载因子(如 0.5)。
- 如果内存紧张且能接受一定的性能损失,可以设置更高的负载因子(如 0.8)。
// 创建 HashMap 时指定负载因子 Map<String, Integer> map = new HashMap<>(16, 0.5f);
- 避免频繁扩容:
- 如果预先知道元素数量,可以通过
初始容量 = 预期元素数量 / 负载因子
计算初始容量,避免多次扩容。
// 预期存储 100 个元素,避免扩容 Map<String, Integer> map = new HashMap<>((int) (100 / 0.75) + 1, 0.75f);
- 如果预先知道元素数量,可以通过
注意事项
- 非线程安全:
HashMap
的扩容操作是非线程安全的,多线程环境下应使用ConcurrentHashMap
。
- 哈希冲突影响:
- 即使负载因子合理,不良的
hashCode()
实现仍可能导致严重冲突。需确保键对象的hashCode()
分布均匀。
- 即使负载因子合理,不良的
- 初始容量与负载因子的关系:
- 初始容量和负载因子共同决定扩容阈值,修改负载因子会影响扩容行为。
示例代码
public class LoadFactorExample {
public static void main(String[] args) {
// 默认负载因子 0.75
Map<String, Integer> defaultMap = new HashMap<>();
// 自定义负载因子 0.5
Map<String, Integer> customMap = new HashMap<>(16, 0.5f);
// 填充元素直到触发扩容
for (int i = 0; i < 12; i++) {
defaultMap.put("key" + i, i);
customMap.put("key" + i, i);
}
System.out.println("Default map size: " + defaultMap.size()); // 12(未扩容)
System.out.println("Custom map size: " + customMap.size()); // 12(已扩容)
}
}
TREEIFY_THRESHOLD 树化阈值
概念定义
TREEIFY_THRESHOLD
是 HashMap
中的一个常量,表示链表转换为红黑树的阈值。在 Java 8 及之后的版本中,HashMap
的底层实现引入了红黑树优化性能。当链表的长度达到 TREEIFY_THRESHOLD
时,HashMap
会将该链表转换为红黑树,以减少查询时间。
在 Java 8 中,TREEIFY_THRESHOLD
的默认值为 8,定义如下:
static final int TREEIFY_THRESHOLD = 8;
使用场景
- 优化哈希冲突:当多个键的哈希值映射到同一个桶(bucket)时,
HashMap
会使用链表存储这些键值对。但如果链表过长(比如达到TREEIFY_THRESHOLD
),查询效率会下降(从O(1)
退化为O(n)
)。此时,HashMap
会将链表转换为红黑树,将查询时间优化为O(log n)
。 - 防止哈希碰撞攻击:如果恶意攻击者构造大量哈希冲突的键,会导致链表过长,严重影响性能。引入红黑树可以缓解这一问题。
树化的条件
链表转换为红黑树需要满足两个条件:
- 链表长度达到
TREEIFY_THRESHOLD
(默认 8)。 HashMap
的容量达到MIN_TREEIFY_CAPACITY
(默认 64)。如果容量不足,HashMap
会优先扩容(resize
)而不是树化。
示例代码
以下是一个模拟链表树化的示例:
import java.util.HashMap;
public class HashMapTreeifyExample {
public static void main(String[] args) {
HashMap<Key, Integer> map = new HashMap<>(64); // 初始容量设为 64,避免优先扩容
// 插入 8 个哈希冲突的键
for (int i = 0; i < 8; i++) {
map.put(new Key(i), i);
}
System.out.println("HashMap 结构可能已树化(若调试可看到 TreeNode)");
}
static class Key {
int value;
Key(int value) { this.value = value; }
// 重写 hashCode,确保所有 Key 的哈希值相同,强制哈希冲突
@Override
public int hashCode() { return 42; }
}
}
注意事项
- 树化是单向的:当红黑树的节点数减少到
UNTREEIFY_THRESHOLD
(默认 6)时,会退化为链表。 - 性能权衡:树化需要额外空间和维护成本,因此仅在链表较长时触发。
- 哈希函数的重要性:良好的
hashCode()
实现可以减少哈希冲突,避免不必要的树化。
UNTREEIFY_THRESHOLD 链化阈值
概念定义
UNTREEIFY_THRESHOLD
是 HashMap
中的一个常量,其值为 6。它表示在哈希表扩容或删除元素时,当红黑树节点数量小于等于该阈值时,会将红黑树退化为链表结构。
使用场景
- 红黑树退化:当
HashMap
的某个桶(bucket)中的红黑树节点数因删除或扩容操作减少到UNTREEIFY_THRESHOLD
或更少时,会触发树退化为链表的操作。 - 性能优化:避免在节点数较少时维护红黑树的开销,因为链表在小数据量下的操作效率更高。
常见误区或注意事项
- 与
TREEIFY_THRESHOLD
的区别:TREEIFY_THRESHOLD
(默认 8)是链表转红黑树的阈值。UNTREEIFY_THRESHOLD
(默认 6)是红黑树退化为链表的阈值。- 两者不相等是为了避免频繁的树化和链化操作(即“抖动”现象)。
- 扩容影响:在
resize()
过程中,如果红黑树节点分布到新桶后数量不足,也会触发退化。
示例代码
以下是 HashMap
中判断是否需要退化为链表的代码片段(JDK 8 源码):
final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
// ...
if (loHead != null) {
if (lc <= UNTREEIFY_THRESHOLD)
tab[index] = loHead.untreeify(map); // 退化为链表
else {
tab[index] = loHead;
if (hiHead != null)
loHead.treeify(tab); // 保持树结构
}
}
}
设计意义
通过设置 UNTREEIFY_THRESHOLD
,HashMap
在动态调整数据结构时实现了空间与时间的平衡:
- 空间效率:链表比红黑树占用更少内存。
- 时间效率:小规模数据下链表的插入/删除操作更快。
MIN_TREEIFY_CAPACITY 最小树化容量
概念定义
MIN_TREEIFY_CAPACITY
是 Java HashMap 中的一个常量,值为 64。它定义了 HashMap 在链表转换为红黑树(树化)时要求的最小哈希表容量。换句话说,只有当哈希表的容量达到或超过 MIN_TREEIFY_CAPACITY
(64)且某个桶(bucket)中的链表长度超过 TREEIFY_THRESHOLD
(默认8)时,才会将该链表转换为红黑树。
设计目的
- 避免早期树化:如果哈希表容量很小(比如默认初始容量16),此时哈希碰撞概率较高,扩容(resize)可能是更优的选择,而不是直接树化。树化需要额外的内存和计算开销。
- 平衡性能与空间:在哈希表容量较小时,扩容可以更均匀地分散键值对,而树化更适合处理容量较大时的极端哈希冲突情况。
源码示例
在 HashMap
的源码中,树化逻辑如下(JDK 8+):
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
// 如果哈希表容量小于 MIN_TREEIFY_CAPACITY(64),优先扩容
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
// 否则,将链表转换为红黑树
TreeNode<K,V> hd = null, tl = null;
do {
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null)
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
使用场景
- 高冲突场景:当哈希表容量 ≥64 且某个桶的链表长度 ≥8 时,树化会显著提升查询效率(从 O(n) 提升到 O(log n))。
- 动态扩容:如果容量不足 64,即使链表长度达到 8,HashMap 也会选择扩容而非树化。
注意事项
- 树化不是立即触发:需要同时满足两个条件:
- 哈希表容量 ≥
MIN_TREEIFY_CAPACITY
(64)。 - 链表长度 ≥
TREEIFY_THRESHOLD
(8)。
- 哈希表容量 ≥
- 反向操作:当红黑树节点数 ≤
UNTREEIFY_THRESHOLD
(6)时,会退化为链表。 - 性能权衡:树化虽然提高了查询效率,但增加了插入和删除的复杂度。
四、HashMap 哈希计算
hash() 方法的作用
概念定义
hash()
方法是 Java 中 HashMap
内部用于计算键(Key)的哈希值的方法。它的主要作用是将任意长度的输入(键对象)通过哈希算法转换为固定长度的输出(哈希值)。这个哈希值用于确定键值对在哈希表中的存储位置(即数组的索引)。
核心功能
- 均匀分布:
hash()
方法的设计目标是让键的哈希值尽可能均匀分布,以减少哈希冲突(即不同键计算出的哈希值相同的情况)。 - 快速定位:通过哈希值可以快速定位到键值对在哈希表中的存储位置,从而实现高效的查找、插入和删除操作。
- 减少冲突:在 Java 8 及以后版本中,
HashMap
对哈希值进行了二次处理(扰动函数),以减少低位相同的键导致的哈希冲突。
实现细节
在 HashMap
中,hash()
方法的实现通常如下(以 Java 8 为例):
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
- 如果键为
null
,则哈希值为0
,HashMap
会将键为null
的键值对存储在数组的第一个位置(索引 0)。 - 如果键不为
null
,则调用键对象的hashCode()
方法获取原始哈希值,然后通过异或操作(^
)将原始哈希值的高 16 位和低 16 位混合,以减少哈希冲突。
使用场景
- 键的定位:
hash()
方法用于计算键的哈希值,进而确定键值对在哈希表中的存储位置。int index = (n - 1) & hash(key); // n 是哈希表数组的长度
- 哈希冲突处理:当多个键的哈希值相同时,
HashMap
会将这些键值对存储在同一个桶(bucket)中,形成链表或红黑树(Java 8 及以后版本)。
注意事项
- 重写
hashCode()
:如果使用自定义对象作为键,必须正确重写hashCode()
方法,以确保哈希值的均匀分布。否则可能导致哈希冲突频繁,降低HashMap
的性能。 - 哈希冲突:即使
hash()
方法设计良好,哈希冲突仍不可避免。HashMap
通过链表和红黑树(在冲突较多时)来解决冲突。 - 性能影响:
hash()
方法的效率直接影响HashMap
的操作性能(如put
、get
)。设计良好的哈希函数可以显著提升性能。
示例代码
以下是一个自定义键对象并重写 hashCode()
的示例:
class Person {
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public int hashCode() {
// 使用 name 和 age 计算哈希值
return Objects.hash(name, age);
}
@Override
public boolean equals(Object obj) {
// 必须同时重写 equals 方法
if (this == obj) return true;
if (obj == null || getClass() != obj.getClass()) return false;
Person person = (Person) obj;
return age == person.age && Objects.equals(name, person.name);
}
}
public class Main {
public static void main(String[] args) {
HashMap<Person, String> map = new HashMap<>();
Person p1 = new Person("Alice", 25);
map.put(p1, "Engineer");
System.out.println(map.get(p1)); // 输出: Engineer
}
}
总结
hash()
方法是 HashMap
实现高效键值存储和检索的核心机制之一。它通过计算键的哈希值,快速定位存储位置,并结合冲突处理策略(链表或红黑树)确保性能。正确实现 hashCode()
是使用 HashMap
的关键。
key 的 hashCode 处理
概念定义
在 HashMap 中,key
的 hashCode
是一个关键步骤,用于确定键值对在哈希表中的存储位置。hashCode
是一个由 Object
类定义的方法,任何 Java 对象都可以调用该方法返回一个整数值(哈希码)。HashMap 通过 hashCode
计算键的哈希值,进而决定其在数组(桶)中的索引位置。
hashCode 的作用
- 快速定位:通过哈希值快速定位键值对的存储位置(数组索引),避免遍历整个数据结构。
- 减少冲突:良好的
hashCode
实现可以均匀分布键值对,减少哈希冲突(即不同键映射到同一索引的情况)。
HashMap 中的处理流程
- 调用 key 的 hashCode():获取键的原始哈希值。
- 扰动函数(哈希再计算):HashMap 会对原始哈希值进行二次处理(扰动),以减少哈希冲突。Java 8 中的扰动函数如下:
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
- 将哈希值的高 16 位与低 16 位进行异或运算,使低位信息包含高位信息,减少哈希冲突。
- 计算桶索引:通过
(n - 1) & hash
计算索引(n
是数组长度,hash
是扰动后的哈希值)。
注意事项
- 重写 hashCode() 的规则:
- 一致性:如果
equals()
方法判定两个对象相等,则它们的hashCode()
必须相同。 - 高效性:计算应尽量快速。
- 均匀性:不同对象的哈希值应尽量分布均匀,以减少冲突。
- 一致性:如果
- 默认 hashCode() 的问题:
Object
类的默认实现(通常是对象内存地址的哈希)可能导致不同对象哈希值相同概率低,但不符合业务逻辑的相等性。- 例如,两个内容相同的
String
对象应返回相同哈希值,因此String
类重写了hashCode()
。
- 哈希冲突:
- 即使经过扰动,仍可能发生冲突。HashMap 使用链表或红黑树(Java 8+)解决冲突。
示例代码
public class KeyExample {
private int id;
private String name;
@Override
public int hashCode() {
// 使用 Objects.hash() 自动生成复合属性的哈希值
return Objects.hash(id, name);
}
@Override
public boolean equals(Object obj) {
// 必须同时重写 equals 和 hashCode
if (this == obj) return true;
if (obj == null || getClass() != obj.getClass()) return false;
KeyExample other = (KeyExample) obj;
return id == other.id && Objects.equals(name, other.name);
}
}
常见误区
- 仅重写 hashCode() 不重写 equals():
- 违反
hashCode
契约,导致HashMap
无法正确判断键是否相等。
- 违反
- 可变对象作为键:
- 如果键的
hashCode
依赖可变字段,修改字段后会导致HashMap
无法定位到原来的值。 - 例如:
Map<KeyExample, String> map = new HashMap<>(); KeyExample key = new KeyExample(1, "A"); map.put(key, "Value"); key.setId(2); // 修改后,hashCode() 结果可能变化,导致无法通过 get(key) 获取值
- 如果键的
- 忽略哈希分布:
- 简单的哈希实现(如直接返回固定值)会导致所有键冲突,退化为链表,性能从 O(1) 降为 O(n)。
扰动函数的设计目的
概念定义
扰动函数(Hash Function Perturbation)是 HashMap 在计算键(Key)的哈希值时,对原始哈希值进行二次处理的算法。在 Java 的 HashMap 实现中,扰动函数通常表现为对键的 hashCode()
返回值进行位运算(如异或、位移等),以生成最终的哈希值。
核心目的
-
减少哈希冲突
- 原始
hashCode()
可能分布不均匀,尤其是低位的随机性较差(例如某些对象的哈希值集中在某几位)。 - 扰动函数通过混合高位和低位信息(如
hashCode ^ (hashCode >>> 16)
),增强哈希值的随机性,使数据更均匀地分布在桶(Bucket)中。
- 原始
-
优化哈希表性能
- 均匀的哈希分布能减少链表长度(或红黑树高度),从而提升
get()
和put()
操作的效率。 - 避免极端情况下哈希冲突导致的性能退化(如链表退化为线性查找)。
- 均匀的哈希分布能减少链表长度(或红黑树高度),从而提升
Java 中的实现示例
以下是 Java 8 HashMap 中扰动函数的源码片段:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
^ (h >>> 16)
:将哈希值的高 16 位与低 16 位进行异或,保留高位信息的同时增加低位的随机性。
为什么需要扰动?
- 直接使用
hashCode()
的问题:
假设 HashMap 的桶数量为n
,计算桶下标的方式是hashCode % n
(实际为(n-1) & hash
)。若hashCode
低位重复度高(如连续数字的哈希值),会导致大量键落入同一个桶中。 - 扰动后的效果:
通过高位参与运算,使得即使原始hashCode
低位相似,最终哈希值的低位也会因高位影响而不同。
注意事项
-
扰动与桶数量的关系
- 扰动函数的效果在桶数量较少时(如初始容量 16)更显著,因为低位决定了桶下标。
- 随着扩容(桶数量增加),高位信息会逐步参与下标计算。
-
自定义对象的
hashCode()
- 若用户重写的
hashCode()
质量较高(分布均匀),扰动函数的优化效果可能减弱,但仍需保留以保证通用性。
- 若用户重写的
对比未扰动的情况
假设两个键的 hashCode()
分别为 0x0000FFFF
和 0x0000FFFE
,桶数量为 16(n-1 = 0x0000000F
):
- 未扰动:
0x0000FFFF & 0xF = 15
,0x0000FFFE & 0xF = 14
(无冲突,但若低位相同则冲突)。 - 扰动后:
0x0000FFFF ^ (0x0000FFFF >>> 16) = 0xFFFF0000 ^ 0x0000FFFF = 0xFFFFFFFF
→ 桶下标 15。
0x0000FFFE ^ (0x0000FFFE >>> 16) = 0xFFFF0000 ^ 0x0000FFFE = 0xFFFF0000
→ 桶下标 0。
结果:即使原始哈希值连续,扰动后分布更分散。
哈希冲突的解决方式
概念定义
哈希冲突(Hash Collision)是指在使用哈希表(如 HashMap
)时,两个或多个不同的键(key)通过哈希函数计算后得到相同的哈希值,导致它们需要存储在同一个位置(桶)中。哈希冲突是不可避免的,因为哈希函数的输出空间通常远小于输入空间(例如,HashMap
的哈希值最终会映射到一个有限的数组索引上)。
常见解决方式
1. 链地址法(Separate Chaining)
- 原理:每个哈希表的桶(bucket)是一个链表(或树结构),所有哈希值相同的键值对会以链表形式存储在该桶中。
- Java 实现:
HashMap
在 JDK 1.8 之前完全使用链表,JDK 1.8 后当链表长度超过阈值(默认 8)时,链表会转换为红黑树以提高查询效率。 - 优点:
- 实现简单。
- 适合频繁插入和删除的场景。
- 缺点:
- 链表过长时(极端情况下退化为线性结构),查询效率会降低(从
O(1)
退化为O(n)
)。 - 需要额外空间存储链表指针。
- 链表过长时(极端情况下退化为线性结构),查询效率会降低(从
示例代码(链地址法简化实现)
class Entry<K, V> {
K key;
V value;
Entry<K, V> next; // 链表指针
}
class HashMap<K, V> {
Entry<K, V>[] buckets; // 桶数组
void put(K key, V value) {
int hash = key.hashCode() % buckets.length;
Entry<K, V> entry = new Entry<>(key, value);
if (buckets[hash] == null) {
buckets[hash] = entry;
} else {
entry.next = buckets[hash]; // 头插法
buckets[hash] = entry;
}
}
}
2. 开放寻址法(Open Addressing)
- 原理:当发生冲突时,按照某种探测规则(如线性探测、平方探测)在哈希表中寻找下一个空闲的桶,直到找到空位。
- 常见探测方式:
- 线性探测:依次检查
hash(key) + 1, hash(key) + 2, ...
。 - 平方探测:检查
hash(key) + 1², hash(key) + 2², ...
。 - 双重哈希:使用第二个哈希函数计算步长。
- 线性探测:依次检查
- 优点:
- 无需额外空间存储链表。
- 缓存友好(数据连续存储)。
- 缺点:
- 删除操作复杂(需标记为“已删除”而非直接置空)。
- 容易发生聚集(Clustering),降低性能。
示例代码(线性探测简化实现)
class HashMap<K, V> {
Entry<K, V>[] table;
void put(K key, V value) {
int hash = key.hashCode() % table.length;
while (table[hash] != null && !table[hash].key.equals(key)) {
hash = (hash + 1) % table.length; // 线性探测
}
table[hash] = new Entry<>(key, value);
}
}
3. 再哈希法(Rehashing)
- 原理:当发生冲突时,使用另一个哈希函数重新计算哈希值,直到找到空桶。
- 优点:减少聚集现象。
- 缺点:需要设计多个高效的哈希函数。
4. 公共溢出区法(Overflow Area)
- 原理:将冲突的键值对统一存储在一个单独的溢出区(如另一个数组)。
- 适用场景:冲突较少时效率较高。
- 缺点:溢出区过大时会退化为线性查找。
注意事项
- 负载因子(Load Factor):
HashMap
默认负载因子为0.75
,当元素数量超过容量 * 负载因子
时触发扩容。- 负载因子过高会增加冲突概率,过低会浪费空间。
- 哈希函数设计:
- 好的哈希函数应均匀分布键(如
HashMap
的hash()
方法通过高 16 位异或低 16 位减少碰撞)。
- 好的哈希函数应均匀分布键(如
- JDK 优化:
- JDK 1.8 的
HashMap
在链表长度 ≥8 且桶数组长度 ≥64 时,将链表转为红黑树(查询时间从O(n)
优化为O(log n)
)。
- JDK 1.8 的
五、HashMap 核心方法实现
put() 方法的执行流程
HashMap 的 put()
方法是其核心操作之一,用于将键值对存储到哈希表中。以下是其详细的执行流程:
1. 计算键的哈希值
首先,put()
方法会调用键对象的 hashCode()
方法计算其哈希值。HashMap 会对该哈希值进行二次哈希(扰动函数)以减少哈希冲突:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
这一步的目的是让哈希值的高位也参与运算,从而降低哈希冲突的概率。
2. 计算数组下标
根据哈希值和数组长度计算键值对应在数组中的下标:
int index = (n - 1) & hash;
其中 n
是数组的长度(始终是 2 的幂次方),&
操作相当于取模运算,但效率更高。
3. 处理数组位置
检查计算出的数组位置:
- 如果该位置为空(
null
),直接创建一个新节点并放入该位置。 - 如果该位置不为空(哈希冲突),则进入链表或红黑树的处理逻辑。
4. 处理哈希冲突
如果发生哈希冲突,HashMap 会遍历该位置的链表或红黑树:
-
链表处理:
- 遍历链表,比较每个节点的键是否与当前键相同(通过
equals()
方法)。 - 如果找到相同的键,则更新对应的值。
- 如果没有找到,则在链表尾部插入新节点。
- 如果链表长度超过
TREEIFY_THRESHOLD
(默认为 8),则转换为红黑树。
- 遍历链表,比较每个节点的键是否与当前键相同(通过
-
红黑树处理:
- 如果当前节点是树节点(
TreeNode
),则调用红黑树的插入方法。 - 红黑树会保持平衡,确保查找效率为 O(log n)。
- 如果当前节点是树节点(
5. 判断是否需要扩容
插入新节点后,检查当前元素数量是否超过阈值(threshold = capacity * loadFactor
):
- 如果超过阈值,则调用
resize()
方法进行扩容。 - 扩容时,数组大小变为原来的 2 倍,并重新计算所有节点的位置。
6. 返回旧值(如果存在)
如果插入的键已存在,put()
方法会返回旧值;否则返回 null
。
示例代码
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 如果数组为空或长度为 0,则扩容
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 计算下标,如果该位置为空,直接插入新节点
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
// 如果键已存在,则更新值
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) {
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;
}
}
// 更新已存在的键的值
if (e != null) {
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
// 检查是否需要扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
注意事项
- 哈希冲突:良好的
hashCode()
实现可以减少冲突,提高性能。 - 扩容开销:扩容会重新分配所有节点,影响性能,建议初始化时预估容量。
- 线程不安全:
put()
方法非线程安全,多线程环境下需使用ConcurrentHashMap
。 - 红黑树转换:链表长度超过 8 且数组长度 ≥ 64 时才会转换为红黑树,否则优先扩容。
HashMap 的 get() 方法执行流程
1. 方法签名
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
2. 核心步骤
2.1 计算哈希值
- 调用
hash(key)
方法计算 key 的哈希值 - 实际是调用 key 的
hashCode()
并进行二次扰动:static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
2.2 定位桶位置
- 通过
(n - 1) & hash
计算桶下标(n 是数组长度) - 相当于
hash % n
但效率更高
2.3 遍历链表/红黑树
- 检查第一个节点:
- 比较 hash 值
- 比较 key 的地址或 equals() 方法
- 如果不匹配:
- 链表:顺序遍历直到找到或到达末尾
- 红黑树:调用
TreeNode.getTreeNode()
进行树查找
2.4 返回结果
- 找到节点:返回节点的 value
- 未找到:返回 null
3. 关键代码实现
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
// 1. 检查表不为空且长度>0
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);
}
}
return null;
}
4. 时间复杂度分析
- 最佳情况(无冲突):O(1)
- 最坏情况(所有 key 哈希冲突):
- JDK 1.8 前:O(n)
- JDK 1.8+:链表转红黑树后 O(log n)
5. 注意事项
-
key 的 equals() 和 hashCode():
- 必须保证:如果
a.equals(b)
,则a.hashCode() == b.hashCode()
- 违反此规则会导致无法正确获取值
- 必须保证:如果
-
并发问题:
- HashMap 非线程安全
- 并发 get() 可能遇到脏读问题
-
null 键处理:
- HashMap 允许一个 null 键
- 存储在数组第 0 个桶位置
-
扩容影响:
- 扩容会重建哈希表,但不影响 get() 的正确性
- 可能暂时增加查询时间
resize() 扩容机制
概念定义
resize()
是 HashMap 内部用于扩容的核心方法,当 HashMap 中的元素数量超过阈值(threshold)时触发,目的是扩大哈希表的容量,减少哈希冲突,提高查询效率。扩容过程包括创建新的哈希表、重新计算元素位置(rehash)和迁移数据。
触发条件
- 首次插入元素:HashMap 初始化时(无参构造)不会立即分配空间,首次调用
put()
时通过resize()
初始化默认容量(16)。 - 元素数量超过阈值:阈值 = 当前容量(capacity) × 负载因子(loadFactor,默认0.75)。例如,默认容量16的 HashMap 在插入第13个元素(16×0.75=12)时触发扩容。
扩容流程
- 计算新容量和新阈值:
- 新容量 = 旧容量 × 2(保证始终是2的幂)。
- 新阈值 = 新容量 × 负载因子。
- 创建新数组:基于新容量初始化新的
Node<K,V>[]
数组。 - 数据迁移(rehash):
- 遍历旧数组的每个桶(bucket),对每个节点重新计算哈希值和新位置。
- JDK 8 优化:若桶中是链表或红黑树,根据
(e.hash & oldCap) == 0
判断节点是否需要移动(高位链表或低位链表),避免全量重新哈希。
示例代码(简化版)
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
// 计算新容量和阈值
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
newCap = oldCap << 1; // 容量翻倍
newThr = oldThr << 1; // 阈值翻倍
}
// 初始化逻辑(首次put时触发)
else if (oldThr > 0) newCap = oldThr;
else {
newCap = DEFAULT_INITIAL_CAPACITY; // 默认16
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); // 12
}
// 创建新数组并迁移数据
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 { // 链表
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
do {
// 根据高位是否为0拆分链表
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 = 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;
}
注意事项
- 线程不安全:多线程环境下,
resize()
可能导致循环链表(JDK 7 问题)或数据丢失。 - 性能开销:扩容涉及全量数据迁移,应尽量避免频繁扩容。初始化时可预估容量,如
new HashMap<>(expectedSize)
。 - 红黑树处理:若迁移后链表长度小于6,红黑树会退化为链表。
优化点(JDK 8)
- 高位低位链表拆分:通过
(e.hash & oldCap)
判断节点位置是否需要调整,减少哈希计算次数。 - 懒加载:首次
put()
时才初始化数组,节省内存。
treeifyBin() 树化过程
概念定义
treeifyBin()
是 HashMap 中的一个方法,用于将链表结构转换为红黑树结构。当链表长度超过阈值(默认为 8)且 HashMap 的容量达到一定大小(默认为 64)时,链表会被转换为红黑树,以提高查询效率。
使用场景
- 链表过长:当哈希冲突严重时,链表的长度会变长,导致查询效率下降(从 O(1) 退化为 O(n))。
- 容量足够大:HashMap 的容量必须达到
MIN_TREEIFY_CAPACITY
(默认为 64),否则会优先扩容而不是树化。
方法执行流程
- 检查容量:如果当前 HashMap 的容量小于
MIN_TREEIFY_CAPACITY
(64),则调用resize()
扩容,而不是树化。 - 链表转红黑树:
- 遍历链表节点,将每个节点转换为
TreeNode
(红黑树节点)。 - 将链表转换为红黑树结构,并维护红黑树的平衡性。
- 遍历链表节点,将每个节点转换为
示例代码
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
// 检查容量是否足够
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null;
// 遍历链表,将 Node 转换为 TreeNode
do {
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null)
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
// 将 TreeNode 链表转换为红黑树
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
注意事项
- 树化阈值:链表长度必须达到
TREEIFY_THRESHOLD
(8)才会触发树化。 - 容量限制:如果 HashMap 容量小于
MIN_TREEIFY_CAPACITY
(64),会优先扩容。 - 退化为链表:当红黑树的节点数小于
UNTREEIFY_THRESHOLD
(6)时,红黑树会退化为链表。
常见误区
- 树化一定会发生:不一定,如果容量不足,会优先扩容。
- 树化后性能一定更好:红黑树的查询效率是 O(log n),但在节点较少时,链表的性能可能更好。
- 树化是立即的:树化是一个耗时的操作,会遍历链表并重建红黑树结构。
总结
treeifyBin()
是 HashMap 优化查询性能的重要手段,通过将过长的链表转换为红黑树,避免哈希冲突导致的性能下降。但树化并非总是发生,需满足容量和链表长度的双重条件。
untreeify() 链化过程
概念定义
untreeify()
是 HashMap
中的一个内部方法,用于将红黑树结构退化为链表结构。当红黑树中的节点数量减少到一定阈值(默认为 6)时,HashMap
会调用此方法将树节点转换为普通链表节点,以节省内存和提高性能。
使用场景
- 红黑树节点数量减少:当
HashMap
中的红黑树节点数量因删除操作而低于阈值(UNTREEIFY_THRESHOLD
,默认为 6)时,会触发untreeify()
。 - 扩容时拆分树节点:在
HashMap
扩容(resize()
)过程中,如果拆分后的树节点数量不足,也会调用untreeify()
退化为链表。
方法实现
以下是 untreeify()
的核心实现逻辑(基于 JDK 8 源码):
final Node<K,V> untreeify(HashMap<K,V> map) {
Node<K,V> hd = null, tl = null; // hd: 头节点, tl: 尾节点
for (Node<K,V> q = this; q != null; q = q.next) {
// 将 TreeNode 替换为普通的 Node
Node<K,V> p = map.replacementNode(q, null);
if (tl == null)
hd = p;
else
tl.next = p;
tl = p;
}
return hd;
}
关键步骤
- 遍历树节点:从当前节点(
this
)开始,按链表顺序遍历所有节点。 - 替换节点类型:通过
replacementNode()
将TreeNode
转换为普通Node
。 - 重建链表:维护头节点(
hd
)和尾节点(tl
),将转换后的节点按顺序链接。
注意事项
- 性能开销:
untreeify()
是线性时间复杂度(O(n)),但仅在树节点较少时调用,对整体性能影响较小。 - 阈值设置:退化阈值(6)与树化阈值(8)不同,避免了频繁的树化和链化切换。
- 并发问题:
HashMap
非线程安全,untreeify()
过程中若并发修改可能导致数据不一致。
示例场景
假设一个 HashMap
的桶中原本有 8 个节点(已树化),经过多次删除后剩余 5 个节点:
HashMap<Integer, String> map = new HashMap<>();
// 插入 8 个节点到同一桶(触发树化)
for (int i = 0; i < 8; i++) {
map.put(i, "Value" + i);
}
// 删除 3 个节点(剩余 5 个,触发 untreeify)
map.remove(0);
map.remove(1);
map.remove(2);
// 此时桶内结构从红黑树退化为链表
六、HashMap 迭代器
KeySet 的实现
概念定义
KeySet
是 HashMap
内部的一个视图集合,用于存储 HashMap
中所有的键(Key)。它并不实际存储键值对,而是通过引用 HashMap
的底层数据结构(如数组 + 链表/红黑树)来动态获取键的集合。KeySet
实现了 Set
接口,因此具有 Set
的特性(无序、不可重复)。
核心实现原理
- 延迟初始化:
KeySet
通常在第一次调用keySet()
方法时创建,而不是在HashMap
初始化时就生成。 - 视图机制:
KeySet
不独立存储数据,而是通过操作HashMap
的底层数组和节点(Node
或TreeNode
)来获取键的集合。 - 迭代器支持:
KeySet
提供了iterator()
方法,返回的迭代器会遍历HashMap
的所有桶(bucket),依次返回键。
源码分析(基于 JDK 8)
final class KeySet extends AbstractSet<K> {
// 返回键的迭代器
public final Iterator<K> iterator() { return new KeyIterator(); }
public final int size() { return size; } // 直接引用 HashMap 的 size
public final boolean contains(Object o) { return containsKey(o); } // 委托给 HashMap
public final boolean remove(Object key) {
return removeNode(hash(key), key, null, false, true) != null;
}
// 其他方法(如 clear、forEach)均直接操作 HashMap 的底层数据
}
使用场景
- 遍历所有键:当需要获取
HashMap
中所有键时,调用map.keySet()
。for (String key : map.keySet()) { System.out.println(key); }
- 检查键是否存在:通过
keySet.contains(key)
快速判断。 - 批量删除键:通过
keySet.removeAll(collection)
删除多个键。
注意事项
- 性能开销:
keySet()
的遍历操作时间复杂度为 O(n),需遍历所有桶。 - 并发修改异常:在迭代过程中直接通过
HashMap
修改数据(如put
/remove
)会抛出ConcurrentModificationException
。Set<String> keys = map.keySet(); for (String key : keys) { map.remove(key); // 错误!会抛出异常 }
- 视图的实时性:
KeySet
会实时反映HashMap
的变化,但多次调用keySet()
可能返回同一实例。Set<String> set1 = map.keySet(); Set<String> set2 = map.keySet(); System.out.println(set1 == set2); // 输出 true
示例代码
HashMap<String, Integer> map = new HashMap<>();
map.put("A", 1);
map.put("B", 2);
// 获取 KeySet 并遍历
Set<String> keys = map.keySet();
keys.forEach(System.out::println); // 输出 A, B(顺序不确定)
// 通过 KeySet 删除键
keys.remove("A");
System.out.println(map.size()); // 输出 1
Values 的实现
在 Java 的 HashMap
中,Values
是一个内部类,用于表示 HashMap
中所有值的集合。它继承自 AbstractCollection
并实现了 Collection
接口。Values
类的主要作用是提供对 HashMap
中所有值的视图,允许对这些值进行遍历、删除等操作。
核心特性
- 动态更新:
Values
是HashMap
的一个视图,会随着HashMap
的变化而动态更新。 - 不支持添加操作:由于
Values
只是HashMap
的视图,因此不支持直接添加元素(调用add()
方法会抛出UnsupportedOperationException
)。 - 高效的遍历:
Values
的迭代器直接基于HashMap
的内部结构实现,遍历效率较高。
内部实现
Values
类是 HashMap
的一个内部类,通常通过 HashMap
的 values()
方法获取其实例。以下是 Values
类的简化实现:
final class Values extends AbstractCollection<V> {
public final int size() { return size; } // 返回HashMap的大小
public final void clear() { HashMap.this.clear(); } // 清空HashMap
public final Iterator<V> iterator() { return new ValueIterator(); } // 返回值的迭代器
public final boolean contains(Object o) { return containsValue(o); } // 检查是否包含某个值
// 其他方法...
}
使用场景
Values
主要用于需要操作 HashMap
中所有值的场景,例如:
-
遍历所有值:
HashMap<String, Integer> map = new HashMap<>(); map.put("A", 1); map.put("B", 2); for (Integer value : map.values()) { System.out.println(value); // 输出:1, 2 }
-
批量操作:
Collection<Integer> values = map.values(); values.removeIf(value -> value > 1); // 删除值大于1的条目
-
检查值是否存在:
boolean containsTwo = map.values().contains(2); // 检查是否包含值2
注意事项
-
不支持直接添加:
Values
是只读视图,调用add()
方法会抛出异常。map.values().add(3); // 抛出 UnsupportedOperationException
-
迭代器修改限制:通过
Values
的迭代器删除元素是安全的,但如果在迭代过程中直接修改HashMap
(非通过迭代器),可能会抛出ConcurrentModificationException
。 -
性能开销:
Values
的contains()
方法需要遍历整个HashMap
,时间复杂度为 O(n),性能较差,应尽量避免频繁调用。
示例代码
以下是一个完整的 Values
使用示例:
import java.util.HashMap;
import java.util.Collection;
public class HashMapValuesExample {
public static void main(String[] args) {
HashMap<String, Integer> map = new HashMap<>();
map.put("Apple", 10);
map.put("Banana", 20);
map.put("Cherry", 30);
// 获取Values视图
Collection<Integer> values = map.values();
// 遍历值
System.out.println("Values:");
for (Integer value : values) {
System.out.println(value);
}
// 删除满足条件的值
values.removeIf(value -> value > 15);
System.out.println("After removal: " + map); // 输出:{Apple=10}
}
}
EntrySet 的实现
概念定义
EntrySet 是 HashMap 中的一个内部类,用于表示 HashMap 中所有键值对的集合。它是 Map.Entry<K,V>
的集合视图,允许用户遍历、删除或修改 HashMap 中的键值对。EntrySet 实现了 Set<Map.Entry<K,V>>
接口,并提供了一系列操作 HashMap 底层数据的方法。
核心实现
EntrySet 的实现依赖于 HashMap 的底层数据结构(通常是数组 + 链表/红黑树)。以下是关键实现点:
-
迭代器实现:
public final Iterator<Map.Entry<K,V>> iterator() { return new EntryIterator(); }
EntryIterator 是 HashMap 的内部类,继承自
HashIterator
,提供了next()
方法返回Map.Entry
对象。 -
包含检查:
public final boolean contains(Object o) { if (!(o instanceof Map.Entry)) return false; Map.Entry<?,?> e = (Map.Entry<?,?>) o; Object key = e.getKey(); Node<K,V> candidate = getNode(key); return candidate != null && candidate.equals(e); }
-
删除操作:
public final boolean remove(Object o) { if (o instanceof Map.Entry) { Map.Entry<?,?> e = (Map.Entry<?,?>) o; Object key = e.getKey(); Object value = e.getValue(); return removeNode(hash(key), key, value, true, true) != null; } return false; }
性能特点
- 遍历效率:EntrySet 的遍历时间复杂度为 O(n),n 为 HashMap 的大小。
- 快速失败机制:迭代器实现了快速失败(fail-fast)机制,如果在迭代过程中 HashMap 被修改(非通过迭代器自身的 remove 方法),会抛出
ConcurrentModificationException
。
使用示例
HashMap<String, Integer> map = new HashMap<>();
map.put("a", 1);
map.put("b", 2);
// 使用EntrySet遍历
for (Map.Entry<String, Integer> entry : map.entrySet()) {
System.out.println(entry.getKey() + ": " + entry.getValue());
}
// 使用迭代器删除元素
Iterator<Map.Entry<String, Integer>> it = map.entrySet().iterator();
while (it.hasNext()) {
Map.Entry<String, Integer> entry = it.next();
if (entry.getKey().equals("a")) {
it.remove(); // 安全删除
}
}
实现细节
- 懒加载:EntrySet 实例在第一次调用
entrySet()
时创建,之后重复调用返回同一个实例。 - 视图特性:对 EntrySet 的修改会直接反映到底层 HashMap,反之亦然。
- 内存优化:EntrySet 本身不存储数据,只是提供访问底层数据的视图。
注意事项
- 遍历过程中修改 HashMap 可能导致
ConcurrentModificationException
。 - EntrySet 的
size()
、isEmpty()
等方法直接调用 HashMap 的对应方法。 - 在 JDK 8 之后,EntrySet 支持并行遍历(通过
spliterator()
实现)。
快速失败机制(Fail-Fast)
概念定义
快速失败机制是Java集合框架中的一种错误检测机制,当多个线程同时对集合进行结构性修改(如添加、删除元素)时,会立即抛出ConcurrentModificationException
异常,防止数据不一致问题。其核心是通过modCount
(修改计数器)实现。
实现原理
modCount
字段
集合类(如HashMap
)内部维护一个modCount
变量,记录结构性修改的次数(如put
、remove
操作)。- 迭代器检查
创建迭代器时,会记录当前的modCount
为expectedModCount
。每次迭代时(如调用next()
),检查两者是否一致:if (modCount != expectedModCount) throw new ConcurrentModificationException();
典型场景
- 单线程环境
在遍历集合时直接调用remove()
方法(未通过迭代器):
正确做法应使用迭代器的Map<String, Integer> map = new HashMap<>(); map.put("a", 1); for (String key : map.keySet()) { map.remove(key); // 抛出ConcurrentModificationException }
remove()
方法:Iterator<String> it = map.keySet().iterator(); while (it.hasNext()) { it.next(); it.remove(); // 安全删除 }
- 多线程环境
一个线程遍历集合,另一线程修改集合时必然触发快速失败。
注意事项
- 非原子性操作
即使单线程中复合操作(如先containsKey()
再put()
)也可能触发异常:if (!map.containsKey("b")) { map.put("b", 2); // 可能抛出异常(若其他线程在此间修改) }
- 替代方案
需线程安全时,应使用ConcurrentHashMap
或Collections.synchronizedMap()
。
快速失败 vs 安全失败(Fail-Safe)
特性 | 快速失败(Fail-Fast) | 安全失败(Fail-Safe) |
---|---|---|
集合类型 | HashMap , ArrayList 等 | ConcurrentHashMap , CopyOnWriteArrayList |
原理 | 直接抛出异常 | 遍历集合的副本 |
性能影响 | 无额外开销 | 需要复制数据,内存占用更高 |
七、HashMap 性能优化
初始容量设置建议
概念定义
初始容量(Initial Capacity)指的是在创建 HashMap
时,底层数组(哈希桶)的初始大小。默认情况下,HashMap
的初始容量为 16(JDK 1.8 及以后版本)。通过构造函数可以手动指定初始容量,例如:
HashMap<String, Integer> map = new HashMap<>(32); // 初始容量设为32
为什么需要设置初始容量?
- 减少扩容开销:
HashMap
在元素数量超过容量 * 负载因子(默认0.75)
时会触发扩容(rehashing),扩容需要重建哈希表,性能开销较大。 - 避免频繁扩容:如果预先知道存储的元素数量,合理设置初始容量可以避免多次扩容。
计算建议
-
公式:
初始容量 = 预期元素数量 / 负载因子 + 1
例如:预期存储 100 个元素,负载因子为默认值 0.75:
100 / 0.75 + 1 ≈ 134
,向上取最近的 2 的幂次方(HashMap
会自动调整),最终初始容量为 256。 -
2的幂次方规则:
HashMap
会强制将初始容量调整为大于等于指定值的最小 2 的幂次方。例如:- 指定
10
→ 实际容量16
- 指定
33
→ 实际容量64
- 指定
示例代码
// 预期存储100个元素,设置初始容量为134(实际会调整为256)
HashMap<String, Integer> map = new HashMap<>((int) (100 / 0.75 + 1));
注意事项
- 不要过度分配:
初始容量过大会浪费内存,尤其是短期使用的小规模HashMap
。 - 负载因子的影响:
如果负载因子调低(如0.5),初始容量需要更大才能避免扩容,但会减少哈希冲突。 - 并发场景:
HashMap
不是线程安全的,在多线程环境中应考虑ConcurrentHashMap
,其初始容量设置逻辑类似。
常见误区
- 直接使用元素数量作为初始容量:
错误示例:new HashMap<>(100)
实际会导致在插入第75
(100*0.75)个元素时就触发扩容。 - 忽略负载因子:
如果自定义负载因子(如new HashMap<>(16, 0.5f)
),初始容量需重新计算。
负载因子选择策略
概念定义
负载因子(Load Factor)是 HashMap 中的一个重要参数,用于衡量哈希表的填充程度。其计算公式为:
负载因子 = 元素数量 / 哈希表容量
默认情况下,Java 中 HashMap 的负载因子为 0.75。当 HashMap 中的元素数量超过 容量 × 负载因子
时,哈希表会进行扩容(通常扩容为原来的两倍),并重新哈希所有元素。
负载因子的作用
-
平衡空间和时间效率:
- 较高的负载因子(如 0.9)可以减少内存占用,但会增加哈希冲突的概率,导致查找、插入性能下降。
- 较低的负载因子(如 0.5)可以减少哈希冲突,提高查询效率,但会占用更多内存。
-
控制扩容时机:
- 负载因子决定了 HashMap 何时触发扩容,从而影响哈希表的性能表现。
常见负载因子选择
-
默认值 0.75(推荐)
Java 的 HashMap 默认使用 0.75,这是一个在时间和空间效率之间取得平衡的经验值。研究表明,在大多数情况下,0.75 能提供较好的性能。 -
高负载因子(如 0.9)
- 适用场景:内存紧张,且对查询性能要求不高的情况。
- 缺点:哈希冲突增多,链表或红黑树的查询成本增加。
-
低负载因子(如 0.5)
- 适用场景:对查询性能要求极高,且内存充足的情况。
- 缺点:内存占用较大,扩容频率可能增加。
示例代码:自定义负载因子
import java.util.HashMap;
public class HashMapLoadFactorExample {
public static void main(String[] args) {
// 创建一个初始容量为16,负载因子为0.5的HashMap
HashMap<String, Integer> map = new HashMap<>(16, 0.5f);
// 添加元素
map.put("A", 1);
map.put("B", 2);
map.put("C", 3);
System.out.println("Map size: " + map.size());
}
}
注意事项
-
避免极端值:
- 负载因子不宜过高(如 > 0.9),否则哈希冲突严重,退化成链表或红黑树查询。
- 负载因子不宜过低(如 < 0.5),否则内存浪费严重。
-
扩容成本:
- 扩容会触发
rehash
,这是一个 O(n) 操作,在高并发场景下可能影响性能。
- 扩容会触发
-
特殊场景调整:
- 如果数据量固定且已知,可以设置合适的初始容量和负载因子,避免频繁扩容。
- 例如:预计存放 1000 个元素,负载因子 0.75,则初始容量应设为
1000 / 0.75 ≈ 1333
,取最近的 2 的幂(2048)。
总结
负载因子的选择需要根据具体应用场景权衡:
- 默认 0.75 适用于大多数情况。
- 内存敏感型应用 可适当提高负载因子。
- 查询密集型应用 可适当降低负载因子。
扩容的性能影响
HashMap 的扩容是指当哈希表中的元素数量超过当前容量与负载因子的乘积时,HashMap 会创建一个新的、更大的数组,并将所有现有元素重新计算哈希值并放入新数组中。扩容是 HashMap 性能优化的重要机制,但也会带来一定的性能开销。
扩容的触发条件
HashMap 扩容的触发条件是:
if (size > threshold) {
resize();
}
其中:
size
是当前 HashMap 中的键值对数量。threshold
是扩容阈值,等于capacity * loadFactor
(默认负载因子为 0.75)。
扩容的过程
- 创建新数组:新数组的容量通常是原数组的两倍(即
newCap = oldCap << 1
)。 - 重新哈希:遍历原数组中的每个桶(bucket),对每个键值对重新计算哈希值,并分配到新数组的对应位置。
- 链表/红黑树拆分:如果桶中是链表或红黑树,会根据新容量重新拆分节点。
性能影响
-
时间复杂度:
- 扩容的时间复杂度为 O(n),其中 n 是 HashMap 中的元素数量。
- 每次插入操作的平均时间复杂度为 O(1),但在扩容时会退化为 O(n)。
-
空间开销:
- 扩容会占用额外的内存空间(新数组的大小是原数组的两倍)。
- 如果频繁扩容,可能导致内存浪费。
-
CPU 开销:
- 重新计算哈希值和重新分配节点会消耗 CPU 资源。
- 在高并发场景下,扩容可能导致短暂的性能下降。
优化建议
-
预分配容量:
如果能够预估 HashMap 的最终大小,可以在初始化时指定容量,避免多次扩容。Map<String, String> map = new HashMap<>(1000); // 预分配容量为 1000
-
调整负载因子:
如果对空间敏感,可以适当增大负载因子(如 0.8),减少扩容次数;如果对查询性能敏感,可以减小负载因子(如 0.6)。Map<String, String> map = new HashMap<>(16, 0.6f); // 负载因子设为 0.6
-
避免频繁插入删除:
频繁的插入和删除操作可能导致 HashMap 反复扩容和缩容,影响性能。
示例代码
以下是一个展示扩容性能影响的简单示例:
public class HashMapResizeDemo {
public static void main(String[] args) {
// 不预分配容量,频繁扩容
Map<Integer, String> map1 = new HashMap<>();
long start1 = System.currentTimeMillis();
for (int i = 0; i < 1000000; i++) {
map1.put(i, "value" + i);
}
long end1 = System.currentTimeMillis();
System.out.println("未预分配容量耗时:" + (end1 - start1) + "ms");
// 预分配容量,减少扩容
Map<Integer, String> map2 = new HashMap<>(1000000);
long start2 = System.currentTimeMillis();
for (int i = 0; i < 1000000; i++) {
map2.put(i, "value" + i);
}
long end2 = System.currentTimeMillis();
System.out.println("预分配容量耗时:" + (end2 - start2) + "ms");
}
}
运行结果通常会显示预分配容量的 HashMap 性能更优。
树化优化的意义
概念定义
树化优化(Treeify)是指当 HashMap 中的链表长度超过一定阈值(默认为8)时,将链表转换为红黑树(Red-Black Tree)的过程。这一优化在 JDK 8 中被引入,目的是解决哈希冲突严重时链表查询效率低下的问题。
使用场景
- 哈希冲突严重时:当多个键的哈希值映射到同一个桶(Bucket)时,链表会变得很长,导致查询效率从 O(1) 退化为 O(n)。
- 高并发或大数据量场景:在数据量较大或哈希函数分布不均匀的情况下,树化能显著提升查询性能。
为什么需要树化?
- 链表查询效率低:链表的查询时间复杂度为 O(n),当链表过长时,性能会急剧下降。
- 红黑树的优势:红黑树是一种自平衡二叉搜索树,查询、插入和删除的时间复杂度均为 O(log n),在数据量大时性能更优。
树化的触发条件
- 当链表的长度 ≥ 8 时,且 HashMap 的容量 ≥ 64,链表会转换为红黑树。
- 如果容量 < 64,HashMap 会优先尝试扩容(resize),而不是直接树化。
树化的逆操作:退化
当红黑树的节点数 ≤ 6 时,红黑树会退化为链表。这是因为在小数据量下,链表的实际性能可能优于红黑树(红黑树的平衡操作有一定开销)。
示例代码(树化逻辑)
以下是简化版的树化逻辑(摘自 JDK 源码):
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
// 如果表为空或容量不足,优先扩容
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
// 链表长度 ≥ 8 且容量 ≥ 64 时,转换为红黑树
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null;
do {
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null)
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
注意事项
- 哈希函数的重要性:如果哈希函数设计不合理,可能导致大量键集中在少数桶中,触发频繁树化,影响性能。
- 内存开销:红黑树的节点(
TreeNode
)比链表节点(Node
)占用更多内存,需权衡空间与时间效率。 - 并发问题:HashMap 本身是非线程安全的,树化过程在高并发环境下可能引发问题(应使用
ConcurrentHashMap
)。
总结
树化优化是 HashMap 在极端哈希冲突情况下的一种性能保障机制,通过将链表转换为红黑树,将查询时间复杂度从 O(n) 优化为 O(log n),显著提升了数据结构的稳定性。
八、HashMap 线程安全性
多线程环境下的问题
概念定义
在 Java 中,HashMap
是一个非线程安全的集合类,这意味着在多线程环境下,多个线程同时操作同一个 HashMap
实例可能会导致数据不一致、死循环或其他不可预期的行为。HashMap
的设计初衷是为了在单线程环境下提供高效的键值对存储和查询,因此在并发场景下使用时需要特别注意。
常见问题
- 数据丢失:当多个线程同时执行
put
操作时,可能会导致某些键值对被覆盖,从而丢失数据。 - 死循环:在
HashMap
扩容时(即resize
操作),多个线程同时操作可能会导致链表形成环形结构,进而引发死循环(主要发生在 JDK 1.7 及之前版本)。 - 脏读:一个线程在读取
HashMap
时,另一个线程可能正在修改它,导致读取到不一致的数据。
问题根源
HashMap
的非线程安全性主要源于以下设计:
- 无同步机制:
HashMap
的方法没有使用synchronized
或其他同步机制来保证线程安全。 - 结构修改:在扩容或链表转红黑树时,
HashMap
的内部结构会发生变化,多线程并发操作可能导致结构破坏。
示例代码(问题复现)
以下代码展示了多线程环境下 HashMap
可能导致的问题:
import java.util.HashMap;
import java.util.Map;
public class HashMapConcurrencyIssue {
public static void main(String[] args) throws InterruptedException {
Map<Integer, Integer> map = new HashMap<>();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
map.put(i, i);
}
});
Thread t2 = new Thread(() -> {
for (int i = 1000; i < 2000; i++) {
map.put(i, i);
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Map size: " + map.size()); // 可能小于 2000
}
}
运行结果可能显示 map.size()
小于 2000,说明部分数据丢失。
解决方案
- 使用
ConcurrentHashMap
:ConcurrentHashMap
是线程安全的HashMap
替代方案,通过分段锁(JDK 1.7)或 CAS +synchronized
(JDK 1.8+)实现高效并发。Map<Integer, Integer> safeMap = new ConcurrentHashMap<>();
- 使用
Collections.synchronizedMap
:通过包装HashMap
使其线程安全,但性能较差(全局锁)。Map<Integer, Integer> safeMap = Collections.synchronizedMap(new HashMap<>());
- 手动同步:在操作
HashMap
时使用synchronized
块,但需注意锁的粒度。synchronized (map) { map.put(key, value); }
注意事项
- 避免在迭代时修改:即使在单线程中,
HashMap
在迭代时修改也会抛出ConcurrentModificationException
。 - 性能权衡:
ConcurrentHashMap
在并发场景下性能优于Collections.synchronizedMap
,但单线程性能略低于HashMap
。 - JDK 版本差异:JDK 1.8 优化了
HashMap
和ConcurrentHashMap
的实现,减少了死循环问题,但仍需注意线程安全。
适用场景
- 单线程或只读场景:优先使用
HashMap
。 - 高并发读写场景:优先使用
ConcurrentHashMap
。 - 低并发或兼容旧代码:可考虑
Collections.synchronizedMap
。
ConcurrentHashMap 对比
1. 概念定义
ConcurrentHashMap 是 Java 并发包(java.util.concurrent
)中的一个线程安全的哈希表实现,用于在多线程环境下高效地存储和操作键值对。与传统的 Hashtable
或 Collections.synchronizedMap
不同,ConcurrentHashMap 通过分段锁(Segment)或 CAS(Compare-And-Swap)机制实现高并发性能。
2. 与 HashMap 的对比
特性 | HashMap | ConcurrentHashMap |
---|---|---|
线程安全 | 非线程安全 | 线程安全 |
锁机制 | 无锁(非线程安全) | 分段锁(JDK 1.7)或 CAS + synchronized(JDK 1.8) |
性能 | 单线程性能最高 | 高并发场景下性能优于同步的 HashMap |
Null 键/值 | 允许 | 不允许 |
迭代器 | 快速失败(Fail-Fast) | 弱一致性(Weakly Consistent) |
3. 与 Hashtable 的对比
特性 | Hashtable | ConcurrentHashMap |
---|---|---|
线程安全 | 线程安全(全表锁) | 线程安全(分段锁或 CAS) |
性能 | 低(锁竞争严重) | 高(锁粒度更细) |
Null 键/值 | 不允许 | 不允许 |
迭代器 | 快速失败(Fail-Fast) | 弱一致性(Weakly Consistent) |
4. 与 Collections.synchronizedMap 的对比
特性 | Collections.synchronizedMap | ConcurrentHashMap |
---|---|---|
线程安全 | 线程安全(全表锁) | 线程安全(分段锁或 CAS) |
性能 | 低(锁竞争严重) | 高(锁粒度更细) |
实现方式 | 包装类(装饰器模式) | 原生实现 |
迭代器 | 快速失败(Fail-Fast) | 弱一致性(Weakly Consistent) |
5. 使用场景
- 单线程环境:优先使用
HashMap
,性能最高。 - 低并发多线程环境:可以使用
Hashtable
或Collections.synchronizedMap
,但性能较差。 - 高并发多线程环境:优先使用
ConcurrentHashMap
,性能最优。
6. 常见误区与注意事项
- Null 值问题:
ConcurrentHashMap
不允许null
键或值,而HashMap
允许。 - 弱一致性迭代器:
ConcurrentHashMap
的迭代器是弱一致性的,可能无法反映最新的修改。 - JDK 版本差异:
- JDK 1.7 使用分段锁(Segment)。
- JDK 1.8 改为 CAS +
synchronized
优化锁粒度(锁住单个桶)。
- 复合操作非原子性:
ConcurrentHashMap
的单个操作是线程安全的,但复合操作(如putIfAbsent
+get
)仍需额外同步。
7. 示例代码
import java.util.concurrent.ConcurrentHashMap;
public class ConcurrentHashMapExample {
public static void main(String[] args) {
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
// 线程安全的 put 操作
map.put("A", 1);
map.put("B", 2);
// 原子性操作:仅当键不存在时插入
map.putIfAbsent("A", 100); // 不会覆盖原有值
// 多线程并发操作
Runnable task = () -> {
for (int i = 0; i < 1000; i++) {
map.compute("Counter", (k, v) -> (v == null) ? 1 : v + 1);
}
};
Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Final Counter: " + map.get("Counter")); // 输出 2000
}
}
Collections.synchronizedMap 包装
概念定义
Collections.synchronizedMap
是 Java 集合框架提供的一个静态方法,用于将一个普通的 Map
包装成线程安全的 Map
。它返回一个同步(线程安全)的 Map
视图,所有对该 Map
的操作都会被同步,从而保证在多线程环境下的安全性。
实现原理
Collections.synchronizedMap
通过在每个方法上添加 synchronized
关键字来实现线程安全。具体来说,它会将传入的 Map
包装在一个内部类中,并通过一个全局锁(通常是 this
)来同步所有方法调用。
使用场景
- 多线程环境:当需要在多线程环境下共享一个
Map
时,可以使用Collections.synchronizedMap
来保证线程安全。 - 兼容性需求:某些遗留代码或第三方库可能要求传入一个线程安全的
Map
,此时可以使用Collections.synchronizedMap
进行包装。
示例代码
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
public class SynchronizedMapExample {
public static void main(String[] args) {
// 创建一个普通的 HashMap
Map<String, Integer> map = new HashMap<>();
// 包装成线程安全的 Map
Map<String, Integer> synchronizedMap = Collections.synchronizedMap(map);
// 多线程操作
Thread thread1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
synchronizedMap.put("key" + i, i);
}
});
Thread thread2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
synchronizedMap.put("key" + i, i);
}
});
thread1.start();
thread2.start();
try {
thread1.join();
thread2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Map size: " + synchronizedMap.size());
}
}
注意事项
-
复合操作非原子性:虽然单个操作(如
put
、get
)是线程安全的,但复合操作(如先检查后更新)仍然需要额外的同步。例如:if (!synchronizedMap.containsKey("key")) { synchronizedMap.put("key", "value"); // 仍然可能发生竞态条件 }
正确的做法是在外部使用同步块:
synchronized (synchronizedMap) { if (!synchronizedMap.containsKey("key")) { synchronizedMap.put("key", "value"); } }
-
性能开销:由于所有操作都需要获取锁,
Collections.synchronizedMap
在高并发场景下性能较差。可以考虑使用ConcurrentHashMap
替代。 -
迭代器非线程安全:即使使用了
Collections.synchronizedMap
,其迭代器仍然是非线程安全的。在迭代时需要手动同步:synchronized (synchronizedMap) { for (Map.Entry<String, Integer> entry : synchronizedMap.entrySet()) { // 处理 entry } }
与 ConcurrentHashMap 的比较
特性 | Collections.synchronizedMap | ConcurrentHashMap |
---|---|---|
锁粒度 | 全局锁 | 分段锁或 CAS |
性能 | 较低 | 较高 |
复合操作支持 | 需要外部同步 | 内置原子操作支持 |
迭代器线程安全性 | 需要外部同步 | 弱一致性迭代器 |
总结
Collections.synchronizedMap
是一种简单直接的线程安全 Map
实现方式,适用于低并发或需要兼容旧代码的场景。但在高并发环境下,ConcurrentHashMap
通常是更好的选择。
九、HashMap 常见问题
为什么使用红黑树
概念定义
红黑树(Red-Black Tree)是一种自平衡的二叉查找树(Binary Search Tree, BST)。它通过特定的规则(颜色标记和旋转操作)确保树的高度始终保持在较低水平,从而保证查找、插入和删除操作的时间复杂度为 O(log n)。
使用场景
在 Java 的 HashMap
中,当哈希冲突较多时,链表会转换为红黑树,以提高查询效率。具体来说:
- 链表过长问题:当哈希桶中的链表长度超过阈值(默认为 8)时,链表会转换为红黑树。
- 性能优化:链表的时间复杂度为 O(n),而红黑树的时间复杂度为 O(log n),可以显著提升查询性能。
常见误区或注意事项
- 红黑树并非总是最优:红黑树的维护成本较高(如旋转、颜色调整),因此在数据量较小时(如链表长度小于 6 时),
HashMap
会从红黑树退化为链表。 - 哈希函数的重要性:如果哈希函数设计不合理,导致大量键集中在少数桶中,即使使用红黑树,性能也会下降。
- 内存占用:红黑树比链表占用更多内存,因此在空间敏感的场景中需权衡。
示例代码
以下是 HashMap
中链表转红黑树的逻辑(简化版):
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize(); // 如果表太小,优先扩容
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null;
do {
// 将链表节点转换为树节点
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null)
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
if ((tab[index] = hd) != null)
hd.treeify(tab); // 构建红黑树
}
}
红黑树的优势
- 平衡性:红黑树通过颜色和旋转规则确保树的高度平衡,避免退化为链表。
- 稳定性能:无论数据分布如何,红黑树都能保证 O(log n) 的操作时间。
- 适合动态数据:频繁插入和删除时,红黑树比 AVL 树等更高效(减少旋转次数)。
为什么 HashMap 的容量是 2 的幂次
1. 概念定义
HashMap 的容量指的是其内部数组(Node<K,V>[] table
)的长度。在 Java 的 HashMap 实现中,容量被强制为 2 的幂次方(如 16, 32, 64 等),这是通过 tableSizeFor()
方法实现的,确保任何初始容量都会被调整为最近的 2 的幂次方。
2. 核心原因:高效计算索引
HashMap 通过哈希函数计算键的哈希值后,需要将哈希值映射到数组的某个索引位置。计算公式为:
index = hash(key) & (capacity - 1)
当容量为 2 的幂次时,capacity - 1
的二进制形式是全 1(例如,容量为 16 时,15
的二进制是 1111
)。此时,&
操作等价于取模运算(hash % capacity
),但位运算的效率远高于取模运算。
3. 优势详解
-
位运算替代取模
- 取模运算(
%
)在计算机中需要多次除法操作,性能较差。 - 位运算(
&
)是单周期指令,效率极高。
- 取模运算(
-
哈希分布均匀性
- 当
capacity - 1
是全 1 时,哈希值的高低位都能参与索引计算,减少冲突。 - 如果容量不是 2 的幂次(比如 15),
capacity - 1
的二进制是1110
,会导致最低位始终为 0,部分索引永远无法被映射(如奇数和偶数分布不均)。
- 当
-
扩容优化
- 扩容时(变为原来的 2 倍),元素的新索引要么在原位置,要么在
原位置 + 原容量
。这是通过hash & oldCapacity
判断的,依赖容量的 2 的幂次特性。
- 扩容时(变为原来的 2 倍),元素的新索引要么在原位置,要么在
4. 示例验证
假设容量为 16(00010000
),capacity - 1 = 15
(00001111
):
hash(key) = 10101010 10101010 10101010 10101010 (示例哈希值)
index = 10101010 10101010 10101010 10101010 & 00000000 00000000 00000000 00001111
= 00000000 00000000 00000000 00001010 (十进制 10)
若容量为 15(00001111
),capacity - 1 = 14
(00001110
):
index = 10101010 10101010 10101010 10101010 & 00000000 00000000 00000000 00001110
= 00000000 00000000 00000000 00001010 (仍然是 10,但最低位被强制为 0)
此时,所有奇数的索引(如 1, 3, 5…)都无法被映射。
5. 注意事项
- 手动指定初始容量时:HashMap 会通过
tableSizeFor()
调整为 2 的幂次。例如,指定容量为 17,实际会分配 32。 - 哈希冲突的补充:即使容量为 2 的幂次,仍需良好的哈希函数(如 HashMap 中的
hash()
方法扰动高低位)来进一步减少冲突。
为什么重写 equals 必须重写 hashCode
概念定义
在 Java 中,equals
和 hashCode
是两个重要的方法,它们通常一起被重写。equals
方法用于比较两个对象是否相等,而 hashCode
方法返回对象的哈希码值,主要用于哈希表(如 HashMap
、HashSet
等)的高效存储和查找。
哈希表的工作原理
哈希表(如 HashMap
)通过哈希码快速定位对象。当向哈希表中插入或查找对象时,会先调用 hashCode
方法计算哈希值,再根据哈希值确定对象的存储位置。如果两个对象通过 equals
方法比较相等,那么它们的 hashCode
必须相同,否则会导致哈希表的逻辑错误。
重写 equals 但不重写 hashCode 的问题
如果只重写 equals
而不重写 hashCode
,可能会导致以下问题:
- 哈希表无法正确工作:例如,两个对象通过
equals
比较相等,但hashCode
不同,它们会被存储到哈希表的不同位置,导致无法正确查找或去重。 - 违反
hashCode
契约:Java 规定,如果两个对象通过equals
方法比较相等,那么它们的hashCode
必须相同。如果不满足这一条件,哈希表的行为将不可预测。
示例代码
以下是一个典型的错误示例:
class Person {
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null || getClass() != obj.getClass()) return false;
Person person = (Person) obj;
return age == person.age && Objects.equals(name, person.name);
}
// 未重写 hashCode
}
public class Main {
public static void main(String[] args) {
Person p1 = new Person("Alice", 25);
Person p2 = new Person("Alice", 25);
System.out.println(p1.equals(p2)); // true
System.out.println(p1.hashCode() == p2.hashCode()); // false(可能不同)
Map<Person, String> map = new HashMap<>();
map.put(p1, "Alice");
System.out.println(map.get(p2)); // 可能返回 null
}
}
由于 hashCode
未被重写,p1
和 p2
的哈希值可能不同,导致 map.get(p2)
无法正确获取值。
正确的做法
重写 equals
时必须同时重写 hashCode
,确保相等的对象具有相同的哈希值。例如:
@Override
public int hashCode() {
return Objects.hash(name, age);
}
这样,p1
和 p2
的哈希值将相同,哈希表也能正确工作。
注意事项
- 哈希码的计算应尽量均匀分布,以减少哈希冲突。
- 哈希码的计算应基于
equals
方法中使用的字段,确保逻辑一致。 - 哈希码可以缓存:如果对象是不可变的,可以缓存哈希码以提高性能。
HashMap 1.7 和 1.8 版本的区别
数据结构差异
- JDK 1.7:使用 数组 + 链表 结构,链表采用头插法
- JDK 1.8:使用 数组 + 链表/红黑树 结构,链表采用尾插法,当链表长度超过阈值(默认8)时转换为红黑树
哈希冲突处理
- JDK 1.7:单纯使用链表解决哈希冲突
- JDK 1.8:先使用链表,超过阈值后转为红黑树,查找时间复杂度从O(n)降到O(log n)
扩容机制
- JDK 1.7:
- 扩容时需要重新计算每个元素的哈希值和索引位置
- 多线程环境下可能产生死循环(头插法导致)
- JDK 1.8:
- 优化了扩容机制,元素位置要么在原位置,要么在原位置+旧容量
- 使用尾插法避免了死循环问题
性能表现
- JDK 1.7:链表过长时查找效率低
- JDK 1.8:引入红黑树大幅提升了哈希冲突严重时的查询效率
关键方法实现
// JDK 1.7的put方法实现片段
void addEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];
table[bucketIndex] = new Entry<>(hash, key, value, e); // 头插法
if (size++ >= threshold)
resize(2 * table.length);
}
// JDK 1.8的put方法实现片段
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
// ...省略部分代码...
if (binCount >= TREEIFY_THRESHOLD - 1) // 判断是否需要树化
treeifyBin(tab, hash);
// ...省略部分代码...
}
并发安全性
- JDK 1.7:多线程put可能导致死循环和数据丢失
- JDK 1.8:虽然解决了死循环问题,但仍然是线程不安全的
其他改进
- JDK 1.8:
- 新增了compute(), merge()等新方法
- 优化了hash()计算方法
- 引入了TreeNode节点类用于红黑树实现