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 里放,会丢数据。

全景第 4 站 · catalog.get(88L) 88Lkey hashCode()= 88 扰动 h ^ (h>>>16)= 88 (256 − 1) & 88下标 = 88 table[88]equals 比对 → 机械键盘 不管有 100 个还是 100 万个商品,都是这 4 步:一次方法调用、两次位运算、一次数组访问、桶里一两次 equals。 全景第 8 站 · 多线程同时 put 两个线程各放 10 万个用户,HashMap 3 次里 2 次真丢了数据,最多少了 3.4 万个,3 次的 size() 都不对(实测);computeIfAbsent 直接抛 ConcurrentModificationException。 为什么会这样,⑥ 第一圈讲;换成什么,J02 讲。
需求J01 的过关题:不查资料讲清 HashMap put 的全过程,以及扩容为什么是 2 倍。所以④把 put 拆成一张流程图,把“为什么是 2 的幂”讲到能推出来,↺ 里直接给你练这两题。

②没有它会怎样:数组快,但 key 不是下标

挨个比List

10 万单里找用户 1024,要看 10 万次。上一页 实测 查 1 万次 1477 ms。

直接拿 key 当下标long[] 开到最大

商品号 88 就放 arr[88],一步到位。可用户 id 可能是 10 亿、请求号是字符串,数组开不了那么大,也没法拿字符串当下标。

排好序二分TreeMap

O(log n),100 万个要比 20 次,还要求 key 能比大小。不需要有序时,这 20 次是白花的。

不懂精华时的典型误用:重写了 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

桶数组长 16 时,下标只看最低 4 位。如果 key 的差别全在高位呢? key = 65536 × 3 = 196608 hashCode 0000 0000 0000 0011 0000 0000 0000 0000 h >>> 16 0000 0000 0000 0000 0000 0000 0000 0011 异或后 0000 0000 0000 0011 0000 0000 0000 0011 & (16−1) 0011 → 下标 3 不扰动:只看低 4 位 65536 的 1~12 倍,低 16 位全是 0 12 个 key 全进 第 0 格,一条 12 长的链 扰动:高 16 位异或进低 16 位 下标分别是 1、2、3 … 12(javac 17 实测算出) 12 个 key 进 12 个不同的格子
JDK 17 源码 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 的全过程(过关题第一问)

1 hash = h ^ (h >>> 16),null 为 0 2 table 为空?→ resize() 建 16 格 3 i = (n − 1) & hash,看 table[i] 空 → table[i] = newNode,去 8 4 比第一个节点:hash 相等,且 == 或 equals?→ 记下它,去 7 5 第一个是 TreeNode → putTreeVal 6 沿链表走:遇到相等的 key → 记下,去 7走到尾 → 挂在尾巴(JDK 8 起尾插)链表到 9 个 → treeifyBin treeifyBin:数组 < 64 → 改为扩容否则把这条链变成红黑树 7 已有这个 key:换掉 value,返回旧值(size、modCount 都不动) 8 新增:modCount++++size > threshold?→ resize()翻倍,高低位拆分(④-3) 9 返回 null(之前没有这个 key)
对着 JDK 17 putVal 画的。第 6 步“到 9 个”来自源码 binCount >= TREEIFY_THRESHOLD − 1:binCount 从第一个节点后面开始数,条件成立时新节点已经挂上,链表正好 9 个。常说的“链表长度超过 8 就树化”指的就是这个,⑤ 里实测也是第 9 个才触发。

④-3扩容:翻倍,每个节点只看一位就知道去哪

256 → 512:下标从看低 8 位,变成看低 9 位 88 = 0 0101 1000 344 = 1 0101 1000 ← 低 8 位一样,所以长 256 时同在 88;多出来的第 9 位(hash & 256)不一样 扩容前 table[88]88 → 344 低位链:(hash & 256) == 088 → 留在 table[88] 高位链:(hash & 256) != 0344 → 搬到 table[88 + 256] = table[344] 为什么不用重新算下标 新下标 = hash & (2n − 1),比旧下标只多看 一位。那一位是 0 就不变,是 1 就加 n。 一个桶只拆成两条链,顺序不变, 也就不会出现 JDK 7 那种成环(⑥-3)。 实测 商品 100 个时桶数组 256,88 和 344 挤在 table[88];加到 201 个超过阈值 192,扩到 512,88 留在 88,344 搬到 344,各剩 1 个节点。
阈值 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,单线程也付锁的钱
决定胜负的约束:HashMap 的 get / put 是 Java 里调用最频繁的操作之一,每次都要“定位”。2 的幂让定位从除法变成一次按位与,翻倍又让扩容只需看一位。它唯一的弱点“只用低位”,用一次扰动 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 →

    1Demo.java 节选,JDK 17
    // 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);
    2运行 JDK 17.0.20,2 核 ARM
    $ 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 个。

    到这里已经能交差了:你亲眼看到了第 4 站那行输出背后的每一步:扩容的时机、碰撞和拆分、树化要先等数组长到 64;也看到了第 8 站的雷到底有多响。
    已知还没处理的细节:可变 key(⑥-1)、多线程写(⑥-1)、初始容量算错(⑥-1)、坏 hashCode(⑥-1)、JDK 7 的死循环(⑥-3)。

    ⑥细节·规范·优化

    第一圈

    注意事项:不知道就会出事故

    坑 1放进去以后改了 key,再也取不出来← ④-1 下标由 hash 决定
    现象
    拿购物车的商品列表当缓存 key,之后往列表里加了一件,get(同一个对象) 返回 null,旧条目还占着内存。⑤ 第 6 段
    根源
    节点还躺在旧 hash 算出的桶里,新 hash 指向另一个桶;节点里还存着旧 hash,比对也过不去。
    做法
    key 用不可变类型:Long、String、record(字段也要不可变,List.copyOf)。
    坑 2多个线程同时写,丢数据、size 不准、抛异常← ④-2 put 不是原子的
    现象
    两个线程各放 10 万个,3 次里 2 次少了 1.8 万~3.5 万个;3 次的 size() 都和遍历数出来的对不上;computeIfAbsent 直接 ConcurrentModificationException。⑤ 第 5 段
    根源
    “看桶是空的 → 放新节点”“++size”“扩容时搬节点”都是好几步,两个线程交错执行就互相覆盖:同时看到空桶,后写的盖掉先写的;扩容时一边在搬,另一边往旧数组里放,放进去的就丢了。
    做法
    共享就用 ConcurrentHashMap(J02 讲它怎么做到);或者只在一个线程里写。Collections.synchronizedMap 能用,但整个 Map 一把锁。
    坑 3new HashMap<>(100) 放 100 个,还是扩容了← ④-3 阈值 = 容量 × 0.75
    现象
    以为给了 100 就够了。实测 容量被调成 128,阈值 96,放到第 97 个扩到 256。
    根源
    构造参数是“桶数组长度”,会被向上取成 2 的幂,而阈值只有它的四分之三。
    做法
    放 n 个就给 n / 0.75 + 1(100 个给 134,实测一开始就是 256,不再扩容);JDK 19 起直接 HashMap.newHashMap(100);Guava 的 Maps.newHashMapWithExpectedSize 同理。
    坑 4hashCode 写得差,还不可比较:查一次要走遍整棵树← ④-4 树化靠比较
    现象
    hashCode 全一样的 2 万个 key,查 2 万次:可比较时 5 ms,不可比较时 4077 ms,慢 800 倍。⑤ 第 4 段
    根源
    红黑树要靠“比大小”决定往左还是往右。hash 一样又不能比较时,插入只好用 System.identityHashCode 随便定个顺序,查找时却不知道往哪边走,只能两边都找,退化成 O(n)。
    做法
    hashCode 用全部参与 equals 的字段算(record 或 Objects.hash);会大量做 key 的类型顺手实现 Comparable。
    坑 5只重写了 equals,没重写 hashCode← ④-1 先 hash 后 equals
    现象
    两个 equals 相等的 SkuKey,put 了一个,用另一个 get 拿不到;HashSet 里出现“重复”元素。
    根源
    默认 hashCode 是对象身份,两个对象算出不同的下标,根本走不到 equals 那一步。
    做法
    两个一起写,或者直接用 record;IDE 和 Sonar 都能检查。
    坑 6依赖 HashMap 的遍历顺序← ④-3 扩容会拆分
    现象
    测试里接口返回的 JSON 字段顺序固定,数据一多、或者换了 JDK,顺序变了,断言失败、签名对不上。
    根源
    遍历是按桶下标走的,下标取决于容量和 hash;扩容一次,顺序就可能变。
    做法
    要插入顺序用 LinkedHashMap,要按 key 排序用 TreeMap。
    第二圈

    使用规范

    知道数量就给容量

    new HashMap<>(n * 4 / 3 + 1) 或 JDK 19 的 HashMap.newHashMap(n)。

    一路扩容的搬迁和临时垃圾。

    key 不可变,equals 和 hashCode 成对

    优先用 Long、String、枚举、record。自己写就用 Objects.hash(全部字段),字段和 equals 用的完全一致。

    坑 1、坑 5。

    用复合操作,不要先查再放

    computeIfAbsent、merge、putIfAbsent 代替 if (!containsKey) put,少一次定位,语义也清楚。

    多余的哈希计算,以及换成 ConcurrentHashMap 后的竞态。

    computeIfAbsent 的函数里别改同一个 Map

    JDK 9 起,在 mapping 函数里再往同一个 HashMap 放东西会抛 ConcurrentModificationException。

    递归缓存写法踩坑(典型是递归算斐波那契)。

    跨线程共享就用 ConcurrentHashMap

    哪怕“只是偶尔写一下”。它不允许 null key 和 null value。

    坑 2。

    遍历用 entrySet,不用 keySet 再 get

    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)。

    下一圈

    ↺回到⑤:过关题,以及改造练习

    过关题 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 线程同时写,会悄悄丢掉用户的订单记录,“我的订单”里少一单,日志里什么都没有。