Java集合(2)
作者:没有四次元口袋的蓝胖
日期:2026-07-01
标签:Java, 集合框架, HashMap
一、HashMap 概述
HashMap 是 Java 中最常用的 Map 实现,基于哈希表实现,存储键值对(key-value),允许 key 和 value 为 null(key 只能有一个 null,value 可以多个)。
特点:
- 无序(不保证插入顺序)
- 线程不安全
- 查询、插入、删除平均时间复杂度 O(1)
- JDK 1.7:数组 + 链表
- JDK 1.8:数组 + 链表 + 红黑树
面试地位: HashMap 是 Java 面试的"第一题",底层结构、put 流程、扩容机制几乎必问,必须吃透。
二、底层结构
2.1 JDK 1.7 vs JDK 1.8
| 版本 | 底层结构 | 链表插入方式 | 扩容时顺序 |
|---|---|---|---|
| JDK 1.7 | 数组 + 链表 | 头插法 | 逆序(头插导致) |
| JDK 1.8 | 数组 + 链表 + 红黑树 | 尾插法 | 正序(保持原顺序) |
为什么 1.8 要加红黑树?
当哈希冲突严重时,链表会越来越长,查询效率退化为 O(n)。引入红黑树后,链表长度超过阈值时转红黑树,查询效率维持在 O(log n)。
2.2 JDK 1.8 核心字段
java
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {
// 默认初始容量:16(必须是 2 的幂)
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 16
// 最大容量:2^30
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认加载因子:0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 链表转红黑树的阈值:链表长度 >= 8 时考虑转树
static final int TREEIFY_THRESHOLD = 8;
// 红黑树退化为链表的阈值:树节点 <= 6 时退化为链表
static final int UNTREEIFY_THRESHOLD = 6;
// 链表转红黑树的最小数组容量:数组长度 >= 64 才转树
// (数组太小的话优先扩容,不转树)
static final int MIN_TREEIFY_CAPACITY = 64;
// 哈希桶数组(存储链表/红黑树的头节点)
transient Node<K,V>[] table;
// 键值对个数
transient int size;
// 扩容阈值 = 容量 × 加载因子
int threshold;
// 加载因子
final float loadFactor;
}
2.3 Node 节点(链表节点)
java
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; // key 的 hash 值
final K key; // 键
V value; // 值
Node<K,V> next; // 下一个节点(链表指针)
}
2.4 整体结构示意图
table[] 数组(哈希桶)
├── [0] → Node → Node → Node(链表,长度 < 8)
├── [1] → null
├── [2] → TreeNode(红黑树,链表长度 >= 8 且数组长度 >= 64)
├── [3] → Node
├── ...
└── [15] → null
2.5 红黑树节点(TreeNode)
java
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 继承自 LinkedHashMap.Entry,保留了链表指针,所以红黑树中仍然可以通过链表遍历。
2.6 几个关键概念
| 概念 | 含义 |
|---|---|
| 容量(capacity) | 哈希桶数组的长度,默认 16,必须是 2 的幂 |
| 加载因子(loadFactor) | 填充比例,默认 0.75 |
| 阈值(threshold) | capacity × loadFactor,size 超过这个值就扩容 |
| 哈希桶(bucket) | 数组的每一个位置叫一个桶 |
为什么容量必须是 2 的幂? 见下文"寻址优化"。
三、Hash 冲突
3.1 什么是 Hash 冲突?
两个不同的 key,通过哈希函数计算出相同的数组下标(落在同一个桶里),这就是 Hash 冲突。
hash("apple") % 16 = 3
hash("banana") % 16 = 3 ← 冲突了!
Hash 冲突不可避免,因为 key 是无限的,数组长度是有限的。
3.2 Hash 冲突的解决方案
| 方法 | 原理 | 代表 |
|---|---|---|
| 链地址法 | 冲突的元素放在同一个桶里,用链表/红黑树串起来 | HashMap |
| 开放地址法 | 冲突了就找下一个空位置(线性探测、二次探测等) | ThreadLocalMap |
| 再哈希法 | 冲突了就换一个哈希函数重新算 | - |
| 公共溢出区 | 冲突的元素放到另一个溢出表 | - |
HashMap 用的是链地址法(链表 + 红黑树)。
3.3 HashMap 的哈希函数
java
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
这段代码做了什么?------ 扰动函数
- 拿到 key 的 hashCode()(32 位 int)
- 将 hashCode 的高 16 位与低 16 位做异或 (
h >>> 16是无符号右移 16 位) - 目的:让高位也参与到下标计算中,减少哈希冲突
为什么要这么做?
因为后面计算数组下标用的是 hash & (n-1)(n 是数组长度),当 n 比较小时(比如 16),只有低几位参与运算,高位完全用不上。扰动函数把高位的特征混入低位,让哈希更均匀。
hashCode: 1101 1010 1110 0101 1011 0001 1100 1010
>>> 16: 0000 0000 0000 0000 1101 1010 1110 0101
异或后: 1101 1010 1110 0101 0110 1011 0010 1111
↑ 高位特征混入低位
3.4 寻址优化:用位运算代替取模
java
// 计算数组下标
// 普通写法:hash % n (取模)
// HashMap 写法:hash & (n - 1) (位与运算)
为什么 hash & (n - 1) 等价于 hash % n?
前提是 n 必须是 2 的幂。
举个例子,n = 16(二进制 10000),n-1 = 15(二进制 01111):
hash: 1010 1101
n-1: 0000 1111
& 结果: 0000 1101 = 13
n-1 的低位全是 1,高位全是 0,和 hash 做与运算就相当于只保留低几位,效果等价于取模,但位运算比取模快得多。
这就是容量必须是 2 的幂的原因! 为了用位运算优化寻址效率。
3.5 链表转红黑树的条件
两个条件同时满足:
- 链表长度 >= 8(TREEIFY_THRESHOLD)
- 数组容量 >= 64(MIN_TREEIFY_CAPACITY)
如果链表长度 >= 8 但数组容量 < 64,会优先扩容而不是转红黑树。
为什么阈值是 8?
根据泊松分布,在哈希函数良好的情况下,链表长度达到 8 的概率约为 0.00000006(千万分之六),概率极低。选 8 是为了让转红黑树的概率足够小,避免频繁转换。
为什么退化阈值是 6 而不是 8?
留一个缓冲区间(8 转树,6 退化),避免在 8 附近频繁地"树化 ↔ 链表化"来回震荡。
四、Put 流程
4.1 总流程图
put(key, value)
↓
计算 hash 值(扰动函数)
↓
table 数组为空?
├── 是 → resize() 初始化数组(默认容量16,阈值12)
└── 否 → 继续
↓
计算数组下标:i = (n - 1) & hash
↓
table[i] 位置是否为空?
├── 空 → 直接 new Node 放进去 → 转第7步
└── 不为空 → 继续
↓
桶里第一个元素的 key 和新 key 相同吗?
├── 相同 → 记录这个节点 → 转第6步
└── 不相同 → 继续
↓
是红黑树节点吗?
├── 是 → 调用树的插入方法 → 转第6步
└── 否(链表) → 遍历链表:
├── 找到 key 相同的节点 → 记录 → 转第6步
└── 遍历到末尾都没找到 → 尾插新节点
↓
链表长度 >= 8 吗?
├── 是 → treeifyBin() 尝试转红黑树
└── 否 → 不用转
↓
6. 如果找到了相同 key 的节点 → 用新 value 覆盖旧 value → 返回旧 value
↓
7. modCount++(修改次数+1)
↓
8. size++,size > threshold 吗?
├── 是 → resize() 扩容
└── 否 → 结束
↓
返回 null
4.2 源码级拆解
第一步:put 入口
java
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
第二步:putVal 核心方法
java
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. 数组为空 → 初始化(resize 既是初始化也是扩容)
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 {
Node<K,V> e; K k;
// 3. 桶头节点的 key 相同 → 记录下来
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 4. 是红黑树 → 走树的插入逻辑
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 5. 是链表 → 遍历
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 因为从0开始
treeifyBin(tab, hash);
break;
}
// 找到 key 相同的节点 → 跳出
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 6. 找到了相同 key → 覆盖 value
if (e != null) {
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e); // 回调,给 LinkedHashMap 用
return oldValue;
}
}
++modCount; // 7. 修改次数+1
// 8. size 超过阈值 → 扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict); // 回调
return null;
}
4.3 判断 key 相同的逻辑
java
p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))
两步判断:
- 先比 hash:hash 不同,key 一定不同;hash 相同,key 不一定相同
- 再比 equals / ==:hash 相同的情况下,再比较 key 是否真的相同
为什么要先比 hash 再比 equals?
因为 hash 是 int,比较非常快;equals 可能比较复杂(比如字符串要逐字符比)。先比 hash 可以快速排除绝大多数不相等的情况,提高效率。
面试题: 为什么重写 equals 必须重写 hashCode?
如果两个对象 equals 返回 true,但 hashCode 不一样,它们在 HashMap 中会被放到不同的桶里,导致"相同"的 key 可以存多次,违反了 Map 的语义。
4.4 JDK 1.7 vs 1.8 put 流程区别
| 对比项 | JDK 1.7 | JDK 1.8 |
|---|---|---|
| 插入方式 | 头插法 | 尾插法 |
| 数据结构 | 数组 + 链表 | 数组 + 链表 + 红黑树 |
| 扩容时元素顺序 | 反转(头插导致) | 保持原顺序 |
| 扩容条件 | size >= threshold 且 当前位置不为空 | size > threshold |
| 哈希计算 | 4次位运算 + 5次异或 | 1次异或(高16位 ^ 低16位) |
| 死循环问题 | 多线程扩容可能出现 | 不会(尾插法) |
注意: JDK 1.8 的 HashMap 虽然不会出现死循环,但仍然是线程不安全的,多线程场景请用 ConcurrentHashMap。
五、扩容机制(resize)
5.1 什么时候扩容?
两种情况:
- 数组为空时,初始化扩容(默认容量 16)
size > threshold时,扩容(threshold = capacity × loadFactor)
5.2 扩容大小
新容量 = 旧容量 × 2(保持 2 的幂)
java
// JDK 1.8 resize 源码片段
if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // 阈值也翻倍
5.3 扩容后元素的位置变化
扩容后,元素在新数组中的位置有两种可能:
旧容量 n = 16,新容量 = 32
旧下标 = hash & 15(01111)
新下标 = hash & 31(11111)
差别就在第 5 位(从0开始数第4位):
- 如果 hash 的第 5 位是 0 → 新下标 = 旧下标
- 如果 hash 的第 5 位是 1 → 新下标 = 旧下标 + 旧容量
JDK 1.8 的优化: 不需要重新计算 hash,只需要看 hash 值的那一位是 0 还是 1,就能确定新位置。
java
// 源码中的判断
if ((e.hash & oldCap) == 0) {
// 放在原位置的链表
...
} else {
// 放在 原位置 + oldCap 的链表
...
}
e.hash & oldCap:oldCap 是 2 的幂,二进制只有一位是 1,和 hash 做与运算就是看 hash 对应的那一位是 0 还是 1。
5.4 JDK 1.7 扩容的死循环问题
JDK 1.7 用头插法,多线程并发扩容时可能导致链表形成环,后续 get 操作会死循环(CPU 100%)。
JDK 1.8 用尾插法,扩容后保持元素原有顺序,不会形成环。
但 HashMap 本来就是线程不安全的! 不要在多线程中使用 HashMap,请用 ConcurrentHashMap。
5.5 为什么加载因子是 0.75?
这是时间和空间的权衡:
- 加载因子太大(如 1.0):空间利用率高,但冲突概率大,链表变长,查询效率下降
- 加载因子太小(如 0.5):冲突少,查询快,但空间浪费严重,频繁扩容
0.75 是一个经验值,在时间和空间之间取得了较好的平衡。
六、Get 流程
java
public V get(Object key) {
Node<K,V> e;
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;
// 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;
if ((e = first.next) != null) {
// 3. 是红黑树 → 树中查找
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
// 4. 是链表 → 遍历查找
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
流程总结:
- 计算 hash 值
- 数组为空 → 返回 null
- 找到对应桶,桶头就是目标 → 直接返回
- 是红黑树 → 树中查找(O(log n))
- 是链表 → 遍历查找(O(n))
- 找不到 → 返回 null
七、思维导图速览
HashMap 核心
├── 底层结构
│ ├── JDK 1.7:数组 + 链表(头插法)
│ └── JDK 1.8:数组 + 链表 + 红黑树(尾插法)
│ ├── 链表转红黑树:链表>=8 且 数组>=64
│ ├── 红黑树退链表:节点<=6
│ └── 默认容量 16、加载因子 0.75、容量必须 2 的幂
├── Hash 冲突
│ ├── 定义:不同 key 算到同一个下标
│ ├── 解决方案:链地址法(链表+红黑树)
│ ├── 扰动函数:高16位 ^ 低16位(减少冲突)
│ └── 寻址优化:hash & (n-1) 等价于 hash % n(位运算更快)
├── Put 流程
│ ├── 1. 计算 hash(扰动函数)
│ ├── 2. 数组为空 → resize 初始化
│ ├── 3. 位置为空 → 直接插入
│ ├── 4. key 相同 → 覆盖 value
│ ├── 5. 红黑树 → 树插入
│ ├── 6. 链表 → 尾插,长度>=8尝试转树
│ ├── 7. modCount++
│ └── 8. size > threshold → resize 扩容
├── 扩容机制
│ ├── 扩容时机:size > threshold
│ ├── 扩容大小:容量 × 2、阈值 × 2
│ ├── 元素重定位:
│ │ ├── hash & oldCap == 0 → 原位置
│ │ └── hash & oldCap == 1 → 原位置 + oldCap
│ ├── JDK 1.7 头插法 → 死循环问题
│ └── JDK 1.8 尾插法 → 保持顺序,不会死循环
└── Get 流程
├── 计算 hash → 定位桶 → 判断桶头
├── 红黑树 → 树查找 O(log n)
└── 链表 → 遍历查找 O(n)
八、写在最后
学习建议
- put 流程必须能手写出来:这是 HashMap 面试的核心,说不清楚直接挂
- 扩容机制要理解透:为什么是 2 倍、为什么用位运算、扩容后元素位置怎么变
- 红黑树转换条件要记准 :链表长度 >= 8 且 数组容量 >= 64,两个条件缺一不可
- JDK 1.7 和 1.8 的区别:头插 vs 尾插、数据结构变化、死循环问题,都是高频考点
- 建议读源码:putVal 和 resize 两个方法加起来也不长,对着注释读一遍比背十遍都管用
面试高频题(附答题思路)
Q1:HashMap 的底层结构?
JDK 1.8 是数组 + 链表 + 红黑树。数组是主体,冲突用链表解决,链表长度 >= 8 且数组容量 >= 64 时转红黑树优化查询效率。
Q2:HashMap 的 put 流程?
按上面的流程图说,8步说清楚,重点说清楚链表和红黑树的分支判断。
Q3:为什么容量必须是 2 的幂?
为了用 hash & (n-1) 位运算代替取模,效率更高,而且只要 n 是 2 的幂,这个等式就成立。
Q4:为什么重写 equals 必须重写 hashCode?
HashMap 判断 key 是否相同的逻辑是:先比 hash,再比 equals。如果 equals 相同但 hashCode 不同,会被放到不同的桶里,导致同一个 key 可以存多次,违反 Map 语义。
Q5:HashMap 是线程安全的吗?
不是。JDK 1.7 多线程扩容会有死循环问题,JDK 1.8 虽然不会死循环但仍有数据丢失等问题。多线程用 ConcurrentHashMap。
Q6:加载因子为什么是 0.75?
时间和空间的权衡。太大冲突多、查询慢;太小浪费空间、扩容频繁。0.75 是经验值,综合性能最好。