1.1.1 底层存储结构原理
JDK1.8 采用 数组 (Node \[\]) + 单向链表 + 红黑树 三层结构,解决 JDK7 纯链表查询慢问题:
- 数组:哈希桶,长度固定为 2 的幂,快速定位元素;
- Node 单向链表:哈希冲突时挂载元素;
- TreeNode 红黑树:链表长度≥8 转为红黑树,查询时间复杂度从 O (n) 降到 O (logn);
- 转换阈值:链表≥8 转红黑树,节点≤6 退回链表;7 作为缓冲阈值,避免频繁转换造成性能抖动。
Node 节点源码 + 逐行解释
java
static class Node<K,V> implements Map.Entry<K,V> {
// 当前key经过扰动函数生成哈希值,final不可变,避免修改导致下标错乱
final int hash;
// 键不可变,重写equals时依据key对比
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;
}
// 获取键值拼接字符串,打印输出用
public final String toString() {
return key + "=" + value;
}
// 哈希值 = key哈希 ^ value哈希,保证Entry整体唯一性
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
// 修改value并返回旧值
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
// 对比两个Entry是否完全相等(key、value全部一致)
public final boolean equals(Object o) {
if (this == o) return true; // 自身直接相等
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
// key相等 且 value相等才算同一个Entry
return Objects.equals(key, e.getKey()) && Objects.equals(value, e.getValue());
}
return false;
}
}
文字解释
- hash、key 被 final 修饰:节点创建后键与哈希无法修改,防止修改后元素存储下标错乱、内存丢失;
- next 指针形成单向链表,同一哈希桶下冲突元素串联;
- equals 同时对比 key 和 value,
hashCode使用异或保证键值组合哈希唯一。
HashMap 核心成员变量
java
transient Node<K,V>[] table; // 哈希主数组,也称哈希桶,延迟初始化(首次put才创建)
transient int size; // 当前存储键值对总数量
transient int modCount; // 修改计数器,用于快速失败fail-fast机制
int threshold; // 扩容阈值 = 数组容量 * 负载因子,size超过触发resize扩容
final float loadFactor; // 负载因子,默认0.75,平衡空间与查询效率
文字解释
- transient:序列化时忽略数组,序列化会单独处理键值对,节省序列化空间;
- modCount:新增 / 删除 / 修改时自增,迭代过程 modCount 变化直接抛
ConcurrentModificationException; - threshold:数组装满 75% 时扩容,避免哈希冲突激增。
1.1.2 扰动函数 hash () 原理 + 代码
java
static final int hash(Object key) {
int h;
// key为null直接返回0,存入数组下标0;
// key非空:原始hashCode右移16位,与自身异或,混合高低位
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
文字完整解释
- 问题:原始
hashCode()仅低 16 位参与(n-1)&hash下标计算,高位完全失效,大量 key 映射同一桶,冲突严重; - 解决方案:将 hashCode 高 16 位右移到低 16 位,与原值异或,让低位携带高位特征;
- 效果:大幅分散哈希值,降低哈希碰撞概率;
- JDK7 对比:4 次移位异或,运算开销更大,JDK8 简化为一次运算,性能提升。
1.1.3 put () 完整源码逐行解析
scss
public V put(K key, V value) {
// 封装底层putVal,传入扰动后的hash、键、值
return putVal(hash(key), key, value, false, true);
}
/**
* @param hash 扰动后的哈希
* @param key 存入键
* @param value 存入值
* @param onlyIfAbsent true:存在key不覆盖,false:直接覆盖
* @param evict 预留缓存淘汰标记,HashMap无用,LinkedHashMap使用
*/
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. 判断哈希数组未初始化/长度为0,执行resize创建默认容量16的数组
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 2. 计算存储下标 i = (数组长度-1) & hash 等价 hash % n(位运算更快)
if ((p = tab[i = (n - 1) & hash]) == null)
// 3. 当前桶无元素,直接新建Node放入数组
tab[i] = newNode(hash, key, value, null);
else {
// 当前桶存在元素,发生哈希冲突
Node<K,V> e; K k;
// 4. 桶头节点key完全匹配,属于更新操作
if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 5. 桶头是红黑树节点,走红黑树插入逻辑
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 6. 普通单向链表场景,循环遍历链表尾部新增
else {
for (int binCount = 0; ; ++binCount) {
// 遍历到链表末尾,无重复key,新建节点追加
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 链表长度≥8,调用treeifyBin转为红黑树
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, hash);
break;
}
// 遍历中找到相同key,跳出循环准备覆盖value
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 7. 存在旧key,执行覆盖逻辑
if (e != null) {
V oldValue = e.value;
// onlyIfAbsent=false允许覆盖原值
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e); // LinkedHashMap回调,HashMap空实现
return oldValue; // 返回旧值
}
}
++modCount; // 修改计数+1,迭代检测快速失败
// 8. 元素数量超过扩容阈值,执行resize两倍扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict); // LinkedHashMap淘汰回调
return null; // 新增元素返回null
}
文字分步总结流程
-
数组为空 → 初始化容量 16;
-
计算下标,空桶直接存入;
-
桶有元素:
- 头 key 匹配:覆盖 value;
- 红黑树节点:树插入;
- 链表:尾部追加,长度≥8 转红黑树;
-
插入完成,size 超过阈值扩容。
1.1.4 get () 源码与逻辑说明
kotlin
public V get(Object key) {
Node<K,V> e;
// 计算hash,调用getNode查找节点,无节点返回null
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) {
// 头节点直接匹配key,直接返回
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);
// 遍历单向链表匹配key
do {
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
// 无匹配元素返回null
return null;
}
文字解释
- 先定位哈希桶,优先判断桶头,命中直接返回;
- 桶头不区分红黑树 / 链表分别遍历查找;
- 找不到返回 null,无法区分 key 不存在 /value 为 null。
1.1.5 Hash 冲突完整讲解
- 定义 :不同 key 经过
hash()+(n-1)&hash计算后,落到同一个数组下标; - 产生原因:hashCode 范围 -2\^31,2\^31-1,数组长度仅 16/32,取模必然重叠;
- 缓解手段:扰动函数混合高低位,分散哈希值;
- 解决手段:链地址法(链表 + 红黑树);
- 举例:数组长度 8,hash=9、hash=17,
9&7=1,17&7=1,同下标冲突。
1.1.6 为什么 HashMap 容量必须是 2 的 n 次方
核心公式:i = (table.length - 1) & hash
- 性能层面 :位运算
&比取模%CPU 运算快数倍;只有 2^n 时,length-1二进制全 1,&等价取模; - 减少冲突 :若长度非 2 次幂,
length-1存在二进制 0 位,hash 对应 bit 会置 0,一半数组空间永久无法使用,冲突暴增; - 底层
tableSizeFor方法自动向上取最近 2 次幂:
ini
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 >= MAX_CAPACITY) ? MAX_CAPACITY : n + 1;
}
代码解释:不断右移或运算,把低位全部填充 1,最后 + 1 得到最近 2 次幂。
1.1.7 负载因子 0.75 设计原理
负载因子 = 存储元素 size / 数组容量 capacity
- 负载越大:数组利用率高,内存省,但哈希冲突变多,链表变长查询慢;
- 负载越小:冲突少查询快,但大量数组空间闲置,内存浪费;
- 0.75 是统计学最优折中,平衡时间与空间开销,JDK 官方实测最优值。
1.1.8 resize 扩容机制
- 扩容规则:新容量 = 原容量 × 2,保证 2 次幂;
- 下标优化:
hash & oldCap == 0下标不变,否则原下标+oldCap; - JDK8 优化:链表尾插,JDK7 头插多线程会形成环形链表 CPU100% 死循环;
- 扩容开销极大,开发建议初始化
new HashMap(预期容量/0.75),减少扩容次数。
1.1.9 HashMap vs Hashtable vs ConcurrentHashMap
表格
| 特性 | HashMap | Hashtable | ConcurrentHashMap |
|---|---|---|---|
| 线程安全 | ❌ 非线程安全 | ✅ 线程安全(synchronized) | ✅ 线程安全(CAS + synchronized) |
| 效率 | 高 | 低(全表锁) | 高(分段锁 / 桶级锁) |
| null 键 / 值 | 允许 1 个 null 键,多个 null 值 | 不允许 null 键 / 值 | 不允许 null 键 / 值 |
| 初始容量 | 16 | 11 | 16 |
| 扩容方式 | 2 倍 | 2 倍 + 1 | 2 倍 |
| 锁机制 | 无 | 全局锁 | 分段锁(JDK1.7)/ CAS + synchronized(JDK1.8+) |
| 适用场景 | 单线程环境 | 已过时,不推荐使用 | 多线程并发环境 |
| 出现版本 | JDK 1.2 | JDK 1.0 | JDK 1.5 |
| 父类 | AbstractMap | Dictionary | AbstractMap |
| 迭代器 | fail-fast | fail-fast(Enumeration 不是) | fail-safe(弱一致性) |
1.1.10 HashMap 线程不安全三大场景
- 并发 put/remove:数据覆盖、元素丢失;
- 迭代时修改集合,modCount 不一致抛出
ConcurrentModificationException快速失败; - JDK7 扩容头插,多线程环形链表死循环;解决方案 :并发使用
ConcurrentHashMap,单线程使用 HashMap。
1.1.11 Key 对象规范
必须使用不可变对象(String、Integer):
- 可变实体修改属性后 hashCode 改变,元素存储下标变更,get 无法找到数据;
- 自定义类做 key 必须重写
hashCode()与equals()。