Lesson 02 · 集合框架源码

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

中级·⭐ 必问·#集合·#面试·#核心

第 1 站

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

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

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

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

这篇文章不讲 put 流程的细节(那是上一篇的内容),我们聚焦面试中最高频的几个追问方向,把每一个"为什么"讲透。

第 2 站

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

面试中经常被问到:"HashMap 和 ConcurrentHashMap 都基于哈希表,为什么前者允许 null key,后者不允许?"——这不是随意的设计,背后有清晰的工程权衡。

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

HashMap.hash() · null key 的特殊处理
static final int hash(Object key) {
    int h;
    // key == null → 直接返回 0,不调用 hashCode()
    return (key == null) ? 0
         : (h = key.hashCode()) ^ (h >>> 16);
}

看到了吗?当 key == null 时,hash 函数直接返回 0。这意味着:

  • null key 的桶下标永远是 0 & (n-1) = 0,即 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] 桶中

为什么 HashMap 选择允许 null?

这不是技术限制,而是设计哲学。Joshua Bloch(Java 集合框架设计者)认为:

  • 便利性:很多业务场景中 null 是一个有意义的值——"用户未选择""配置项缺失""缓存未命中"。允许 null key 让开发者可以直接 map.put(null, defaultValue),省去额外的判空逻辑
  • 单线程场景无歧义:HashMap 设计初衷是单线程使用,null 不会带来语义混乱
  • hash 函数做了兜底:null → 0 的映射简单明确,不会引入 bug

ConcurrentHashMap 为什么拒绝 null?

ConcurrentHashMap.putVal() · 直接拒绝 null
if (key == null || value == null)
    throw new NullPointerException();  // 没有任何商量

Doug Lea(ConcurrentHashMap 作者)在邮件列表中解释过这个设计决策:

null 在并发环境中有二义性

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

为了避免这种无法排查的歧义,ConcurrentHashMap 选择了最简单粗暴的方案:禁止 null key 和 null value,从源头消除二义性。

面试标准答案
  • HashMap 允许 null key,因为 hash(null) 直接返回 0,null key 存储在 0 号桶
  • HashMap 允许 null value,因为单线程下 get() 返回 null 可以用 containsKey 区分
  • ConcurrentHashMap 不允许 null key/value,因为并发环境下 get() 返回 null 有二义性(key 不存在 vs value 为 null),且无法通过 containsKey 二次确认
第 3 站

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

"我按 A、B、C 的顺序 put 进 HashMap,为什么遍历出来是 B、A、C?" —— 这是新手最常踩的坑。

答案藏在 HashMap 的哈希分布机制里。元素在数组中的位置取决于 hash & (n-1),而不是插入顺序。我们用一个例子来看:

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

// 遍历结果(取决于 hashCode 和数组长度):
// 可能是 banana, apple, date, cherry —— 和插入顺序完全不同
for (String key : map.keySet()) {
    System.out.println(key + " → " + map.get(key));
}

为什么顺序会乱?

"apple" 的 hashCode 和 "banana" 的 hashCode 经过扰动后,取模得到的桶下标可能分别是 3、1、7、5。遍历 HashMap 时,迭代器从 table[0] 开始逐个桶扫描,所以输出顺序取决于桶下标的大小,而不是插入的先后。

三种 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 都会把被访问的节点移到链表末尾,配合 removeEldestEntry() 就能实现 LRU 缓存。

选型指南
  • 不需要顺序保证 → HashMap(性能最优)
  • 需要保持插入顺序或访问顺序 → LinkedHashMap(LRU 缓存场景)
  • 需要按 key 排序 → TreeMap(自然序或自定义 Comparator)
  • 扩容后 HashMap 的遍历顺序可能改变,LinkedHashMap 不会
第 4 站

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

"重写 equals() 为什么必须同时重写 hashCode()?能举个只重写一个导致 Bug 的例子吗?"

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

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

关键点在第 ③ 步:hashCode 不同的两个对象,HashMap 直接认为它们不相等,根本不会调用 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——同样找不到。所以两个方法必须同时重写,且逻辑一致

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
  • hashCode 计算时记得用 Objects.hash(field1, field2),不要手写位运算(容易写错)
  • 作为 HashMap key 的字段必须是 final 或不可变的,否则 put 后修改字段会导致"丢失"
第 5 站

线程安全:HashMap 在并发下会出什么问题?

"HashMap 不是线程安全的——那它具体会出什么问题?仅仅是数据丢失吗?"

HashMap 在并发环境下至少有三个层次的问题,面试官想听的不只是"会死循环":

问题 1:size 不准确

++size 不是原子操作(读→加→写三步),两个线程同时执行 put() 时,可能两个 ++size 只增加了一次。结果就是 map.size() 比实际元素数少。

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

线程 A 和线程 B 同时发现 table[i] == null,都执行了 tab[i] = newNode(...)。后写入的那个会覆盖先写入的——数据悄悄丢了,没有任何异常。

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

这是最"经典"的问题。JDK 1.7 使用头插法扩容,两个线程同时 resize 时,链表会被逆序重建。如果线程 A 执行到一半被挂起,线程 B 完成扩容后链表顺序反转,线程 A 恢复后继续按旧引用操作——最终可能形成环形链表,后续所有经过这个桶的 get() 都变成死循环。

生产事故案例
// 典型事故场景:全局缓存使用 HashMap
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 修复了死循环,但 HashMap 仍然不是线程安全的

1.8 改用尾插法后,并发扩容不再形成环形链表——这是事实。但 size 不准确和数据覆盖的问题依然存在。所以"JDK 1.8 的 HashMap 可以安全用在多线程环境"是错误结论。面试时如果被追问,一定要明确区分"修复了死循环"和"线程安全"是两码事。

第 6 站

高频面试陷阱题

陷阱 1:"HashMap 的容量为什么必须是 2 的幂?" —— 大部分人都能答对一半。

完整答案有三层:

  • 数学等价:当 n = 2k 时,hash & (n-1) 等价于 hash % n,位运算比取模快几个数量级
  • 均匀分布:2 的幂减 1 的二进制全是 1(如 15 = 1111₂),hash 的每一位都参与运算,碰撞最小化
  • 扩容优化:1.8 扩容时利用 hash & oldCap 直接判断新位置,省去重新 hash——这个优化也依赖"2 的幂"的前提
陷阱 2:"两个 key 的 hashCode 相同会怎样?" —— 面试官想看你是否理解冲突解决。

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

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

这就是哈希冲突的正常处理方式,不会丢数据。只有当大量 key 碰撞到同一个桶时,性能才从 O(1) 退化为 O(n) 或 O(log n)。

陷阱 3:"put 之后修改 key 的值会怎样?" —— 这是一道"坑题"。
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。这也是为什么 String、Integer 这些不可变类是最佳 key 选择——它们的 hashCode 永远不变。

第 7 站

生产环境最佳实践

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

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

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

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

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

// ✅ 更简洁:Google Guava
Map<String, Object> guava = Maps.newHashMapWithExpectedSize(1000);

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

HashMap vs ConcurrentHashMap vs Hashtable 选型

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

全篇速查表

面试前快速过一遍,确保每个点都能讲清楚"为什么":

HashMap 面试速查表
问题核心答案
为什么允许 null key?hash(null) 返回 0,存 table[0];ConcurrentHashMap 不允许,因为并发下 null 有二义性
遍历顺序为什么不保证?元素位置由 hash & (n-1) 决定,与插入顺序无关;扩容后可能再变
需要有序遍历怎么办?LinkedHashMap(插入序/访问序)或 TreeMap(排序序)
equals/hashCode 的关系?equals 相等 → hashCode 必相等;必须同时重写,否则 HashMap 找不到 key
多线程下会出什么问题?size 不准、数据覆盖、JDK 1.7 死循环(1.8 修复死循环但仍有前两个问题)
容量为什么是 2 的幂?hash & (n-1) 等价 hash % n;位运算快;保证每一位参与分布
put 后能改 key 吗?不能。key 的 hashCode 变了 → 找不到原来的桶 → get 返回 null
初始容量怎么设?(int)(expectedSize / 0.75) + 1,避免扩容开销

这篇文章的核心收获

  • null key 是 HashMap 刻意的工程设计(hash 返回 0 存桶 0),ConcurrentHashMap 因并发二义性禁止 null
  • 遍历无序 是哈希分布的必然结果,LinkedHashMap 用双向链表补偿了这一点
  • equals/hashCode 必须成对重写,这是 HashMap 查找机制(先 hash 定位桶、再 equals 匹配)决定的
  • 线程安全 不能靠 JDK 版本兜底——即使用 1.8,并发环境也必须用 ConcurrentHashMap
  • 生产实践:指定初始容量、用不可变 key、选对 Map 类型