目录
[1 HashMap 原理](#1 HashMap 原理)
[2 自测问题](#2 自测问题)
[为什么hash表中 数组的长度都是 16 32 64 这样的2的幂次方?](#为什么hash表中 数组的长度都是 16 32 64 这样的2的幂次方?)
1 HashMap 原理
先理解它解决什么问题
普通数组查找要一个个遍历,慢:
找"张三"的电话:李四, 王五, 张三, ... → 从头找,O(n)
HashMap 目标:给任意Key,O(1)时间找到Value。
核心结构
数组 + 链表 + 红黑树
底层是一个数组,每个格子叫"桶"(bucket):
index: 0 1 2 3 4 5 6 7
↓ ↓
"张三"→18 "李四"→25
↓
"王五"→30 ← 同一个桶,用链表连接(hash冲突)
存入过程(put)
map.put("张三", 18);
第一步:算hash值
"张三".hashCode() = 某个整数,比如 123456
第二步:算数组下标
index = hash % 数组长度
= 123456 % 16 = 0 → 放到第0个桶
第三步:放入桶中
如果桶是空的 → 直接放
如果桶有东西 → 比较key是否相同
相同 → 覆盖value
不同 → 链表追加到后面(hash冲突)
查找过程(get)
map.get("张三");
-
算"张三"的hash → index=0
-
去第0个桶找
-
桶里可能有链表,逐个比较key
-
找到"张三" → 返回18
三个关键设计
- 初始容量 = 16,负载因子 = 0.75
存入元素数量 > 16 × 0.75 = 12 个时
→ 触发扩容:数组扩大为2倍(32)
→ 所有元素重新计算位置(rehash)
为什么0.75?太小(比如0.5)→ 频繁扩容浪费内存;太大(比如1.0)→
冲突多查找慢。0.75是时间和空间的平衡点。
0.75基于泊松分布的数学推导,在该负载因子下哈希冲突概率极低(桶内超过8个元素的概率小于千万分之一), 同时结合HashMap数组长度为2的幂次的特性,容量 × 0.75 = 扩容阈值
16 × 0.75 = 12 ✓ 整数
32 × 0.75 = 24 ✓ 整数
64 × 0.75 = 48 ✓ 整数
兼顾了内存利用率和查找性能。
- 链表转红黑树(Java 8新增)
同一个桶的链表长度 ≥ 8 时:
链表 → 红黑树
链表查找:O(n)
红黑树查找:O(log n)
防止极端情况下大量冲突导致性能退化
- 数组长度始终是2的幂次(16、32、64...)
正常取模:hash % length → 除法,慢
位运算: hash & (length-1) → 快10倍
length=16时,length-1=15=0b1111
hash & 0b1111 等价于 hash % 16
除法在CPU层面很慢
CPU执行指令的速度差异
加法 + → 1个时钟周期
位运算 & → 1个时钟周期
乘法 × → 3-5个时钟周期
除法 ÷ → 20-90个时钟周期 ← 慢很多
取模 % 本质上是除法(求余数),CPU要做大量计算。
为什么除法慢?
加法:直接进位,电路简单
1011
- 0101
──────
逐位处理,一步完成
除法:要反复试商,类似手算竖式除法
123456 ÷ 16 = ?
先试:16×7000=112000, 余11456
再试:16×700=11200, 余256
再试:16×16=256, 余0
→ 多轮计算才能得出结果
位运算为什么快
& 是按位与,每一位独立计算,一步完成:
hash = 123456 = 0b11110001001000000
length-1 = 15 = 0b00000000000001111
按位AND:每位对齐,0&任何=0,1&1=1
结果 = 0b00000000000000000 → 只保留最后4位
CPU对每一位同时计算,没有依赖关系,一个时钟周期搞定。
为什么 & (length-1) 等价于 % length
只在 length是2的幂次 时成立:
length = 16 = 0b10000
length-1= 15 = 0b01111 ← 低4位全是1,高位全是0
任何数 & 0b01111 = 保留最后4位
最后4位的范围 = 0~15
而任何数 % 16 的结果也是 0~15
所以两者等价
举例:
hash = 100 = 0b1100100
100 % 16 = 4
100 & 15 = 0b1100100 & 0b0001111 = 0b0000100 = 4 ✓
▎ 取模是除法,CPU需要多轮试商,慢。位运算每位独立同时计算,一步完成,快。HashMap把数组长度设计成2的幂次,就是为了把慢的除法换成快的位运算。
2 自测问题
Q1:HashMap底层有哪些数据结构?
数组+链表+红黑树
数组:主体骨架,每个格子是一个"桶"
01234567...
快速定位(O(1),直接按下标取)
链表:同一个桶里有多个元素时,用链表串起来
2 → "张三"→18 → "王五"→30 → null
红黑树:链表太长(≥8个)时,链表升级为红黑树
查找从O(n)提升到O(log n)
Q2:三种结构分别干什么?
三种结构各司其职:
数组:主体骨架,每个格子是一个"桶"
01234567...
快速定位(O(1),直接按下标取)
链表:同一个桶里有多个元素时,用链表串起来
2 → "张三"→18 → "王五"→30 → null
红黑树:链表太长(≥8个)时,链表升级为红黑树
查找从O(n)提升到O(log n)
Q3:存储时会有哪些冲突?
只有一种冲突:hash冲突(不同的key算出了相同的桶下标)
Q4:hash冲突常见吗?跟数组长度有关?
计算过程:
第1步:key → hashCode()
"张三".hashCode() = 774889
"王五".hashCode() = 890123
第2步:hashCode → 数组下标
下标 = hashCode & (length-1)
= 774889 & 15 = 9 → 张三放桶9
= 890123 & 15 = 11 → 王五放桶11 (不冲突)
如果两个key算出的下标相同 → 冲突
为什么跟数组长度有关:
数组长度=16,只有0~15共16个桶
不管有多少key,最终下标只能落在0~15
key越多,撞在一起的概率越大
数组长度=1024,有1024个桶
同样的key,分散空间更大,冲突更少
输入一组数据 → hash算法 → 固定的值
这说的是hash函数的特性:相同输入永远得到相同输出
"张三" → hash算法 → 774889 (每次都是774889,不会变)
"张三" → hash算法 → 774889 (再算一次,还是一样)
不同输入:
"张三" → 774889
"李四" → 328901 (不同key,不同结果)
这是HashMap能工作的基础------同一个key每次都能算出同一个桶,才能找回数据
总结
put("张三", 18)
↓
hashCode() → 774889
↓
& (16-1) → index = 9
↓
去第9个桶
↓
空桶?→ 直接存
有元素?→ key相同覆盖 / key不同挂链表
链表≥8?→ 升级红黑树
Q5:HashMap存储&扩容过程
HashMap存储过程
通过 key 计算一个 hash 值
将 hash 与数组的最大下标与运算 得到的目标存储下标
为什么hash表中 数组的长度都是 16 32 64 这样的2的幂次方?

初始长度: 16 数组的扩容倍数: 2 倍
1 : 二进制码都是满 1 的形式,方便与运算时 散射的范围可以覆盖数组的 每个下标
1111 & 1010 = 1010
1111 & 1100 = 1100 0000-1111
1010 & 1010 = 1010 1000 1010 0010 0000
1010 & 1100 = 1000
1010 & 1011 = 1010
核心原因:length-1 的二进制必须全是1
length = 16,length-1 = 15 = 0b 1111 ← 全是1 ✓
length = 32,length-1 = 31 = 0b 11111 ← 全是1 ✓
length = 64,length-1 = 63 = 0b 111111 ← 全是1 ✓length = 10,length-1 = 9 = 0b 1001 ← 不全是1 ✗
为什么全是1好?
全是1的掩码,& 运算的结果能覆盖 0~length-1 每一个值:
1111 & 任意数 → 结果可以是 0000~1111(0到15,16种)
1001 & 任意数 → 结果只能是 0000、0001、1000、1001(只有4种!)
大量下标永远不会被用到,浪费严重,冲突剧增1111 & 1010 = 1010 = 10 ✓ 在0~15范围内
1111 & 1100 = 1100 = 12 ✓ 在0~15范围内1010 & 1010 = 1010 = 10 ✗ 部分结果会消失
1010 & 1100 = 1000 = 8
1010 & 1011 = 1010 = 10
→ 1001、0110等下标永远算不到,分布不均匀
2: 扩容时,只会多判断一位二进制码,分成两根链表,降低迁移时的时间 复杂度。
长度 16
1111 & 1110 0101 = 0101 5
1111 & 1011 0101 = 0101 5
长度 32 最大下标 31
1 1111 & 1100 0101 = 0 0101 5
1 1111 & 1011 0101 = 1 0101 16+5
长度 64 最大下标 63
1 1111 & 1101 0101 = 01 0101 21
1 1111 & 1011 0101 = 11 0101 32+21
扩容到 64 之后
11 1111 & 1101 0101 = 01 0101 21
11 1111 & 1011 0101 = 11 0101 32+21
扩容时只多判断一位(这是精髓)这是 Java 8 HashMap 扩容的核心优化。
先看长度16的情况:
length=16,掩码 = 1111
两个元素,hashCode分别是:
A: 1110 0101
B: 1011 0101计算下标:
1111 & 1110 0101 = 0101 = 5 → A在桶5
1111 & 1011 0101 = 0101 = 5 → B也在桶5(冲突,同一条链表)扩容到32:
length=32,掩码 = 1 1111(多了一位)
重新计算:
1 1111 & 1110 0101 = 0 0101 = 5 → A还在桶5
1 1111 & 1011 0101 = 1 0101 = 21 → B去了桶21(16+5)关键观察:
原来同在桶5,扩容后分开了:
A → 桶5 (位置不变)
B → 桶21 (= 原位置5 + 旧长度16)怎么判断去哪个桶?
只看 hashCode 的第5位(新增的那一位):
A: 1110 0101 → 第5位是 0 → 留在原桶(5)
B: 1011 0101 → 第5位是 1 → 去新桶(5+16=21)扩容到64,掩码 = 11 1111
11 1111 & 1101 0101 = 01 0101 = 21
11 1111 & 1011 0101 = 11 0101 = 53(32+21)看第6位:
1101 0101 → 第6位是 0 → 留在桶21
1011 0101 → 第6位是 1 → 去桶53(21+32)每次扩容,只需要看 hashCode 多出来的那一位是0还是1:
是0 → 位置不变
是1 → 位置 = 原位置 + 旧长度

Q6:关于hashcode()
Java 所有类都继承自 Object,Object 里有默认的 hashCode():
public class Object {
public native int hashCode(); // native = 调用C++底层实现
}
你写的任何类,不管有没有写 hashCode(),都自动有这个方法。
默认 hashCode() 怎么计算 默认实现是根据对象的内存地址计算出一个整数:
Object obj = new Object();
obj.hashCode(); // 比如返回 1829164700(每次new出来可能不同)
同一个对象每次调用结果相同,不同对象结果不同。
String 重写了 hashCode()
java
// String 的 hashCode 源码:
public int hashCode() {
int h = 0;
for (char c : value) {
h = 31 * h + c; // 每个字符都参与计算
}
return h;
}
举例:
"ab" 的计算过程:
h = 0
h = 31 × 0 + 'a' = 97
h = 31 × 97 + 'b' = 3105
→ hashCode = 3105
"ab" 不管在哪个对象,结果永远是 3105
这就是为什么:
String a = new String("ab");
String b = new String("ab");
a.hashCode() == b.hashCode() // true,内容相同结果相同
a == b // false,不是同一个对象
为什么乘以 31
31 = 2⁵ - 1
乘以31可以用位运算代替,更快:
n * 31 = (n << 5) - n
同时31是奇素数,用它做乘数能让hash值分布更均匀,减少冲突。
HashMap 对 hashCode 再加工
拿到 hashCode 后,HashMap 不直接用,还要再做一步扰动:
java
// HashMap 源码:
static final int hash(Object key) {
int h = key.hashCode();
return h ^ (h >>> 16); // 高16位 异或 低16位
}
为什么?
hashCode = 0b 1010 1100 0011 1111 | 1001 0110 0111 0001
高16位 | 低16位
& (length-1) 只看低位,高位完全浪费:
0b...1001 0110 0111 0001
& 0b...0000 0000 0000 1111 (length=16时只看最后4位)
高16位信息完全没参与,不同key可能低位一样 → 冲突多
扰动后:高16位混入低16位
让更多信息参与最终下标计算 → 冲突更少
什么时候需要自己重写 hashCode?
当用自定义对象做 key 时
java
// 自定义类
class User {
String name;
int age;
}
User a = new User("张三", 18);
User b = new User("张三", 18);
// 默认hashCode基于内存地址:
a.hashCode() ≠ b.hashCode() // 两个不同对象,地址不同
map.put(a, "value");
map.get(b); // 找不到!因为hashCode不同,去了不同的桶
重写后:
java
@Override
public int hashCode() {
return Objects.hash(name, age); // 根据内容计算
}
// 现在:
a.hashCode() == b.hashCode() // 内容相同,结果相同
map.get(b); // 能找到 ✓
默认 hashCode() → 基于内存地址,不同对象不同
String hashCode() → 基于字符内容,内容相同则相同(已重写)
自定义类 → 需要自己重写,否则用内存地址,当key时会出问题
HashMap 额外做了扰动(^ h>>>16),让hash分布更均匀
Q7:HashMap线程安全吗?
▎ 不安全。多线程同时put,可能导致数据丢失。Java 7还会出现死循环(链表成环)。
▎ 线程安全的替代:ConcurrentHashMap(分段锁/CAS,性能好)或 Hashtable(全表加锁,性能差,已淘汰)。