为什么默认容量是16?负载因子为何是0.75?链表何时转红黑树?------读完这篇,你才算真正掌握 HashMap
在日常开发中,Map(尤其是 HashMap)是我们打交道最频繁的容器。配置管理、数据缓存、对象池、频次统计......几乎无处不在。
但很多同学只停留在"会用"层面。当你调用 map.put(key, value) 时,底层到底发生了什么?当你调用 map.get(key) 时,它又是怎么在庞大的数据中瞬间定位到目标的?为什么容量必须是 2 的幂?为什么树化阈值是 8?
今天这篇文章,我们从 底层数据结构 出发,逐步深入到 数学原理与工程权衡 ,再结合 put/get 的完整执行流程,彻底把 Map 吃透。
一、Map 家族核心成员速览
在深入原理之前,先对 Map 家族的四大金刚做个全景扫描:
| 实现类 | 底层结构 | 有序性 | 线程安全 | 允许 null 键 | 适用场景 |
|---|---|---|---|---|---|
| HashMap | 数组+链表+红黑树 | 无序 | ❌ | ✅(1个) | 单线程下最高效,通用首选 |
| LinkedHashMap | 继承 HashMap + 双向链表 | 插入/访问顺序 | ❌ | ✅ | 需保证遍历顺序,或实现 LRU 缓存 |
| TreeMap | 红黑树 | 按键排序 | ❌ | ❌ | 需按键自然/自定义排序 |
| ConcurrentHashMap | 数组+链表+红黑树 | 无序 | ✅ | ❌ | 高并发读写,线程安全王者 |
选型铁律:单线程用 HashMap,要排序用 TreeMap,要顺序用 LinkedHashMap,多线程永远只用 ConcurrentHashMap。
二、HashMap 的底层数据结构
1. JDK 1.7 时代:数组 + 链表
底层是一个 Entry[] 数组,每个数组元素称为一个"桶"(Bucket)。当多个键值对落入同一个桶时,以单向链表 的形式存储,采用头插法------新节点插入到链表头部。
存在的问题:
- 哈希冲突严重时,链表过长,查找复杂度退化为 O(n)。
- 并发扩容时,头插法可能导致链表形成环形链表 ,
get()陷入死循环,CPU 飙升至 100%。
2. JDK 1.8 及之后:数组 + 链表 + 红黑树
底层改为 Node[] 数组,存储结构变成 "数组 + 链表 + 红黑树" 的组合,插入方式改为尾插法。
当链表长度超过一定阈值时,链表会转换为红黑树 (一种自平衡的二叉查找树),查找复杂度从 O(n) 优化到 O(log n) 。
| 版本 | 底层结构 | 插入方式 | 并发问题 |
|---|---|---|---|
| JDK 1.7 | Entry\[\] 数组 + 单向链表 | 头插法 | 环形链表、数据丢失 |
| JDK 1.8+ | Node\[\] 数组 + 链表 / 红黑树 | 尾插法 | 数据覆盖、size 不准(但仍不安全) |
三、为什么要引入红黑树?------ 树化与去树化的完整规则
红黑树不是"链表一长就立刻转",它有一套完整的触发条件:
1. 树化(链表 → 红黑树)的条件
必须同时满足两个条件:
- 链表的节点数 ≥ 8 (
TREEIFY_THRESHOLD = 8) - 当前数组的容量 ≥ 64 (
MIN_TREEIFY_CAPACITY = 64)
如果链表长度 ≥ 8,但数组容量 < 64,怎么办?
源码中的 treeifyBin() 方法会这样处理:
java
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY) // 64
resize(); // 不树化,优先扩容!
也就是说,容量不足 64 时,哪怕链表已经很长,也不会转红黑树,而是执行扩容。
为什么?因为数组容量小,说明哈希冲突激烈的原因是"桶位太少"而非"哈希函数差"。通过扩容(容量翻倍),原本挤在同一个桶里的元素会被重新分散到两个新桶中,链表长度自然缩短,问题迎刃而解。这比直接创建内存占用翻倍的红黑树节点更划算。
2. 树化阈值为什么是 8?
这源于泊松分布 的概率计算。在负载因子为 0.75(默认值)的前提下,一个桶中链表长度达到 8 的概率低于千万分之一。
也就是说,链表长度达到 8 在正常场景下几乎不可能发生。红黑树不是为了"常规优化"而存在的,它是一道安全保险丝------用来防御极端恶意的哈希攻击(或者极度巧合的哈希碰撞),保证最坏情况下查询性能也不会崩盘。
3. 去树化(红黑树 → 链表)的阈值为什么是 6?
当红黑树中的节点数 ≤ 6 (UNTREEIFY_THRESHOLD = 6)时,红黑树会退化为链表。
为什么是 6,而不是 7 或 8?这是经典的滞后缓冲(Hysteresis) 设计。
如果树化阈值和去树化阈值都是 8,那么当链表长度在 7、8、9 之间反复波动时,就会触发"树化 ↔ 去树化"的来回转换,造成严重的性能抖动。设置 6 作为去树化阈值(与 8 保持 2 个数的差值) ,形成了一个缓冲区,有效避免了临界值附近的震荡。
四、核心原理:数据如何存储与定位?
这部分我们深入 HashMap 的"心脏",搞清楚一个键值对到底是如何确定它在数组中的位置的。
1. 第一步:哈希值计算(扰动函数)
当调用 put(key, value) 时,第一步不是直接拿 key.hashCode() 用,而是先做一次扰动:
java
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
- 若 key 为
null,哈希值固定为 0。 - 若 key 不为空,取
hashCode()(32 位 int),然后将高 16 位与低 16 位进行异或运算(^) 。
为什么要右移 16 位?
因为 int 是 32 位的。计算数组下标时,实际只用到 (n-1) & hash。当数组长度 n 还很小时(比如默认 16,n-1=15 二进制为 0000...1111),只有 hash 值的低 4 位参与了寻址,高 32 位的信息被完全丢弃,极易造成碰撞。
右移 16 位(正好 32 位的一半),将高位特征"搬"到低位,再与自身异或,让高位信息也能影响最终的下标,大幅提升了散列的均匀性。
2. 第二步:数组下标计算(寻址)
java
index = (table.length - 1) & hash
table.length是数组长度,必须为 2 的幂。- 因为
n是 2 的幂,(n-1) & hash等价于hash % n,但位运算(&)比取模运算(%)快一个数量级。
这就是为什么 HashMap 的容量必须是 2 的幂------这是所有位运算优化的前提。
3. 第三步:定位到桶后,如何查找具体节点?
无论是 put 还是 get,定位到桶之后,都需要在桶内查找 Key 是否已存在。
查找时的比对规则是:先比较 hash 值,再比较 equals 。因为 hash 是 int 值比较(极快),equals 可能涉及复杂的逻辑(较慢),用 hash 先做过滤能大幅提升效率。
java
if (e.hash == hash && e.key.equals(key)) {
// 找到了
}
五、数字背后的工程美学:为什么是这些值?
理解了数据如何存储和定位,现在我们来回答那些经典的"为什么"。
1. 为什么默认初始容量是 16?
既然容量必须是 2 的幂,那为什么选 16 而不是 8 或 32?
- 太小(如 8) :极易触发扩容。扩容涉及所有键值对的重新哈希(Rehashing),是极其耗时的操作,频繁扩容会严重拖垮性能。
- 太大(如 64) :若存储的数据量很少,巨大的数组空间会造成严重的内存浪费。
- 16(即 2^4) :在"初始内存开销"与"扩容频率"之间取得了最佳的工程平衡。
指定容量 :如果你明确知道数据量很大,务必使用
new HashMap<>(initialCapacity)指定容量。HashMap 会通过tableSizeFor方法,将你传入的数字向上取整为大于等于它的最小 2 的幂(比如传入 10,实际容量为 16)。
2. 为什么负载因子(加载因子)是 0.75?
负载因子 = 扩容阈值 / 容量,默认 threshold = 16 * 0.75 = 12。当 size > 12 时触发扩容。
0.75 是空间与时间的数学最优平衡点:
| 负载因子 | 内存利用率 | 哈希冲突 | 查询性能 | 扩容频率 |
|---|---|---|---|---|
| 1.0 | 最高(100%) | 极高 | 最差(退化 O(n)) | 最低 |
| 0.5 | 最低(50%) | 极低 | 最好 | 最高 |
| 0.75 | 适中(75%) | 适中 | 优秀 | 适中 |
0.75 的数学依据 :JDK 源码注释中明确提到了泊松分布。在理想随机哈希函数下,负载因子为 0.75 时,一个桶中元素数量的概率分布如下:
| 桶内元素个数 | 理论概率 |
|---|---|
| 0 | 0.60653066 |
| 1 | 0.30326533 |
| 2 | 0.07581633 |
| 3 | 0.01263606 |
| 4 | 0.00157952 |
| 5 | 0.00015795 |
| 6 | 0.00001316 |
| 7 | 0.00000094 |
| ≥ 8 | < 千万分之一 |
也就是说,在 0.75 的负载因子下,绝大多数桶只有 0 或 1 个元素,查询性能极佳,同时内存利用率维持在 75%。0.75 正是内存浪费与哈希碰撞概率的"帕累托最优" 。
3. 为什么扩容是翻倍(2倍)?
容量翻倍后,新数组长度 newCap = oldCap << 1。
这里藏着一个精妙的设计:扩容后,元素的新索引要么不变,要么 = 原索引 + 旧容量。判断依据是哈希值新增的那个比特位是 0 还是 1:
- 新增位为 0 → 索引不变。
- 新增位为 1 → 新索引 = 原索引 + 旧容量。
这意味着 JDK 1.8 的扩容完全不需要重新计算每个键值对的哈希值!只需检查一个二进制位就能精准定位新位置。这在海量数据扩容时,极大地减少了计算开销,是 JDK 1.8 最重要的性能优化之一。
4. 最大容量为什么是 1 << 30?
1 << 30(即 1073741824)是 Java 正数范围内最大的 2 的幂。
1 << 31的结果是负数(-2147483648),不能作为数组长度。Integer.MAX_VALUE(2^31 - 1)虽然更大,但它不是 2 的幂,违反了 HashMap 容量必须为 2 的幂的核心前提。
因此源码中硬性规定:static final int MAXIMUM_CAPACITY = 1 << 30;
六、执行流程:put 方法的完整流水线
理解了底层数据结构和设计原理,现在我们来看 put 方法到底是怎么一步步执行的。我们以 map.put("name", "张三") 为例:
第 1 步:计算哈希值
调用 hash(key) 方法,进行扰动处理。"name" 的 hashCode 经过高 16 位与低 16 位异或后,得到一个散列值。
第 2 步:计算数组下标
index = (table.length - 1) & hash,确定要放入哪个桶。
第 3 步:根据桶的状态执行不同逻辑
场景 A:桶为空(没有任何元素)
→ 直接创建新节点 Node,放入该桶。完成。
场景 B:桶不为空,且首节点 Key 与待插入 Key 相同
→ 用新 Value 覆盖旧 Value,返回旧 Value。完成。
场景 C:桶不为空,且桶内是红黑树结构
→ 执行红黑树的插入逻辑(按 hash 值和 compareTo 方法在树中寻找插入位置)。
场景 D:桶不为空,且桶内是链表结构
→ 遍历链表,逐个比对 hash 和 equals:
- 如果找到相同 Key → 覆盖 Value,返回旧 Value。
- 如果遍历到末尾都没找到 → 用尾插法将新节点追加到链表末尾。
第 4 步:树化检查(仅当发生了链表插入)
如果第 3 步中是在链表末尾追加了新节点,此时会检查链表长度:
-
如果链表长度 ≥ 8 ,调用
treeifyBin()尝试树化。 -
treeifyBin()内部会再检查数组容量是否 ≥ 64:- 是 → 链表转为红黑树。
- 否 → 执行扩容
resize(),不树化。
第 5 步:扩容检查
插入完成后,size++,然后检查 size > threshold(阈值 = 容量 × 负载因子):
- 是 → 触发
resize(),容量翻倍。 - 否 → 插入结束。
📌 put 方法流程图:
text
put(key, value)
│
├── hash = hash(key) // 扰动:高16位 ^ 低16位
│
├── index = (n - 1) & hash // 位运算寻址
│
├── 桶为空?
│ ├── 是 → 直接插入新节点 → 跳转到扩容检查
│ └── 否 → 进入桶内查找
│
├── 首节点 Key 相同?
│ ├── 是 → 覆盖 Value,返回旧值 → 结束
│ └── 否 → 继续
│
├── 桶内是红黑树?
│ ├── 是 → 红黑树插入 → 跳转到扩容检查
│ └── 否 → 遍历链表
│
├── 遍历链表:
│ ├── 找到相同 Key → 覆盖,返回旧值 → 结束
│ └── 遍历到末尾 → 尾插新节点
│
├── 链表长度 ≥ 8 ?
│ ├── 是 → treeifyBin()
│ │ ├── 数组容量 ≥ 64?→ 是 → 链表 → 红黑树
│ │ └── 数组容量 < 64?→ 是 → 扩容 resize()
│ └── 否 → 跳过
│
└── ++size > threshold ?
├── 是 → 扩容 resize()
└── 否 → 结束
七、执行流程:get 方法的快速查找
当你调用 map.get("name") 时,查找流程比 put 简单得多:
第 1 步:计算哈希值(与 put 完全一致)
java
hash = hash(key); // 同样的扰动函数
第 2 步:计算数组下标(与 put 完全一致)
java
index = (n - 1) & hash;
第 3 步:定位到桶,分情况查找
- 桶为空 → 直接返回
null。 - 桶不为空,检查首节点 :首节点的 Key 与目标匹配(
hash相等且equals为 true)?匹配则直接返回首节点的 Value。 - 桶内是红黑树 → 调用
((TreeNode)first).getTreeNode(hash, key),在红黑树中以 O(log n) 复杂度查找。 - 桶内是链表 → 遍历链表,逐个比对
hash和equals,找到则返回 Value,遍历完没找到则返回null。
get 方法的时间复杂度:
- 理想情况(无冲突):O(1)
- 链表情况:O(n)
- 红黑树情况:O(log n)
八、遍历方式与安全删除
1. 三种遍历方式
java
// ❌ 不推荐:keySet + get(多一次哈希查找)
for (String key : map.keySet()) {
System.out.println(key + ":" + map.get(key));
}
// ✅ 推荐:entrySet(一次性拿全)
for (Map.Entry<String, String> entry : map.entrySet()) {
System.out.println(entry.getKey() + ":" + entry.getValue());
}
// ✅ 最优雅:Lambda(Java 8+)
map.forEach((k, v) -> System.out.println(k + ":" + v));
性能建议 :优先使用 entrySet() 或 forEach(),只需遍历一次数据。keySet() + get() 会多一次哈希查找,大数据量下有性能损耗。
2. 遍历时删除的正确姿势
❌ 错误示范 (会抛出 ConcurrentModificationException):
java
for (String key : map.keySet()) {
if (condition) {
map.remove(key); // 并发修改异常!
}
}
✅ 正确做法(使用迭代器):
java
Iterator<Map.Entry<String, String>> iterator = map.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<String, String> entry = iterator.next();
if (condition) {
iterator.remove(); // 安全删除
}
}
九、并发场景:为什么 HashMap 不安全?
1. 具体问题
| 版本 | 并发问题 | 后果 |
|---|---|---|
| JDK 1.7 | 头插法 + 并发扩容 | 形成环形链表 ,get() 死循环,CPU 100% |
| JDK 1.8+ | 尾插法解决死循环,但仍有问题 | 数据覆盖 (后写覆盖先写)、size 计数不准 |
2. 为什么 size 会不准?
HashMap 中的 size 只是一个普通的 int 变量。size++ 在底层包含三步操作:读取 → 加 1 → 写入,并非原子操作。
多线程并发 put 时,线程 A 和线程 B 可能同时读取到相同的 size 旧值(比如 10),各自加 1 后写回 11,但实际上应该增加 2。最终 size 严重偏小。
3. 并发场景的正确选择
| 方案 | 锁机制 | 性能 |
|---|---|---|
Hashtable |
全局锁(方法级 synchronized) |
极差 ❌ |
Collections.synchronizedMap() |
全局互斥锁 | 差 ⚠️ |
ConcurrentHashMap |
CAS + 细粒度 synchronized(只锁链表头) |
优秀 ✅ |
铁律 :并发场景,永远选择 ConcurrentHashMap,这是唯一正确的答案。
十、总结:一张表记住所有核心要点
| 设计要素 | 取值/规则 | 核心原理 |
|---|---|---|
| 初始容量 | 16 | 内存开销与扩容频率的工程平衡点 |
| 容量约束 | 2 的幂 | 用 & 位运算代替 % 取模,性能飙升 |
| 负载因子 | 0.75 | 泊松分布下的时空最优平衡点 |
| 扩容倍数 | 2 倍 | 新索引只看高位比特,无需重算 hash |
| 树化阈值 | ≥ 8 | 泊松分布概率 < 千万分之一,防恶意攻击的保险丝 |
| 去树化阈值 | ≤ 6 | 防震荡缓冲区,避免频繁转换 |
| 最小树化容量 | ≥ 64 | 容量小则优先扩容,而非树化 |
| 扰动右移 | 16 位 | 混合高低位,让高位参与寻址,降低碰撞 |
| 最大容量 | 1 << 30 | int 正数范围内最大的 2 的幂 |
| null 键位置 | 桶 0 | 哈希值硬编码为 0 |
| 并发方案 | ConcurrentHashMap | CAS + 细粒度锁,高并发唯一选择 |
写在最后
Map 是 Java 开发者手中的"瑞士军刀",但只有理解了它底层的数学权衡 与工程妥协,你才能真正在项目中驾驭它。
记住这四句话:
- 底层结构:数组 + 链表 + 红黑树,位运算寻址,扰动函数保均匀。
- 数字原理:2 的幂、0.75、8 和 6,全是内存、时间与概率的最优解。
- 执行流程:put 是 5 步流水线,get 是快速查字典,扩容无需重算 hash。
- 并发铁律:单线程用 HashMap,多线程用 ConcurrentHashMap,刻进 DNA。
希望这篇带着"源码味"和"数学味"的文章,能帮你彻底点亮 Map 的技能树。如果觉得有用,欢迎点赞、收藏、转发,让更多 Java 小伙伴少走弯路!🚀