集合-HashSet&HashMap 源码
大约 9 分钟
集合-HashSet&HashMap 源码
一、核心理论
1.1 HashMap与HashSet类结构分析
HashMap是基于哈希表实现的键值对存储结构,而HashSet则是基于HashMap实现的集合,用于存储不重复元素。两者都允许null值,但不保证元素顺序,且都是非线程安全的。
1.2 核心成员变量
HashMap核心变量
table[]: 存储键值对的哈希表数组,每个元素是链表或红黑树的头节点size: 键值对数量threshold: 扩容阈值,等于容量×负载因子loadFactor: 负载因子,默认为0.75fmodCount: 结构修改计数器,用于快速失败机制
HashSet核心变量
map: 内部使用的HashMap实例PRESENT: 静态常量,作为HashMap中的value占位符
1.3 JDK版本特性差异
HashMap版本演进
- JDK 1.2: 引入HashMap,基于数组+链表实现
- JDK 1.4: 引入LinkedHashMap
- JDK 1.7: 优化哈希算法,引入"扰动函数"增强哈希分布
- JDK 1.8: 重大优化,引入红黑树结构,当链表长度超过8时转为红黑树
- JDK 1.8: 优化resize过程,避免多线程下的死循环问题
- JDK 1.9: 引入of()静态工厂方法创建不可变Map
- JDK 16: 引入record类作为键时的优化
- JDK 21: 未引入重大变更,但持续优化哈希冲突处理
二、代码实践
2.1 HashMap核心方法实现
2.1.1 哈希计算与索引定位
/**
* 计算键的哈希值
* @param key 键对象
* @return 计算后的哈希值
*/
static final int hash(Object key) {
int h;
// 扰动函数:将高16位与低16位异或,减少哈希冲突
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
/**
* 根据哈希值和数组长度计算索引
* @param hash 哈希值
* @param length 数组长度
* @return 数组索引
*/
static int indexFor(int hash, int length) {
return hash & (length - 1); // 等价于hash % length,但效率更高
}2.1.2 put方法实现
/**
* 将键值对放入映射
* @param key 键
* @param value 值
* @return 之前与key关联的值,或null
*/
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
/**
* put操作的核心实现
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 初始化哈希表
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);
// 链表长度超过8,转为红黑树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// 找到相同的键,退出循环
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 替换旧值
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
// 检查是否需要扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}2.1.3 扩容机制(resize)
/**
* 调整哈希表大小
* @return 新的哈希表数组
*/
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;
}
// 容量翻倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // threshold也翻倍
}
else if (oldThr > 0) // 初始容量在构造函数中设置
newCap = oldThr;
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;
@SuppressWarnings({"rawtypes","unchecked"})
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;
Node<K,V> next;
do {
next = e.next;
// 原索引
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
// 原索引 + oldCap
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 放到新数组的原索引位置
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
// 放到新数组的原索引+oldCap位置
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}2.2 HashSet核心方法实现
/**
* 向集合中添加元素
* @param e 要添加的元素
* @return 如果元素不存在且添加成功则返回true
*/
public boolean add(E e) {
return map.put(e, PRESENT) == null;
}
/**
* 从集合中移除元素
* @param o 要移除的元素
* @return 如果元素存在且移除成功则返回true
*/
public boolean remove(Object o) {
return map.remove(o) == PRESENT;
}
/**
* 检查集合是否包含指定元素
* @param o 要检查的元素
* @return 如果包含则返回true
*/
public boolean contains(Object o) {
return map.containsKey(o);
}2.3 实际应用示例
2.3.1 HashMap性能优化示例
/**
* 创建高效的HashMap
* 初始容量设置为预期元素数量 / 负载因子 + 1
* 避免频繁扩容
*/
public class OptimizedHashMapExample {
public static void main(String[] args) {
// 预期存储1000个元素
int expectedSize = 1000;
// 计算初始容量: expectedSize / loadFactor + 1
int initialCapacity = (int) (expectedSize / 0.75f) + 1;
// 创建优化初始容量的HashMap
Map<String, Integer> optimizedMap = new HashMap<>(initialCapacity);
// 添加元素
for (int i = 0; i < expectedSize; i++) {
optimizedMap.put("key" + i, i);
}
}
}2.3.2 自定义对象作为HashMap键
/**
* 自定义对象作为HashMap键的正确实现
* 必须重写hashCode()和equals()方法
*/
class Person {
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
// 重写equals方法
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Person person = (Person) o;
return age == person.age && Objects.equals(name, person.name);
}
// 重写hashCode方法
@Override
public int hashCode() {
return Objects.hash(name, age);
}
}
// 使用示例
Map<Person, String> personMap = new HashMap<>();
personMap.put(new Person("Alice", 30), "Engineer");
// 正确获取值,因为重写了hashCode和equals
String occupation = personMap.get(new Person("Alice", 30));三、设计思想
3.1 哈希表数据结构
HashMap采用数组+链表+红黑树的复合数据结构:
- 数组(哈希表):存储链表或红黑树的头节点,通过哈希值快速定位
- 链表:解决哈希冲突,当冲突较少时使用
- 红黑树:当链表长度超过阈值(默认8)时转换,提高查询效率
3.2 哈希冲突解决策略
HashMap使用链地址法解决哈希冲突:
- 当两个不同的键计算出相同的索引时,将新节点添加到链表末尾
- 当链表长度超过阈值(默认8)且数组长度不小于64时,将链表转为红黑树
- 当红黑树节点数量少于阈值(默认6)时,将红黑树转回链表
3.3 动态扩容机制
HashMap的动态扩容机制是其高性能的关键:
- 初始容量:默认16,可通过构造函数指定
- 负载因子:默认0.75,平衡空间和时间效率
- 扩容阈值:容量×负载因子,达到该值时触发扩容
- 扩容操作:创建原容量2倍的新数组,并重新哈希所有节点
3.4 fail-fast机制
HashMap通过modCount实现快速失败机制:
- 当HashMap被修改(添加/删除元素)时,modCount自增
- 迭代器创建时记录expectedModCount
- 迭代过程中检查modCount是否等于expectedModCount,不等则抛出ConcurrentModificationException
四、避坑指南
4.1 键的哈希值不可变
作为HashMap键的对象必须保证哈希值不变,否则会导致无法正确获取值:
// 错误示例:使用可变对象作为键
class MutableKey {
private int value;
// 省略构造方法和getter
@Override
public int hashCode() {
return value;
}
}
Map<MutableKey, String> map = new HashMap<>();
MutableKey key = new MutableKey(1);
map.put(key, "value");
key.setValue(2); // 修改键的哈希值
String value = map.get(key); // null,无法获取值4.2 线程安全问题
HashMap是非线程安全的,多线程环境下可能导致:
- 数据不一致
- 扩容时的死循环(JDK 1.7及之前)
- ConcurrentModificationException异常
解决方案:
// 1. 使用Collections.synchronizedMap
Map<String, Integer> syncMap = Collections.synchronizedMap(new HashMap<>());
// 2. 使用ConcurrentHashMap (推荐)
Map<String, Integer> concurrentMap = new ConcurrentHashMap<>();4.3 初始容量选择
初始容量设置不当会导致频繁扩容,影响性能:
- 过小:导致频繁扩容
- 过大:浪费内存空间
正确做法:根据预期元素数量计算初始容量
int expectedSize = 1000;
int initialCapacity = (int) (expectedSize / 0.75f) + 1;
Map<String, Integer> map = new HashMap<>(initialCapacity);4.4 遍历方式选择
不同遍历方式性能差异较大:
// 1. 推荐:entrySet遍历键值对
for (Map.Entry<String, Integer> entry : map.entrySet()) {
String key = entry.getKey();
Integer value = entry.getValue();
}
// 2. 不推荐:keySet遍历后get获取值(多一次哈希查找)
for (String key : map.keySet()) {
Integer value = map.get(key);
}五、深度思考题
- HashMap在JDK 7和JDK 8中的实现有哪些主要区别?这些改进带来了什么好处?
- 为什么HashMap的容量必须是2的幂次方?如果传入非2的幂次方的初始容量,会发生什么?
- HashMap、Hashtable和ConcurrentHashMap的线程安全特性有何区别?各自的实现原理是什么?
- 如何实现一个自定义的HashMap,支持按照插入顺序或访问顺序遍历?
- HashMap中的红黑树与TreeMap中的红黑树有何异同?
思考题回答:
JDK 7和JDK 8中HashMap的主要区别:
- 数据结构: JDK 7使用数组+链表;JDK 8使用数组+链表+红黑树
- 哈希计算: JDK 8优化了扰动函数,减少了哈希冲突
- 扩容机制: JDK 8优化了resize过程,避免了多线程下的死循环
- 插入方式: JDK 7使用头插法;JDK 8使用尾插法
- 查询性能: JDK 8在哈希冲突严重时查询性能从O(n)提升到O(log n)
HashMap容量必须是2的幂次方的原因:
- 便于使用位运算计算索引: hash & (length - 1),效率高于取模运算
- 扩容时可以通过hash & oldCap快速判断元素应该放在原索引还是新索引
- 如果传入非2的幂次方的初始容量,HashMap会通过tableSizeFor()方法将其转换为大于等于该值的最小2的幂次方
