目录
[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底层有哪些数据结构?
数组+链表+红黑树
数组:主体骨架,每个格子是一个"桶"
0\]\[1\]\[2\]\[3\]\[4\]\[5\]\[6\]\[7\]... 快速定位(O(1),直接按下标取) 链表:同一个桶里有多个元素时,用链表串起来 \[2\] → "张三"→18 → "王五"→30 → null 红黑树:链表太长(≥8个)时,链表升级为红黑树 查找从O(n)提升到O(log n) ### Q2:三种结构分别干什么? 三种结构各司其职: 数组:主体骨架,每个格子是一个"桶" \[0\]\[1\]\[2\]\[3\]\[4\]\[5\]\[6\]\[7\]... 快速定位(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(全表加锁,性能差,已淘汰)。