首页 / Java 学习笔记 / 02

JAVA · Vol.I · DAY 02 · 集合框架源码

HashMap 高频面试题全解:null key、遍历顺序、线程安全

中级必问#集合#面试#核心
第 1 站

为什么 HashMap 是面试"钉子户"?

如果 Java 面试只能考一道集合题,那一定是 HashMap。我在面试和被面试的十几年里,几乎没有遇到过不考 HashMap 的 Java 岗位。原因很简单——一个 HashMap 能把数据结构、哈希算法、并发安全、JDK 源码设计全串起来,面试官用一个类就能摸清你的功底。

"HashMap 为什么允许 null key?遍历顺序为什么不保证?多线程下 HashMap 会出什么问题?" —— 这三个追问几乎出现在每一场 Java 中高级面试中。你能把每一个"为什么"讲出源码依据吗?

但大多数人准备 HashMap 面试的方式是"背八股":数组+链表+红黑树、负载因子 0.75、扩容翻倍……这些没错,但面试官想听的不是背诵,而是你能把这些设计决策串成一条逻辑链——为什么是这个结构?为什么 null 被特殊对待?为什么遍历顺序会变?为什么多线程会出事?

后端类比:HashMap 就是一张"内存版分库分表"

HashMap 的 table[] 数组,等价于分库分表的 shard 路由表(n-1) & hash 就是路由算法,桶下标就是 shard 号,链表/红黑树就是每个 shard 内部的冲突处理。面试官问 HashMap 的"为什么",本质上是在考你能否把一个哈希表的每个工程决策讲出 trade-off——这正是后端架构面试的核心能力。

本文不讲 put 流程的每一行细节,我们聚焦面试中最高频的三个追问方向,逐问给出结论 → 源码依据 → 面试话术,并补齐扩容 rehash、JDK7 成环、JDK8 覆盖丢失这些"追问深处"的硬核内容。

本篇路线图

  1. 数据结构(第 2~3 站):null key 为什么落桶 0、遍历顺序为什么无序
  2. 核心流程(第 4 站):扩容与 rehash——顺序"变脸"的根源
  3. 并发剖析(第 5~7 站):线程不安全根因、JDK7 成环、JDK8 覆盖丢失
  4. 面试与落地(第 8~12 站):连环追问话术、陷阱题、生产最佳实践、选型对比
第 2 站

null key:HashMap 为什么"网开一面"?

面试中经常被问到:"HashMap 和 ConcurrentHashMap 都基于哈希表,为什么前者允许 null key,后者不允许?"——这不是随意的设计,背后有清晰的工程权衡。先给结论:HashMap 允许 null key,是因为 hash(null) 直接返回 0,null key 等价于一个 hash 为 0 的普通 key,永远落在 0 号桶。

hash() 方法:一行代码定乾坤

HashMap.hash() · null key 的特殊处理 + JDK8 扰动函数
static final int hash(Object key) {
    int h;
    // key == null → 直接返回 0,不调用 hashCode(),无空指针风险
    return (key == null) ? 0
         : (h = key.hashCode()) ^ (h >>> 16);  // 高 16 位与低 16 位异或,让高位参与散列
}

注意一个细节:JDK 1.8 把扰动从四次减为一次。JDK 1.7 的 hash() 里是 h ^= (h >>> 20) ^ (h >>> 12); return h ^ (h >>> 7) ^ (h >>> 4) 四轮扰动,JDK 1.8 认为经过红黑树优化后碰撞代价已可接受,简化成一轮 h ^ (h >>> 16)。这是面试常考的"版本差异"细节。

key == null 时:

  • null key 的桶下标永远是 0 & (n-1) = 0,即 0 号桶;而且无论容量 n 怎么扩(16、32、1024……),0 & (n-1) 恒等于 0——null key 扩容后依然留在桶 0,这一点可以当追问的加分项
  • null key 不需要调用 hashCode(),所以不存在空指针风险
  • get(null) 时同样走 hash(null) → 0 → 0 号桶,能正确找到值
null key 永远落在 0 号桶 table[] 数组 [0] [1] [2] ... [n-1] hash = 0 key=null, value=... hash(null) 返回 0 0 & (n-1) = 0
图 1null key 的 hash 值为 0,永远存储在 table[0] 桶中(扩容后仍在桶 0)

方法级源码:null key 在 put / get 里走的是"普通流程"

很多人以为 HashMap 对 null 有专门的分支。实际上 putVal() / getNode()没有任何 null 特判——因为 hash 已经是 0 了,null key 就是"一个 hash 为 0 的普通 key":

HashMap.putVal() · 摘录:null key 与普通 key 走同一段代码
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;                 // 懒加载:第一次 put 才建表
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);  // 桶空 → 直接挂新节点(null key 也走这里)
    else {
        // 桶非空:链表/红黑树查找,命中则覆盖 value
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            ...
    }
}

注意命中判断里第一个条件是 p.key == key(引用比较)——当 key 为 null 时,只要桶里节点也是 null key,引用比较直接命中,根本不需要调用 equals()。再看 get 侧:

HashMap.getNode() · get(null) 的正确性来源
final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {   // 定位桶
        if (first.hash == hash && ((k = first.key) == key
            || (key != null && key.equals(k))))        // null key:引用比较命中
            return first;
        if ((e = first.next) != null) { // 桶内继续:链表或红黑树
            ...
        }
    }
    return null;   // 表为空 / 桶为空 / 没找到 → 返回 null
}

为什么 HashMap 选择允许 null?为什么 CHM / Hashtable 拒绝?

允许 null 不是技术限制,而是设计哲学(Joshua Bloch 的取舍):

  • 便利性:很多业务场景中 null 是一个有意义的值——"用户未选择""配置项缺失""缓存未命中"。允许 null key 让开发者可以直接 map.put(null, defaultValue),省去额外的判空逻辑
  • 单线程场景无歧义:HashMap 设计初衷是单线程使用,null 不会带来语义混乱
  • hash 函数做了兜底:null → 0 的映射简单明确,不会引入 bug
ConcurrentHashMap.putVal() · 直接拒绝 null
final V putVal(K key, V value, boolean onlyIfAbsent) {
    if (key == null || value == null)
        throw new NullPointerException();  // 没有任何商量
    ...
}

Doug Lea(ConcurrentHashMap 作者)在邮件列表中解释过这个设计决策;Hashtable 同理(putvalue == null 抛 NPE,key 为 null 时调用 key.hashCode() 也会 NPE):

null 在并发环境中有二义性

在单线程的 HashMap 中,如果 map.get(key) 返回 null,你可以再调 map.containsKey(key) 来区分"key 不存在"和"value 是 null"。但在多线程环境下,两次调用之间其他线程可能已经修改了 map,所以 containsKey 的结果不可信。

为了避免这种无法排查的歧义,ConcurrentHashMap 选择了最简单粗暴的方案:禁止 null key 和 null value,从源头消除二义性。这也是《Java 并发编程实战》里明确推荐的思路:不要让调用方去猜"null 到底是什么意思"

面试话术:null key 一题的标准答法
  • 结论:HashMap 允许 null key,因为 hash(null) 直接返回 0,null key 存储在 0 号桶;HashMap 允许 null value,因为单线程下 get() 返回 null 可以用 containsKey 区分
  • 源码依据hash() 三目运算符对 null 返回 0;putVal/getNode 里 null key 靠 p.key == key 引用比较命中,无空指针;扩容后 0 & (n-1) = 0 仍落桶 0
  • 对比补充:ConcurrentHashMap / Hashtable 不允许 null key/value,因为并发环境下 get() 返回 null 有二义性(key 不存在 vs value 为 null),且无法通过 containsKey 二次确认
第 3 站

遍历顺序:为什么 HashMap "不守规矩"?

"我按 A、B、C 的顺序 put 进 HashMap,为什么遍历出来是 B、A、C?" —— 这是新手最常踩的坑,也是面试官考察"你到底是背结论还是懂原理"的试金石。

答案藏在 HashMap 的哈希分布机制里。先给结论:HashMap 不保证遍历顺序——元素在数组中的位置取决于 hash & (n-1) 而不是插入顺序;遍历器按桶下标从 0 扫到 n-1,所以输出顺序本质是"桶下标序";扩容 rehash 后桶位重排,顺序还会再变。

方法级源码:位置是"算"出来的,不是"排"出来的

Demo.java · 插入顺序 ≠ 遍历顺序
Map<String, Integer> map = new HashMap<>();
map.put("apple",  1);
map.put("banana", 2);
map.put("cherry", 3);
map.put("date",   4);

// 遍历:从 table[0] 开始逐桶扫描,桶内按链表/树顺序
// 结果取决于每个 key 的 (n-1) & hash —— 可能是 banana, apple, date, cherry
for (String key : map.keySet()) {
    System.out.println(key + " → " + map.get(key));
}

散列分布决定了落桶位置:

桶下标 = (n - 1) & hash(key)
例如容量 16(n-1 = 1111₂):
"apple".hashCode() = 0x6A9B7CDE → 扰动后 hash → 1111 & hash → 桶 14
"banana" 扰动后 hash → 1111 & hash → 桶 1
所以遍历输出里 banana 一定在 apple 之前——不是因为字母序,而是因为它的桶下标更小。

迭代器(KeyIterator / EntryIterator)的扫描逻辑在 HashMap.HashIterator.hasNextNode()table[0] 出发,跳过空桶,遇到非空桶就顺着链表/红黑树把该桶所有节点吐完,再移到下一个桶。JDK 1.8 尾插法下,同一桶内的链表顺序 = 插入顺序,但桶与桶之间完全由 hash 决定。

两个容易踩的"假象"

假象一:"我测了两次顺序都一样,是不是保证的?"

HashMap 的规范只承诺"不保证顺序",不承诺"每次必变"。给定相同的 key 集合、相同的容量、同一个 JDK 版本(String 的 hashCode 是确定的),遍历结果通常是可复现的——但这属于实现细节,不是规范保证。一旦发生扩容(rehash)、换了 JDK 版本、或 hashSeed 机制生效,顺序就可能变。生产代码如果把业务逻辑依赖在 HashMap 遍历顺序上,就是埋雷。

假象二:"key 是 Integer 时遍历看起来有序?"

当 key 是 0~15 的小整数且容量为 16 时,(n-1) & hash = key,遍历恰好输出 0,1,2,3… 看起来"有序"——这是数字太小、没有碰撞的巧合,不是 HashMap 保证了顺序。容量一变或数字一多,立刻打回原形。

遍历还有一个隐藏约束:fail-fast

HashMap 有 transient int modCount 字段记录结构性修改次数(put 新 key、remove、clear 都会 ++modCount,覆盖已有 key 不算)。迭代器创建时快照 expectedModCount,每次 next() 前检查 modCount != expectedModCount 就抛 ConcurrentModificationException。这意味着:遍历过程中不能修改结构(哪怕是单线程),否则直接异常——这是"快速失败"机制。

三种 Map 的遍历顺序对比 HashMap 按桶下标顺序遍历 [1] banana [3] apple [5] date [7] cherry 顺序:不保证 扩容后顺序可能再变 性能最优 O(n) LinkedHashMap 按插入顺序遍历 1st apple 2nd banana 3rd cherry 4th date 双向链表维护顺序 额外内存开销 ~30% LRU 缓存首选 TreeMap 按 key 排序遍历 apple (a) banana (b) cherry (c) date (d) 红黑树自然排序 put/get O(log n) 需要排序时用
图 2HashMap(无序)、LinkedHashMap(插入序)、TreeMap(排序)三种遍历行为对比

LinkedHashMap 如何记住插入顺序?

LinkedHashMap 继承自 HashMap,在 HashMap 的 Node 基础上额外维护一条双向链表。每次 put 新元素时,除了照常放入哈希桶,还会把新节点追加到双向链表尾部。遍历 LinkedHashMap 时,走的是双向链表而不是哈希桶数组,所以输出顺序 = 插入顺序。

LinkedHashMap.Entry · 额外的前后指针
static class Entry<K,V> extends HashMap.Node<K,V> {
    Entry<K,V> before, after;  // 双向链表的前后指针
    Entry(int hash, K key, V value, Node<K,V> next) {
        super(hash, key, value, next);
    }
}

LinkedHashMap 还有一个杀手级特性:accessOrder 模式。构造时传 accessOrder = true,每次 get/put 都会把被访问的节点移到链表末尾(afterNodeAccess()),配合 removeEldestEntry() 就能实现 LRU 缓存——LinkedHashMap 的链表在扩容 rehash 后顺序依旧保持,因为它独立于桶数组维护。

面试话术:遍历顺序一题的标准答法
  • 结论:元素落桶位置由 (n-1) & hash 决定而非插入顺序;迭代器按桶下标 0→n-1 扫描,所以遍历无序;扩容 rehash 重排桶位后顺序可能再变
  • 源码依据putValtab[i = (n - 1) & hash] 决定落桶;HashIterator.hasNextNode() 逐桶扫描;modCount 支撑 fail-fast(遍历中改结构抛 ConcurrentModificationException)
  • 补充加分:"不保证"≠"每次必变";Integer 小 key 的"有序"是巧合;需要顺序用 LinkedHashMap(插入/访问序)或 TreeMap(排序)
第 4 站

扩容与 rehash:遍历顺序"变脸"的根源

"HashMap 什么时候扩容?扩容多少倍?扩容时所有元素都要重新 hash 吗?为什么 ArrayList 是 1.5 倍、HashMap 是 2 倍?" —— 这一串问题能把"背八股"的人问穿。

触发条件与核心字段

先记住三个字段:threshold(扩容阈值 = 容量 × 负载因子)、loadFactor(默认 0.75f)、size(元素个数)。每次 put 新 key 成功后 ++size,一旦 size > threshold 就触发 resize(),容量翻倍。0.75 是时间与空间的折中:太小浪费内存,太大增加碰撞——这是教科书级的"空间换时间"权衡。

HashMap.putVal() · 尾部:扩容触发点
++modCount;                      // 结构性修改计数
if (++size > threshold)          // 超过阈值 → 扩容
    resize();
afterNodeInsertion(evict);       // 给 LinkedHashMap 的钩子(LRU)
return null;                     // put 返回旧 value,没有则 null

方法级源码:resize() 为什么是 2 倍

HashMap.resize() · 摘录:容量翻倍的关键分支
final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    if (oldCap > 0) {
        if (oldCap >= MAXIMUM_CAPACITY) {          // 1 << 30,极限保护
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY   // 左移一位 = ×2
                 && oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1;                         // 阈值也翻倍
    }
    ...
    // 新建 newTab = new Node[newCap],然后逐桶搬运(见下方拆分逻辑)
}

关键点:newCap = oldCap << 1,正好是 2 倍。为什么必须是 2 的幂?因为只有容量是 2 的幂时,才有下面三个收益:

  • 数学等价hash & (n-1) 等价于 hash % n,位运算比取模快一个数量级(% 是除法指令,& 是一条位运算指令)
  • 均匀分布:2 的幂减 1 的二进制全是 1(如 15 = 1111₂),hash 的每一位都参与定位,碰撞最小化
  • 扩容免重算:1.8 扩容时用 hash & oldCap 直接判断新位置(要么原地 j,要么 j + oldCap),省去重新 hash —— 这个优化依赖"oldCap 是 2 的幂"

方法级源码:JDK 1.8 的 lo / hi 链表拆分(免重 hash)

HashMap.resize() · 摘录:单桶链表的拆分搬运
for (int j = 0; j < oldCap; ++j) {
    Node<K,V> e;
    if ((e = oldTab[j]) != null) {
        oldTab[j] = null;                 // 顺手置空旧桶,帮助 GC
        if (e.next == null)               // 桶里只有一个节点
            newTab[e.hash & (newCap - 1)] = e;   // 直接算新位置
        else if (e instanceof TreeNode)
            ((TreeNode<K,V>)e).split(this, newTab, j, oldCap); // 红黑树拆分
        else {
            Node<K,V> loHead = null, loTail = null;  // 低位链:hash&oldCap==0
            Node<K,V> hiHead = null, hiTail = null;  // 高位链:hash&oldCap==1
            Node<K,V> next;
            do {
                next = e.next;
                if ((e.hash & oldCap) == 0) {      // 关键判断:新的一位是 0
                    if (loTail == null) loHead = e;
                    else loTail.next = e;               // 尾插法,保持原顺序
                    loTail = e;
                } else {
                    if (hiTail == null) hiHead = e;
                    else hiTail.next = e;
                    hiTail = e;
                }
                e = next;
            } while (e != null);
            if (loTail != null) { loTail.next = null; newTab[j] = loHead; }        // 原地
            if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; }  // +oldCap
        }
    }
}

为什么 hash & oldCap 能决定新位置?因为容量翻倍相当于下标二进制多了一位。原来 index = hash & (oldCap-1),现在 newIndex = hash & (2*oldCap-1)——多出的那一位正是 hash & oldCap:为 0 则留在原桶 j,为 1 则移到 j + oldCap这也是遍历顺序在扩容后"变脸"的数学根源

① 扩容前:容量 8,桶[3] = A → B → C ② 扩容后:容量 16 桶[3] A B C A.hash=3, B.hash=11, C.hash=3 hash & 7 全为 3 → 同桶 hash & oldCap(8):A=0, B=8, C=0 A、C:&8=0 B:&8=8 桶[3] A C 桶[11] B hash & 8 == 0 → 留在原桶 j(3) hash & 8 == 1 → 移到 j + oldCap(3+8=11)
图 3JDK 1.8 扩容:链表按 hash & oldCap 拆成 lo/hi 两段,尾插保持相对顺序,无需重新 hash

树化与退化:边界条件必须背准

字段/常量含义
TREEIFY_THRESHOLD8链表长度 ≥ 8 时尝试树化(先查容量)
MIN_TREEIFY_CAPACITY64容量 < 64 时不树化,先扩容(treeifyBin() 里的判断)
UNTREEIFY_THRESHOLD6扩容拆分后红黑树节点 ≤ 6,转回链表
DEFAULT_INITIAL_CAPACITY16默认容量 1 << 4
DEFAULT_LOAD_FACTOR0.75默认负载因子
MAXIMUM_CAPACITY1 << 30容量上限(约 10.7 亿)
为什么树化阈值是 8 / 64?为什么退化是 6?

JDK 源码注释给出依据:在随机 hash 下,桶内链表长度服从泊松分布,长度为 8 的概率约 千万分之六(0.00000006)——达到 8 说明 hash 分布已严重异常(如被恶意构造 collision 攻击),值得升级为红黑树把 O(n) 拉回 O(log n)。退化阈值 6 而不是 8,是为了留出 2 的差值缓冲,避免链表和树在阈值附近来回震荡(频繁转换是性能灾难)。8 和 6 之间隔一个 7,这就是工程上的"滞回区间"。

后端类比:HashMap ×2 vs ArrayList ×1.5

ArrayList 扩容是 newCapacity = oldCapacity + (oldCapacity >> 1),即 1.5 倍——因为它是"搬数组"(System.arraycopy 整块拷贝),搬一次成本高,扩多了浪费内存,1.5 倍是"均摊 O(1) 且内存浪费可控"的折中;HashMap 扩容是"逐桶搬运 + 重新定位",必须 2 倍才能用 hash & oldCap 免重 hash。所以"为什么是 2 倍"的答案核心不是性能习惯,而是2 的幂让位运算成立。顺带一提:HashMap 初始容量由 tableSizeFor() 保证是 2 的幂——cap-1 后不断 n |= n >>> 1/2/4/8/16,把最高位以下的位全填 1,最后 +1

面试话术:扩容一题的标准答法
  • 结论size > threshold 时触发 resize(),容量左移一位(×2),阈值同步 ×2;JDK 8 扩容用 hash & oldCap 拆 lo/hi 两段,节点要么留原桶要么移 j+oldCap,全程不重新调用 hashCode
  • 为什么 2 倍:容量必须是 2 的幂,(n-1) & hash 等价取模、掩码全 1 分布均匀、扩容免重 hash 三个收益都依赖它
  • 边界数据:树化 8 + 容量 64、退化 6、负载因子 0.75、最大容量 1<<30、ArrayList 1.5 倍对比——这些数字必须一字不差
第 5 站

线程安全总览:三个层次的问题与根因

"HashMap 不是线程安全的——那它具体会出什么问题?仅仅是数据丢失吗?" —— 面试官想听的不是"不安全"三个字,而是你能把问题分层、把根因讲到字段级别。

HashMap 在并发环境下至少有三个层次的问题,从轻到重:

问题 1:size 不准确

++size 不是原子操作(读 → 加 → 写三步),两个线程同时执行 put() 时,可能两次 ++size 只生效一次。结果就是 map.size() 比实际元素数少——服务端用 size 做分页/统计时会出现"对不上账"。

问题 2:数据覆盖(丢失更新)

线程 A 和线程 B 同时发现 table[i] == null,都执行了 tab[i] = newNode(...)。后写入的那个会覆盖先写入的——一个 key 被另一个不同 key 静默顶掉,数据悄悄丢了,没有任何异常。这是最隐蔽的:线上不会有报错,只有数据对不上。

问题 3:JDK 1.7 的死循环(CPU 100%)

这是最"经典"的问题(第 6 站展开)。JDK 1.7 使用头插法扩容,两个线程同时 resize 时,链表会被逆序重建,特定交错下会形成环形链表,后续所有经过这个桶的 get() 都变成死循环,CPU 打满。

根因:不是"某个版本有 bug",而是结构性的

一句话根因:HashMap 的读写是"多步非原子操作",且字段无可见性保证

看字段声明就知道——transient Node<K,V>[] table;transient int size;transient int modCount;没有 volatile,没有锁,没有任何同步机制。而 put() 至少包含"定位桶 → 判断桶空 → 写入数组 → size++"四步,每一步之间都可能被其他线程插入执行。原子性缺失(覆盖丢失)、可见性缺失(读到过期数据)、有序性缺失(重排序)三样占全了——所以结论是:HashMap 的线程不安全是结构性的,任何版本都救不了,唯一的解法是换并发容器

问题层次现象根因JDK 版本
size 不准size() 比实际少++size 非原子1.7 / 1.8 都有
数据覆盖丢失不同 key 互相顶掉,无异常并发 tab[i] = newNode 后写覆盖先写1.7 / 1.8 都有
扩容死循环get() 卡死,CPU 100%JDK 7 头插法并发 resize 成环仅 1.7(1.8 尾插修复)
读脏数据get 返回过期/中间态值table 无 volatile,无 happens-before1.7 / 1.8 都有

注意第三行:JDK 1.8 修复的是"成环死循环",不是"线程安全"。这是一个非常容易被面试官挖坑的点,下一站我们分别把 1.7 的环和 1.8 的覆盖讲到方法级。

第 6 站

JDK 1.7 头插成环:CPU 100% 的"经典名场面"

"你见过线上 CPU 100% 吗?怎么定位的?" —— 如果你能把 HashMap 死循环的成因讲到"头插法 + 两个线程各自持有旧引用"这一步,这一题就满分了。

先看 JDK 1.7 的 transfer():为什么头插会逆序

HashMap.transfer() · JDK 1.7 扩容搬运(头插法)
void transfer(Entry[] newTable, boolean rehash) {
    int newCapacity = newTable.length;
    for (Entry<K,V> e : table) {
        while (null != e) {
            Entry<K,V> next = e.next;          // ① 先记下下一个节点
            if (rehash) e.hash = ...;
            int i = indexFor(e.hash, newCapacity);
            e.next = newTable[i];            // ② 新节点的 next 指向当前桶头 → 头插
            newTable[i] = e;                 // ③ 当前节点成为新桶头
            e = next;                           // ④ 处理下一个
        }
    }
}

头插的代价:每搬一个节点都插到桶头,整条链表被逆序重建。单线程下这只是顺序问题,无伤大雅;但并发 resize 时,两个线程各自持有局部变量 enext,它们引用的却是同一批共享的 Entry 节点——灾难由此而来。

成环的三幕剧(必考细节)

假设桶 3 里是链表 A → B → C(A.next=B, B.next=C),两个线程 T1、T2 同时触发扩容:

① 扩容前:桶[3] = A → B → C 桶[3] A B C T1 挂起前:e = A, next = B 头插:e.next = newTable[i] → 逆序 T1 的 newTable 此刻还是空的 ② T2 完成扩容:桶[7] = C → B → A 桶[7] C B A 头插逆序重建:C → B → A 关键:B.next 已被 T2 改成 A T1 的局部变量 e、next 仍是旧值 ③ T1 恢复执行 → 成环 桶[7] A B A.next = B(T1 写入) B.next = A(T2 已改) → 环!get() 死循环,CPU 100%
图 4JDK 1.7 头插扩容并发成环三幕:T2 逆序改写了共享节点的 next,T1 恢复后把 A 插回桶头,A↔B 互指成环

第三幕的细节:T1 恢复后继续搬 A(e=A, next=B),执行 A.next = newTable1[7](此时为 null)→ 桶头=A;接着搬 B,此时 next = B.next 已经被 T2 改成了 A(不是原来的 C);B 入桶后 B.next = A;再处理 e=A 时 A.next = B。最终 A.next = B 且 B.next = A——环形链表成型。之后任何 get() 沿着环遍历,while (e != null) 永不退出,CPU 直接打满。

生产事故:这是真实发生过的线上事故

生产事故案例 · 全局缓存使用 HashMap
// 典型事故场景:全局缓存使用 HashMap(JDK 1.7 时代)
public class CacheService {
    // ❌ 错误:多线程环境用了 HashMap
    private Map<String, Object> cache = new HashMap<>();

    public void refreshCache(List<Config> configs) {
        // 多个线程同时调用 → 并发 put → 触发并发 resize → 成环
        configs.forEach(c -> cache.put(c.getKey(), c.getValue()));
    }
}

// 修复方案:替换为 ConcurrentHashMap(或扩容前加锁)
private Map<String, Object> cache = new ConcurrentHashMap<>();
面试追问:"JDK 1.8 修了什么?彻底安全了吗?"

1.8 改用尾插法p.next = newNode(...) 追加到链表尾部),扩容时按 lo/hi 两段原序搬运——链表不再被逆序重建,经典的成环路径被从源码层面堵死。这是事实。size 不准确、数据覆盖、扩容竞态、读脏数据依然存在。所以"JDK 1.8 的 HashMap 可以安全用在多线程环境"是错误结论。面试时被追问,一定要明确区分:"修复了死循环" ≠ "线程安全",两者差着一整个并发容器。

面试话术:死循环一题的标准答法
  • 结论:JDK 1.7 头插法扩容,并发 resize 时两个线程共享同一批 Entry,特定交错下 A↔B 互指形成环形链表,get() 死循环、CPU 100%
  • 源码依据transfer()e.next = newTable[i]; newTable[i] = e; 的头插三步;成环关键是 T2 逆序改写了 B.next,而 T1 的局部 e/next 还是旧引用
  • 版本对比:JDK 1.8 尾插 + lo/hi 原序拆分,从源码上消灭成环;但覆盖丢失、size 不准、可见性问题仍在,并发必须用 ConcurrentHashMap
第 7 站

JDK 1.8 数据覆盖丢失:不死循环 ≠ 线程安全

"JDK 1.8 的 HashMap 修复了死循环,那它是不是就线程安全了?" —— 这是面试里最经典的"诱导性提问",正确回答是:不是,覆盖丢失还在,而且根因没变。

方法级源码:覆盖点在哪

HashMap.putVal() · JDK 1.8:三个非原子写点
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;                    // 写点①:两个线程可能各自 resize
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);    // 写点②:空桶并发写入,后写覆盖先写
    else {
        // 桶非空:链表/树内插入或覆盖(写点③:链表结构被并发破坏)
        ...
    }
    ++modCount;
    if (++size > threshold)                          // 写点④:++size 非原子,计数丢失
        resize();
    ...
}

JDK 1.8 下真实的丢失场景

场景交错过程后果
空桶覆盖 T1、T2 同时发现 tab[i] == null,各自执行 tab[i] = newNode(...)(key1 ≠ key2) 后写者覆盖先写者,一个 key 静默丢失,无异常
size 计数丢失 两个线程并发 put,++size 读-加-写交错,只生效一次 size() 偏小;更危险的是可能错过扩容触发,表"超载"运行
并发 resize 丢数据 T1、T2 各自 resize 出 newTab,T1 先 table = newTab1,T2 后 table = newTab2;期间 T1 又插入的新节点只存在于 newTab1 最终生效的 newTab2 里没有 T1 插入的数据,同样静默丢失
链表结构破坏 两线程同时往同一桶尾插,p.next = newNode 互相覆盖 桶内节点"断链",get 遍历不到部分 key
更深一层:并发 resize 还可能"丢"出新表

JDK 1.8 的 resize() 结束后执行 table = newTab(把新表赋给字段)。如果两个线程各扩了一次,后赋值者胜出,前者的表被整体丢弃——而前者在扩容期间插入的新节点只存在于它自己的新表里。换句话说,并发下不仅可能丢单个 key,还可能丢"半张表"的数据。这就是为什么"没有死循环了"远不等于"安全了"。

为什么 ConcurrentHashMap 能扛住?先看它的写路径

ConcurrentHashMap.putVal() · JDK 1.8:CAS + 桶级锁
final V putVal(K key, V value, boolean onlyIfAbsent) {
    if (key == null || value == null) throw new NullPointerException();
    int hash = spread(key.hashCode());
    for (Node<K,V>[] tab = table;;) {
        ...
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value)))
                break;                    // 空桶:CAS 原子写入,谁先成功谁赢,不会互相覆盖
        }
        else {
            synchronized (f) { ... }       // 桶非空:锁桶头节点,串行化桶内操作
        }
    }
    addCount(1L, binCount);             // 计数:baseCount + CounterCell[],原子累加
    return null;
}

三个机制正好对上 HashMap 的三个死穴:空桶用 CAS 原子写(消灭覆盖)、非空桶 synchronized 桶头(锁粒度细到单个桶,串行化桶内操作)、addCount 原子计数(消灭 size 丢失)。细节我们在第 12 站选型对比里再展开。

面试话术:JDK 8 覆盖丢失一题的标准答法
  • 结论:1.8 尾插修复了成环,但空桶并发写入互相覆盖、++size 计数丢失、并发 resize 丢新表依然存在——HashMap 仍不是线程安全的
  • 源码依据putValtab[i] = newNode(...)++size 都是非原子多步操作;table/size 字段无 volatile,无锁无 CAS
  • 对比加分:ConcurrentHashMap 用 casTabAt 原子写空桶、synchronized(f) 桶级锁、addCount 原子计数,逐一封堵了这三个点
第 8 站

面试官连环追问:结论 + 源码 + 话术三段式

面试不是"背答案",而是"讲逻辑"。下面把三大核心问题按 结论 → 源码依据 → 面试话术 三段式完整过一遍,你可以照着练习。

追问 1:HashMap 为什么允许 null key?

结论:hash(null) 返回 0,null key 永远落在 0 号桶;ConcurrentHashMap 与 Hashtable 出于并发二义性拒绝 null。

源码依据:hash() 三目运算 (key == null) ? 0 : ...putVal/getNode 命中判断里的 p.key == key 引用比较;扩容后 0 & (n-1) = 0 恒定。

话术:"null key 在 HashMap 里不是一个特殊分支,而是一个 hash 为 0 的普通 key。HashMap 允许 null 是单线程便利性设计;CHM 拒绝 null 是因为并发下 get 返回 null 无法区分'key 不存在'与'value 是 null',containsKey 二次确认在并发下也不可靠。"

追问 2:遍历顺序为什么不保证?

结论:元素落桶由 (n-1) & hash 决定而非插入顺序;迭代器按桶下标扫描;扩容 rehash 后顺序还会变。

源码依据:putValtab[i = (n - 1) & hash]HashIterator.hasNextNode() 逐桶扫描;resize() 的 lo/hi 拆分让节点从桶 j 移到 j+oldCap。

话术:"HashMap 的遍历顺序本质是'桶下标序',由散列决定,规范只承诺不保证顺序。JDK 8 扩容时节点要么留原桶要么平移 oldCap,所以扩容后顺序必然可能改变。需要稳定顺序用 LinkedHashMap(插入/访问序)或 TreeMap(排序)。"

追问 3:多线程下 HashMap 会出什么问题?

结论:JDK 7 头插扩容并发成环 → get 死循环 CPU 100%;JDK 8 尾插修复成环,但覆盖丢失、size 不准、并发 resize 丢新表仍在;根因是结构性非线程安全。

源码依据:JDK 7 transfer()e.next = newTable[i] 头插逆序;JDK 8 putValtab[i] = newNode++size 非原子;字段无 volatile。

话术:"HashMap 的并发问题不是某个版本的偶发 bug,而是 put/get/resize 都是多步非原子操作且无可见性保证。1.8 只是消灭了成环,覆盖和计数问题还在。所以并发场景没有讨论余地,直接上 ConcurrentHashMap——它的 CAS 空桶写入、桶级锁和原子计数正是针对这三个死穴设计的。"

追问 4(进阶):你能复现死循环吗?

能。前提是 JDK 1.7(或 1.6),多个线程同时对同一个 HashMap 做 put 并触发扩容。经典做法是:预填充大量元素逼近扩容阈值,再开多个线程并发 put,跑一段时间后 jstack 能看到多个线程卡在 HashMap.get() 的 while 循环里(栈顶反复出现 HashMap.transfer/getEntry),CPU 飙到 100%。

面试加分:把"背结论"升级为"讲 trade-off"

资深候选人不会停在"HashMap 不安全",而是会补一句:"正因为 HashMap 把复杂度都压给了调用方,JDK 才提供了 LinkedHashMap(顺序)、ConcurrentHashMap(并发)、Collections.synchronizedMap(简单加锁)、Map.of(不可变)四类替代,面试官问 HashMap 其实是想看你会不会在正确场景选正确的容器。" 这一句能把整个回答从"八股"拉高到"架构思维"。

三大问话术速记卡
  • null key:hash(null)=0 → 桶 0 → 引用比较命中;CHM 拒绝是并发二义性
  • 遍历无序:位置 = (n-1) & hash;逐桶扫描;扩容 rehash 后变脸
  • 线程不安全:1.7 头插成环死循环;1.8 覆盖丢失 + size 不准;根因是非原子 + 无可见性;解法是 ConcurrentHashMap
第 9 站

equals / hashCode 契约:最经典的 Bug 制造机

"重写 equals() 为什么必须同时重写 hashCode()?能举个只重写一个导致 Bug 的例子吗?" —— 这道题直接决定你能不能把 HashMap 的查找机制讲透。

先看 HashMap 查找 key 的流程——理解了查找方式,就理解了为什么这两个方法必须成对出现:

HashMap 查找 key 的三步
① 计算 hashCode → 定位桶下标:hash & (n-1)
② 遍历桶中的链表/红黑树
③ 对每个节点:先比 hash(int 比较),再比 ==(引用比较),最后才 equals()

关键点在第 ③ 步:hashCode 不同的两个对象,HashMap 直接认为它们不相等,根本不会调用 equals()。所以 hashCode 决定了"去哪个桶找",equals 决定了"桶里哪个是你要的"——两者协作,缺一不可。

Bug 实例:只重写 equals 不重写 hashCode

BugDemo.java · 经典翻车现场
class User {
    String name;
    int age;

    User(String name, int age) {
        this.name = name; this.age = age;
    }

    // 只重写了 equals,没重写 hashCode —— 这是一个 Bug
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof User)) return false;
        User u = (User) o;
        return age == u.age && name.equals(u.name);
    }
    // hashCode() 继承 Object —— 基于内存地址,每次 new 都不同
}

Map<User, String> map = new HashMap<>();
User u1 = new User("Alice", 25);
User u2 = new User("Alice", 25);

map.put(u1, "VIP");
System.out.println(u1.equals(u2));        // true —— equals 认为相等
System.out.println(map.get(u2));           // null —— 但 HashMap 找不到!
// 原因:u1 和 u2 的 hashCode 不同,落到了不同的桶
反过来:只重写 hashCode 不重写 equals 也会出事

如果只重写 hashCode 让两个对象落到同一个桶,但 equals 沿用 Object 默认(比引用地址),那么 get(u2) 在桶内遍历时,u1.equals(u2) 返回 false——同样找不到。更糟的是 map.put(u2, ...) 会因为 equals 不相等而在同一桶里新增一个重复节点,map 里出现"逻辑上相同却并存"的两份数据。所以两个方法必须同时重写,且逻辑一致(equals 用哪些字段,hashCode 就应该用哪些字段)。

Java 官方的契约规则

equals / hashCode 三条铁律
1. 如果 a.equals(b) == true → a.hashCode() == b.hashCode()(必须)
2. 如果 a.hashCode() == b.hashCode() → a.equals(b) 不一定(允许碰撞)
3. 同一个对象多次调用 hashCode() 返回值不变(除非影响 hash 的字段被修改)
生产环境建议
  • 用 IDE 或 @Data / @EqualsAndHashCode(Lombok)自动生成,避免手写遗漏
  • Java 14+ 可用 record 类型——自动实现 equals、hashCode、toString,天然不可变,是 HashMap key 的首选
  • hashCode 计算时记得用 Objects.hash(field1, field2),不要手写位运算(容易写错)
  • 作为 HashMap key 的字段必须是 final 或不可变的,否则 put 后修改字段会导致"丢失"(见第 10 站陷阱 3)
第 10 站

高频面试陷阱题

陷阱 1:"HashMap 的容量为什么必须是 2 的幂?" —— 大部分人都能答对一半(位运算),漏掉另一半(扩容优化)。

完整答案有三层:

  • 数学等价:当 n = 2k 时,hash & (n-1) 等价于 hash % n,位运算比取模快几个数量级
  • 均匀分布:2 的幂减 1 的二进制全是 1(如 15 = 1111₂),hash 的每一位都参与运算,碰撞最小化;如果 n-1 有 0 位(如 13-1=12 = 1100₂),那几位 hash 永远无法影响下标,等于人为制造碰撞
  • 扩容优化:1.8 扩容时利用 hash & oldCap 直接判断新位置(原地 or +oldCap),省去重新 hash——这个优化也依赖"2 的幂"的前提
陷阱 2:"两个 key 的 hashCode 相同会怎样?" —— 面试官想看你是否理解冲突解决,以及"相同 hashCode"和"相等 key"的区别。

hashCode 相同 ≠ 同一个 key。HashMap 的处理方式:

  1. 两个 key 的 hash 值相同 → 落到同一个桶
  2. 桶里形成链表(或红黑树),两个节点共存
  3. get(key) 时,先定位到桶,再遍历链表用 equals() 逐个比对

这就是哈希冲突的正常处理方式(链地址法),不会丢数据。只有当大量 key 碰撞到同一个桶时,性能才从 O(1) 退化为 O(n)(链表)或 O(log n)(红黑树)——这也是"恶意构造 collision 攻击"的原理,树化阈值 8 就是为了兜底这种退化。

陷阱 3:"put 之后修改 key 的值会怎样?" —— 这是一道"坑题",答出'hashCode 变了就找不到'只是及格。
MutableKeyBug.java · 修改 key 后 HashMap "找不到"
class Key {
    String value;
    public int hashCode() { return value.hashCode(); }
    public boolean equals(Object o) {
        return o instanceof Key && value.equals(((Key)o).value);
    }
}

Map<Key, String> map = new HashMap<>();
Key k = new Key();
k.value = "hello";
map.put(k, "world");

k.value = "changed";    // 修改了影响 hashCode 的字段
System.out.println(map.get(k));  // null —— 找不到!
// 原因:put 时 k 在 hash("hello") 对应的桶里
// get 时用 hash("changed") 去查,去了完全不同的桶

结论:put 之后不要修改 key——不仅 get 找不到,旧节点还会成为永远无法访问的"内存泄漏"(它还在桶里,只是你再也算不出它的下标了)。这也是为什么 String、Integer 这些不可变类是最佳 key 选择——它们的 hashCode 永远不变。加分回答:"这个问题在《Effective Java》里被列为'可变对象不适合做 Map key',Java 8 之后甚至可以在文档里查到明确警告。"

陷阱 4:"put 方法的返回值是什么?" —— 很多人答不上来,因为它"反直觉"。
PutReturn.java · put 返回的是"被覆盖的旧值"
Map<String, String> m = new HashMap<>();
String r1 = m.put("k", "v1");   // null —— 之前没有该 key
String r2 = m.put("k", "v2");   // "v1" —— 返回被覆盖的旧值
// 注意:如果旧值本身就是 null,返回值也是 null,无法区分"新增"与"覆盖为 null"
// 想区分必须用 containsKey() 先判断(单线程下可靠)
陷阱题小结
  • 2 的幂:位运算取模 + 掩码全 1 均匀分布 + 扩容免重 hash,三层都要答
  • hashCode 相同:链地址法解决冲突,不丢数据,只退化性能
  • 可变 key:put 后改字段 → hashCode 变 → 找不到 + 内存泄漏;key 必须不可变
  • put 返回值:返回被覆盖的旧值;旧值为 null 时无法区分新增/覆盖
第 11 站

生产环境最佳实践

初始容量:别偷懒用默认值

阿里开发手册明确要求:初始化 HashMap 时必须指定容量。公式如下:

initialCapacity = (int)(expectedSize / 0.75f) + 1

示例:预计存 100 个元素 → (int)(100 / 0.75) + 1 = 134
HashMap 构造器内部会调 tableSizeFor() 向上取到 2 的幂 → 实际容量 256
全程零扩容,性能最优(100 / 256 ≈ 0.39,远低于 0.75 阈值)
BestPractice.java · 初始容量设置
// ❌ 默认容量 16,存 1000 个元素会触发多次扩容(每次都要搬运+rehash)
Map<String, Object> bad = new HashMap<>();

// ✅ 正确:预估元素数量,指定初始容量
int expectedSize = 1000;
Map<String, Object> good = new HashMap<>(
    (int)(expectedSize / 0.75f) + 1
);

// ✅ 更简洁:Google Guava(内部同样按 0.75 反推)
Map<String, Object> guava = Maps.newHashMapWithExpectedSize(1000);
生产坑:千万别用"容量=元素数"

直接 new HashMap<>(1000)错的:构造参数是"容量",而扩容触发点是 容量 × 0.75。容量 1000 时,存到 751 个就会扩容一次,等于白设。所以要么按公式 (int)(expectedSize / 0.75f) + 1,要么用 Guava 的 newHashMapWithExpectedSize。另外注意 tableSizeFor 会把 134 抬到 256,所以"设 134"实际拿到的是 256——这是预期行为,别当成 bug。

Java 9+ 不可变 Map

如果你的 Map 在创建后不需要修改(配置项、常量映射),Java 9 引入的工厂方法更简洁安全:

ImmutableMap.java · Java 9+ 不可变 Map
// Java 9+ Map.of() —— 不可变,不允许 null key/value
Map<String, Integer> config = Map.of(
    "timeout",  3000,
    "retries",  3,
    "poolSize", 10
);

// Map.copyOf() —— 从已有 Map 创建不可变副本
Map<String, Integer> snapshot = Map.copyOf(config);

// config.put("new", 1)  → UnsupportedOperationException
// Map.of("key", null)   → NullPointerException(与 CHM 同一哲学)

并发场景:唯一的正解是 ConcurrentHashMap

再次强调:任何"给 HashMap 加个锁"的封装都不如直接用 ConcurrentHashMap。Hashtable(全表锁)性能差,Collections.synchronizedMap 同样全表锁且复合操作(如"先查后写")仍需手动加锁。ConcurrentHashMap 的桶级锁 + CAS 让并发读写吞吐量高一到两个数量级。另外注意:不要用 HashMap 做全局缓存——缓存是典型的"多读少写 + 周期性刷新",正是第 5~7 站所有事故的标准温床。

生产建议清单
  • 创建时按 (int)(expectedSize / 0.75f) + 1 指定容量,避免反复扩容
  • key 用不可变对象(String / Integer / record / 枚举),禁止 put 后修改
  • 只读配置用 Map.of() / Map.copyOf(),天然线程安全
  • 并发读写一律 ConcurrentHashMap;全局缓存禁止 HashMap
  • 对外暴露内部 Map 前用 Collections.unmodifiableMap 或拷贝,防止调用方改坏内部状态
第 12 站

全家桶对比:HashMap vs LinkedHashMap vs TreeMap vs ConcurrentHashMap

"给你 1 亿条用户数据做实时去重统计,你会选哪个 Map?为什么不用 HashMap?" —— 选型题考的不只是记忆,而是 trade-off 意识。

ConcurrentHashMap 的线程安全原理(JDK 1.8)

面试如果聊到"为什么 CHM 安全",下面三个机制是最小完备集:

  • CAS 原子写空桶casTabAt(tab, i, null, newNode),空桶插入用 Unsafe.compareAndSwapObject,谁先成功谁生效,天然消灭"后写覆盖先写"
  • synchronized 桶头锁:桶非空时锁住桶头节点 f,锁粒度是一个桶而不是整张表,不同桶的读写可以完全并行
  • sizeCtl + CounterCell 原子计数sizeCtl 三态复用(负数且非 -1 表示扩容中、-1 表示初始化中、正数为扩容阈值);计数走 baseCount + CounterCell[](高并发下分散到多个 cell 用 CAS 累加,sumCount() 汇总),彻底解决 ++size 不原子的问题
深度加分:CHM 的"协助扩容"

CHM 扩容不是单线程搬完的:resizeStamp 记录扩容戳,其他线程在 put/get 时如果发现某个桶是 ForwardingNode(forwarding 标记节点,hash 值为 -1),会主动帮忙搬运该桶再继续自己的操作。所以 CHM 扩容是"多线程协作搬桶",而不是像 HashMap 那样一个线程全包——这也是它在大容量下扩容不卡顿的原因。面试说出 ForwardingNode 这个词,基本就是满分了。

选型决策表

选型决策表
场景推荐方案理由
单线程,无顺序要求HashMap性能最优,零额外开销
单线程,需保持插入顺序LinkedHashMap双向链表维护顺序,LRU 缓存
单线程,需按 key 排序TreeMap红黑树自然排序,O(log n)
多线程并发读写ConcurrentHashMapCAS + 桶级锁,高吞吐
创建后只读Map.of() / Map.copyOf()不可变,线程安全,内存紧凑
任何新代码❌ 不要用 Hashtable全方法 synchronized,锁粒度最大,性能极差

全篇速查表

HashMap 面试速查表
问题核心答案
为什么允许 null key?hash(null) 返回 0,存 table[0];ConcurrentHashMap 不允许,因为并发下 null 有二义性
遍历顺序为什么不保证?元素位置由 hash & (n-1) 决定,与插入顺序无关;扩容 rehash 后可能再变
需要有序遍历怎么办?LinkedHashMap(插入序/访问序)或 TreeMap(排序序)
equals/hashCode 的关系?equals 相等 → hashCode 必相等;必须同时重写,否则 HashMap 找不到 key
多线程下会出什么问题?size 不准、数据覆盖、JDK 1.7 死循环(1.8 尾插修复死循环但覆盖/计数问题仍在)
容量为什么是 2 的幂?hash & (n-1) 等价 hash % n;位运算快;掩码全 1 均匀;扩容免重 hash
put 后能改 key 吗?不能。key 的 hashCode 变了 → 找不到原来的桶 → get 返回 null 且旧节点泄漏
初始容量怎么设?(int)(expectedSize / 0.75f) + 1,避免扩容开销
树化阈值?链表 ≥ 8 且容量 ≥ 64 树化为红黑树;≤ 6 退化回链表(滞回区间避免震荡)
总结

这一篇你掌握了什么

回到开头的三连问,现在你不仅能答出结论,还能讲出源码依据和工程取舍:null key 为什么落桶 0、遍历顺序为什么无序、多线程下 1.7 为什么死循环而 1.8 为什么还会丢数据——并且你知道这些设计决策背后的 trade-off,这才是面试官真正想听到的东西。

核心知识点回顾

  • null key 是 HashMap 刻意的工程设计:hash(null) = 0,永远落桶 0(扩容后不变),命中靠 p.key == key 引用比较;ConcurrentHashMap / Hashtable 因并发二义性禁止 null
  • 遍历无序 是哈希分布的必然结果:落桶 = (n-1) & hash,迭代器逐桶扫描;扩容 lo/hi 拆分重排桶位,顺序随之"变脸";LinkedHashMap 用双向链表补偿顺序
  • 扩容 rehashsize > 容量×0.75 触发,容量 ×2;JDK 8 用 hash & oldCap 免重 hash 拆 lo/hi 两段;树化 8/64、退化 6 是防震荡的滞回设计
  • 线程不安全根因:put/resize 是多步非原子操作,字段无 volatile 无锁;JDK 7 头插成环死循环(CPU 100%),JDK 8 尾插修复成环但覆盖丢失、size 不准、并发 resize 丢新表仍在
  • equals/hashCode 必须成对重写:先 hash 定位桶、再 equals 匹配;可变 key 是生产大坑
  • 生产实践:初始容量按 (int)(expectedSize / 0.75f) + 1、不可变 key、只读用 Map.of、并发一律 ConcurrentHashMap(CAS + 桶级锁 + sizeCtl/CounterCell)
一句话带走

HashMap 面试没有"背诵题",只有"原理题"——把 null → 桶 0、散列 → 无序、非原子 → 并发事故 这条逻辑链串起来,你就已经超过了大多数候选人。

Comments · 评论