hashCode · 扰动 · (n−1) & hash · 拉链 · 负载因子 0.75 · resize · 高低位拆分 · 树化 8 / 6 / 64 · fail-fast · ConcurrentHashMap
HashMap:把 key 变成数组下标,撞了就挂链,挂长了就扩容,扩不动才变树
数组按下标取是一步到位的,可 key 是 88L、"req-1"、一个 record,不是 0、1、2。HashMap 的全部心思就是把任意 key 稳定地换算成一个下标:先取 hashCode,把高 16 位混进低 16 位,再和“数组长度 − 1”按位与。长度永远是 2 的幂,所以这一步是一次位运算;扩容翻倍时,每个节点只看多出来的那一位,决定留在原地还是搬到“原位 + 旧长度”,不用重新算。撞到同一个桶的 key 挂成链表,链表太长而数组已经够大时,才变成红黑树。
全景:一个订单对象的一生 · 第 4、8 站地图:J01 · HashMap:哈希、扩容、树化(过关题)上一页:常用集合怎么选实验:JDK 17.0.20 实测
①为什么需要:get(88L) 怎么一步就找到
在全景第 4 站,这一步要解决的是:100 个商品缓存在内存里,下单时给一个商品号 88,要立刻拿到机械键盘,不能把 100 个挨个比一遍;到了几十万个商品也得一样快。全景实测打印出的那行“hashCode=88,扰动后 88,桶数组长 256,下标 88”,就是这一页要讲清的全过程。第 8 站还埋着一个雷:多个 Tomcat 线程同时往 userOrders 这个 HashMap 里放,会丢数据。
②没有它会怎样:数组快,但 key 不是下标
挨个比List
10 万单里找用户 1024,要看 10 万次。上一页 实测 查 1 万次 1477 ms。
直接拿 key 当下标long[] 开到最大
商品号 88 就放 arr[88],一步到位。可用户 id 可能是 10 亿、请求号是字符串,数组开不了那么大,也没法拿字符串当下标。
排好序二分TreeMap
O(log n),100 万个要比 20 次,还要求 key 能比大小。不需要有序时,这 20 次是白花的。
- 挨个比太慢;排序二分要求可比较,而且仍然要比 log n 次。
- 直接当下标最快,但只适用于“小范围的整数 key”。
- 缺的那一块:一个函数,把任意 key 压到 0 ~ n−1 的一个整数上,这就是哈希。代价是不同的 key 可能压到同一格(碰撞),所以要有办法处理碰撞,并且让碰撞少。
不懂精华时的典型误用:重写了 equals 却没重写 hashCode。两个“相等”的订单算出不同的下标,各住各的桶,get 永远找不到另一个(坑见⑥)。
③相邻方案:撞了怎么办
| 做法 | 谁在用 | 撞了以后 | 好在哪 | 输在哪 |
|---|---|---|---|---|
| 拉链法 | HashMap、ConcurrentHashMap | 同一个桶挂一条链表(太长变树) | 装得再满也不会“没地方”,删除简单 | 每个元素一个节点对象,32 字节 |
| 开放寻址 | ThreadLocalMap、IdentityHashMap、Python dict | 往后找下一个空格 | 没有节点对象,数组连续,缓存友好 | 负载因子必须低,删除要留“墓碑”,满了退化严重 |
| 素数长度 + 取模 | Hashtable(JDK 1.0) | 拉链 | 取模对坏哈希更宽容 | % 比位运算慢;扩容要每个元素重新取模;每个方法都加锁 |
| 不哈希,用树 | TreeMap | 没有碰撞这回事 | 有序、能范围查 | O(log n),key 必须可比较 |
本页学 HashMap(JDK 8 起的实现,JDK 17 源码):拉链法 + 2 的幂长度 + 扰动 + 太长才树化。Java 里按 key 找、不要求顺序时,默认就选它;多线程共享时换 ConcurrentHashMap,它的桶结构和这页一样,只是加了并发控制(J02)。
④原理:定位、put、扩容、树化
- 按位与:
x & 15只留下 x 二进制的最低 4 位,结果在 0~15 之间。当 n 是 2 的幂时,x & (n − 1)和x % n结果一样(x 非负),但只要一条指令。 - hashCode 和 equals 的约定:equals 相等的两个对象,hashCode 必须相等;反过来不要求。HashMap 先用 hash 找桶,再用 equals 确认,靠的就是这条约定。record 会按全部字段自动生成这两个方法。
- 红黑树:上一页讲过,有序、O(log n)。这里只用到“链表太长时换成它”。
④-1定位三步:hashCode → 扰动 → (n−1) & hash
hash(key) 就一行:(h = key.hashCode()) ^ (h >>> 16),key 为 null 时 hash 是 0(所以 HashMap 允许一个 null key,放在第 0 格)。全景第 4 站的 88 只有低位,扰动后还是 88。JDK 7 的扰动做了 4 次移位异或,JDK 8 减到 1 次,因为最坏情况已经有树化兜底了(⑥-3)。④-2put 的全过程(过关题第一问)
putVal 画的。第 6 步“到 9 个”来自源码 binCount >= TREEIFY_THRESHOLD − 1:binCount 从第一个节点后面开始数,条件成立时新节点已经挂上,链表正好 9 个。常说的“链表长度超过 8 就树化”指的就是这个,⑤ 里实测也是第 9 个才触发。④-3扩容:翻倍,每个节点只看一位就知道去哪
threshold = 容量 × 负载因子 0.75。实测 从空的 HashMap 一个个放:第 1 个时才分配 16 格(阈值 12),第 13、25、49、97 个时依次扩到 32、64、128、256,阈值跟着变成 24、48、96、192。④-4树化:链表长到 9 个,数组也够大,才变红黑树
三个常量JDK 17 源码
TREEIFY_THRESHOLD = 8:链表挂到第 9 个时尝试树化MIN_TREEIFY_CAPACITY = 64:数组小于 64 时不树化,改为扩容。数组小,链长多半是因为“格子太少”,扩容更对症UNTREEIFY_THRESHOLD = 6:扩容拆分后一边剩 6 个及以下,退回链表。8 和 6 隔开一段,免得在临界值附近来回变
实测 12 个 hashCode 都是 42 的 key:第 9 个时扩到 32,第 10 个扩到 64,第 11 个那个桶才变成 TreeNode。
为什么阈值是 8源码注释里的算账
hashCode 分布正常、负载因子 0.75 时,一个桶里的节点数近似泊松分布(平均约 0.5 个)。源码注释列出:桶里有 0 个的概率约 0.61,1 个约 0.30,2 个约 0.08……8 个约 0.00000006。
TreeNode 大约是普通 Node 的两倍大。所以树化不是为“平常”准备的,平常根本到不了 8;它是给hashCode 写得很差、或者被人故意构造碰撞时兜底的(⑥-3 哈希碰撞攻击)。
④-5为什么长度非是 2 的幂、扩容非是 2 倍(过关题第二问)
HashMap:2 的幂 + 位运算JDK 1.4 起
- 定位:
hash & (n−1),一条指令;取模要做除法,慢一个数量级 - 扩容:翻倍后仍是 2 的幂,每个节点只看
hash & oldCap一位就知道去哪,不用重新算 - 均摊:按比例扩容,n 次 put 总共只搬 O(n) 次(和 ArrayList 的 1.5 倍同理)
- 代价:下标只看低位,高位再乱也用不上,所以必须扰动;hashCode 低位分布差时更容易撞
Hashtable:素数长度 + 取模JDK 1.0
- 长度取素数(初始 11,扩容 2n+1),取模时 hashCode 的高位低位都参与,对差的 hashCode 更宽容
- 每次定位都要做除法
- 扩容后每个元素都要重新
% newLength - 每个方法都 synchronized,单线程也付锁的钱
h ^ (h >>> 16) 补上,成本几乎为零。所以“为什么扩容是 2 倍”,答案是:只有翻倍才能保持 2 的幂,保住这两个位运算的好处;负载因子 0.75 则是在“浪费空格”和“碰撞变多”之间取的折中。④-6自己放几个 key 试试
模拟和 JDK 17 一致的规则:整数 key 的 hashCode 就是它自己,扰动、按位与、负载因子 0.75、尾插、链表到 9 个尝试树化、数组小于 64 时改为扩容、拆分后不超过 6 个退回链表。模拟逻辑在 node 里跑过,“放 1~13”和“同一个桶”两个预设的结果和 ⑤ 的实测一致。
最少语法
Map<Long, Product> catalog = new HashMap<>(134); // 预计 100 个:100 / 0.75 + 1
catalog.put(88L, keyboard); // 返回旧值,没有就是 null
catalog.get(88L); catalog.getOrDefault(99L, NONE); catalog.containsKey(88L);
userOrders.computeIfAbsent(1024L, k -> new ArrayList<>()).add(order);
stockDelta.merge(88L, -2, Integer::sum); // 有就合并,没有就放
catalog.putIfAbsent(88L, keyboard);
for (Map.Entry<Long, Product> e : catalog.entrySet()) { … } // 遍历用 entrySet
// key 的类型:equals 和 hashCode 必须一起写;record 自动生成
record SkuKey(long skuId, String warehouse) {}
初始容量为什么是 134 不是 100、为什么不能多线程写、迭代顺序:放到第⑥步。
⑤亲眼看到:交易靶场的 catalog 和 userOrders
用反射直接看 HashMap 内部的 table 数组(要加 --add-opens java.base/java.util=ALL-UNNAMED,全景第 4 站也是这么看的)。6 段:扩容时机、第 4 站的 88 和 344、一个桶从链表到树、坏 hashCode 有多慢、第 8 站并发写、可变 key。完整代码:study-labs/java/javase/hashmap-internals →
// 1. 一个个放,记下桶数组什么时候变
HashMap<Long, Product> catalog = new HashMap<>();
for (long id = 1; id <= 100; id++) {
catalog.put(id, p(id));
// Peek.capacity 用反射读 table.length
}
// 2. 88 和 344 撞到同一个桶,加到 201 个后拆开
catalog.put(344L, p(344));
for (long id = 101; id <= 200; id++) catalog.put(id, p(id));
// 3. hashCode 写死成 42:全进一个桶
record BadKey(int id) implements Comparable<BadKey> {
public int hashCode() { return 42; }
public int compareTo(BadKey o) { … }
}
// 4. 同上但不实现 Comparable:BadKeyNoCmp
// 5. 两个线程各放 10 万个不同的用户
Map<Long, Long> m = new HashMap<>();
t1: for (u = 0; u < 100_000; u++) m.put(u, 9527L);
t2: for (u = 1_000_000; … ; u++) m.put(u, 9527L);
// 6. 拿可变的 List 当 key,放进去以后再改它
List<Long> cartKey = new ArrayList<>(List.of(88L));
cache.put(cartKey, "购物车报价 598.00");
cartKey.add(66L);
cache.get(cartKey);
$ java --add-opens java.base/java.util=ALL-UNNAMED \
-cp target/classes labs.javase.hashmap_internals.Demo
== 1. 什么时候扩容 ==
new HashMap<>() 之后:桶数组 0(还没分配)
放第 1 个后:桶数组 16,阈值 12
放第 13 个后:桶数组 32,阈值 24
放第 25 个后:桶数组 64,阈值 48
放第 49 个后:桶数组 128,阈值 96
放第 97 个后:桶数组 256,阈值 192
== 2. 全景第 4 站 ==
hashCode=88,扰动后 88,桶数组 256,下标 88,1 个节点
放进 344:下标 88,88 号的桶里:2 个节点,Node
加到 201 个:桶数组 512,88 → 88(1 个),344 → 344(1 个)
== 3. 一个桶越来越长 ==
第 1~8 个:桶数组 16,链表 1~8 个,Node
放第 9 个:桶数组 32,那个桶 9 个节点,Node
放第 10 个:桶数组 64,那个桶 10 个节点,Node
放第 11 个:桶数组 64,那个桶 11 个节点,TreeNode
== 4. hashCode 全一样,查 2 万次 ==
好的 hashCode(Integer) 1.3 ms
全撞 + Comparable 5.0 ms(两遍:5.0、9.1)
全撞 + 不可比较 4077.0 ms
== 5. 全景第 8 站:两个线程同时 put ==
应有 200000 个用户:
第 1 次 size() = 178400,遍历数出 165390
第 2 次 size() = 199972,遍历数出 200000
第 3 次 size() = 157585,遍历数出 182224
computeIfAbsent:线程抛出 ConcurrentModificationException
ConcurrentHashMap.computeIfAbsent:200000
== 6. 可变 key ==
改了 key 之后 get(同一个对象) → null;size 还是 1
第 3 段第 1~8 个的输出合并成了一行。第 4、5 段每次运行数字会变。第 5 段按遍历数:第 1 次少 34610 个,第 3 次少 17776 个,第 2 次没少但 size() 少算了 28 个。
⑥细节·规范·优化
注意事项:不知道就会出事故
- 现象
- 拿购物车的商品列表当缓存 key,之后往列表里加了一件,
get(同一个对象)返回 null,旧条目还占着内存。⑤ 第 6 段 - 根源
- 节点还躺在旧 hash 算出的桶里,新 hash 指向另一个桶;节点里还存着旧 hash,比对也过不去。
- 做法
- key 用不可变类型:Long、String、record(字段也要不可变,
List.copyOf)。
- 现象
- 两个线程各放 10 万个,3 次里 2 次少了 1.8 万~3.5 万个;3 次的
size()都和遍历数出来的对不上;computeIfAbsent 直接 ConcurrentModificationException。⑤ 第 5 段 - 根源
- “看桶是空的 → 放新节点”“++size”“扩容时搬节点”都是好几步,两个线程交错执行就互相覆盖:同时看到空桶,后写的盖掉先写的;扩容时一边在搬,另一边往旧数组里放,放进去的就丢了。
- 做法
- 共享就用
ConcurrentHashMap(J02 讲它怎么做到);或者只在一个线程里写。Collections.synchronizedMap能用,但整个 Map 一把锁。
- 现象
- 以为给了 100 就够了。实测 容量被调成 128,阈值 96,放到第 97 个扩到 256。
- 根源
- 构造参数是“桶数组长度”,会被向上取成 2 的幂,而阈值只有它的四分之三。
- 做法
- 放 n 个就给
n / 0.75 + 1(100 个给 134,实测一开始就是 256,不再扩容);JDK 19 起直接HashMap.newHashMap(100);Guava 的Maps.newHashMapWithExpectedSize同理。
- 现象
- hashCode 全一样的 2 万个 key,查 2 万次:可比较时 5 ms,不可比较时 4077 ms,慢 800 倍。⑤ 第 4 段
- 根源
- 红黑树要靠“比大小”决定往左还是往右。hash 一样又不能比较时,插入只好用
System.identityHashCode随便定个顺序,查找时却不知道往哪边走,只能两边都找,退化成 O(n)。 - 做法
- hashCode 用全部参与 equals 的字段算(record 或
Objects.hash);会大量做 key 的类型顺手实现 Comparable。
- 现象
- 两个 equals 相等的 SkuKey,
put了一个,用另一个get拿不到;HashSet 里出现“重复”元素。 - 根源
- 默认 hashCode 是对象身份,两个对象算出不同的下标,根本走不到 equals 那一步。
- 做法
- 两个一起写,或者直接用 record;IDE 和 Sonar 都能检查。
- 现象
- 测试里接口返回的 JSON 字段顺序固定,数据一多、或者换了 JDK,顺序变了,断言失败、签名对不上。
- 根源
- 遍历是按桶下标走的,下标取决于容量和 hash;扩容一次,顺序就可能变。
- 做法
- 要插入顺序用 LinkedHashMap,要按 key 排序用 TreeMap。
使用规范
new HashMap<>(n * 4 / 3 + 1) 或 JDK 19 的 HashMap.newHashMap(n)。
一路扩容的搬迁和临时垃圾。
优先用 Long、String、枚举、record。自己写就用 Objects.hash(全部字段),字段和 equals 用的完全一致。
坑 1、坑 5。
computeIfAbsent、merge、putIfAbsent 代替 if (!containsKey) put,少一次定位,语义也清楚。
多余的哈希计算,以及换成 ConcurrentHashMap 后的竞态。
JDK 9 起,在 mapping 函数里再往同一个 HashMap 放东西会抛 ConcurrentModificationException。
递归缓存写法踩坑(典型是递归算斐波那契)。
哪怕“只是偶尔写一下”。它不允许 null key 和 null value。
坑 2。
keySet 再 get,每个 key 多一次定位。
无谓的 hash 计算和 equals。
深处:面试官会接着问的
JDK 7 的 HashMap 为什么会死循环
JDK 7 往链表里放新节点用头插,扩容搬节点时也一个个头插到新桶,于是链表顺序被反转。两个线程同时扩容同一个桶:线程 A 搬了一半被挂起,线程 B 搬完,链表顺序已经倒过来;A 醒来接着按旧的 next 指针搬,就可能把两个节点互相指成一个环。之后任何 get 落到这个桶,就在环里转圈,CPU 100%。
JDK 8 改成尾插,扩容按 lo / hi 两条链保持原顺序,不再成环;但⑤ 已经实测,数据照样丢。所以结论没变:多线程别用 HashMap。
JDK 7 和 8 的差别
| 结构 | 7:数组 + 链表;8:数组 + 链表 + 红黑树 |
|---|---|
| 插入 | 7:头插;8:尾插 |
| 扰动 | 7:4 次移位异或;8:一次 h ^ (h >>> 16) |
| 扩容 | 7:每个节点重新算下标;8:看 hash & oldCap 拆成两条 |
| 最坏查找 | 7:O(n);8:O(log n)(key 可比较时) |
| JDK 9+ | computeIfAbsent 等检测到函数里改了 Map 就抛 CME;JDK 19 加 newHashMap(n);8 到 21 的结构没有再变 |
哈希碰撞攻击,和树化真正防的东西
2011 年底公开的一类攻击:String 的 hashCode 是公开算法,攻击者可以构造成千上万个 hashCode 相同的参数名,一个 POST 请求就让服务器在解析表单时把 HashMap 变成一条超长链表,O(n²) 吃满 CPU。
JDK 8 的树化就是兜底:String 实现了 Comparable,即使 hash 全撞,红黑树也能按字符串大小查,最坏 O(log n)。⑤ 第 4 段的 5 ms 对 4077 ms,说的就是这个差别。容器层面也会限制参数个数(Tomcat 的 maxParameterCount)。
ConcurrentHashMap 一句话
桶结构和本页一样(数组 + 链表 + 红黑树,2 的幂,扩容翻倍)。区别在并发控制:空桶用 CAS 放第一个节点,非空桶只锁那个桶的头节点;扩容时多个线程一起搬,读不加锁。所以⑤ 里它一个都没丢。细节在 J02,以及补给“HashMap 与 ConcurrentHashMap 源码”(待做)。
说一下 HashMap 的 put 过程。
按④-2 的 9 步说:算 hash(扰动)→ 表空则初始化 → 定位桶 → 空桶直接放 → 比首节点 → 树节点走树插入 → 沿链表找相等的 key 或挂到尾巴,到 9 个尝试树化(数组小于 64 则扩容)→ 已存在就替换并返回旧值 → 新增则 modCount++、++size 超过阈值就扩容 → 返回 null。
为什么容量是 2 的幂?扩容为什么是 2 倍?
2 的幂让 hash % n 变成 hash & (n−1);翻倍后还是 2 的幂,扩容时每个节点只看 hash & oldCap 一位,留在原位或搬到原位 + oldCap,不用重新算(④-5)。
为什么要扰动?为什么是异或高 16 位?
下标只用 hash 的低几位,高位的差别用不上。异或把高位信息带到低位,一次移位一次异或,几乎零成本(④-1)。
什么时候树化?为什么是 8,为什么还有 64 和 6?
链表到 9 个(源码 binCount >= 7)且数组不小于 64;数组小就先扩容。8 是按泊松分布几乎碰不到的长度;6 留出回退的余量,避免来回变(④-4)。
HashMap 线程安全吗?JDK 7 和 8 出的问题一样吗?
不安全。JDK 7 并发扩容可能成环,get 死循环;JDK 8 尾插不成环,但仍然丢数据、size 不准,computeIfAbsent 可能抛 CME(⑤ 第 5 段,⑥-3)。
- ConcurrentHashMap:CAS 放首节点、锁桶头、多线程协助扩容、
sizeCtl和CounterCell(J02,补给待做页) - 红黑树本身:插入后的旋转和变色,
TreeNode为什么还保留 next 指针 - LinkedHashMap 的 LRU、WeakHashMap 和内存泄漏:已做成「HashMap 的亲戚们」
- 开放寻址的 ThreadLocalMap:为什么它用线性探测,和 ThreadLocal 内存泄漏(同一页)
↺回到⑤:过关题,以及改造练习
过关题 1:讲 put 的全过程
合上页面,在纸上画出④-2 的 9 步,每一步说出“为什么要这一步”。然后打开 ⑤ 的 Demo,在 catalog.put(344L, …) 处打断点,单步进 HashMap.putVal,看你画的分支对不对。
讲出来以后告诉我,地图上这一项可以标成“讲出”
过关题 2:讲扩容为什么是 2 倍
用 88 和 344 举例,说清:长度 256 时它们为什么在同一格,扩到 512 时为什么一个不动、一个搬到 344,如果扩成 3 倍(768)会失去什么。
用到:④-3、④-5
改造:给 catalog 定容量、换并发 Map
把 catalog 改成 new HashMap<>(134),用 Peek 确认放 100 个不再扩容;把第 5 段的 HashMap 换成 ConcurrentHashMap,跑 3 次,确认一个不少。
用到:坑 2、坑 3
自测
1. 用一句话说出 HashMap 的精华。
把任意 key 用“hashCode → 扰动 → 与上长度减一”换算成数组下标,碰撞挂链表、太长变树;长度保持 2 的幂,让定位和扩容都只是位运算。
2. 不用哈希,按 key 找有什么老办法?疼在哪?
挨个比 O(n);排序后二分 O(log n) 且要求可比较;直接当下标只适用于小范围整数(②)。
3. 改了 key 以后取不出来,根源是哪条原理?
下标由 hash 决定,节点还存着旧 hash,留在旧桶里;新 hash 指向别的桶(④-1,坑 1)。
4. 预测:把⑤第 3 段的 BadKey 放进 new HashMap<>(64),第几个会变成 TreeNode?
第 9 个。数组一开始就是 64(第一次 put 时按 64 分配,阈值 48),链表挂到第 9 个时 treeifyBin 发现数组不小于 64,直接树化,不再扩容(④-2 第 6 步、④-4)。
5. 预测:一个 key 的 hashCode 是 1000,桶数组从 16 扩到 32,它从哪个下标搬到哪个下标?
1000 的二进制是 11 1110 1000。扰动后不变(只有低位)。16 时看低 4 位 1000 → 下标 8;32 时多看第 5 位(值 16),1000 & 16 = 0,所以留在 8。若是 1016(多一个 16),就会搬到 8 + 16 = 24(④-3)。
6. 放回全景:第 4 站前后各发生了什么?这一页讲的东西坏了,整条链路会怎样?
前面是第 3 站进入 Spring 代理、开了事务;后面是第 5 站拿着查到的 Product new 出 Order。如果商品 key 用了可变对象或没写 hashCode,第 4 站 get 返回 null,第 5 站之前就抛“没有商品 88”,用户看到 400;如果 hashCode 全撞又不可比较,第 4 站从几十微秒变成几毫秒,所有下单接口一起变慢。第 8 站的 userOrders 被多个 Tomcat 线程同时写,会悄悄丢掉用户的订单记录,“我的订单”里少一单,日志里什么都没有。