Lesson 02 · 集合框架源码
HashMap 高频面试题全解:null key、遍历顺序、线程安全
为什么 HashMap 是面试"钉子户"?
如果 Java 面试只能考一道集合题,那一定是 HashMap。我在面试和被面试的十几年里,几乎没有遇到过不考 HashMap 的 Java 岗位。原因很简单——一个 HashMap 能把数据结构、哈希算法、并发安全、JDK 源码设计全串起来,面试官用一个类就能摸清你的功底。
但大多数人准备 HashMap 面试的方式是"背八股":数组+链表+红黑树、负载因子 0.75、扩容翻倍……这些没错,但面试官想听的不是背诵,而是你能把这些设计决策串成一条逻辑链——为什么是这个结构?为什么 null 被特殊对待?为什么遍历顺序会变?
这篇文章不讲 put 流程的细节(那是上一篇的内容),我们聚焦面试中最高频的几个追问方向,把每一个"为什么"讲透。
null key:HashMap 为什么"网开一面"?
面试中经常被问到:"HashMap 和 ConcurrentHashMap 都基于哈希表,为什么前者允许 null key,后者不允许?"——这不是随意的设计,背后有清晰的工程权衡。
HashMap 的 hash() 方法:一行代码定乾坤
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 号桶,能正确找到值
为什么 HashMap 选择允许 null?
这不是技术限制,而是设计哲学。Joshua Bloch(Java 集合框架设计者)认为:
- 便利性:很多业务场景中 null 是一个有意义的值——"用户未选择""配置项缺失""缓存未命中"。允许 null key 让开发者可以直接
map.put(null, defaultValue),省去额外的判空逻辑 - 单线程场景无歧义:HashMap 设计初衷是单线程使用,null 不会带来语义混乱
- hash 函数做了兜底:null → 0 的映射简单明确,不会引入 bug
ConcurrentHashMap 为什么拒绝 null?
if (key == null || value == null)
throw new NullPointerException(); // 没有任何商量
Doug Lea(ConcurrentHashMap 作者)在邮件列表中解释过这个设计决策:
在单线程的 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 二次确认
遍历顺序:为什么 HashMap "不守规矩"?
答案藏在 HashMap 的哈希分布机制里。元素在数组中的位置取决于 hash & (n-1),而不是插入顺序。我们用一个例子来看:
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] 开始逐个桶扫描,所以输出顺序取决于桶下标的大小,而不是插入的先后。
LinkedHashMap 如何记住插入顺序?
LinkedHashMap 继承自 HashMap,在 HashMap 的 Node 基础上额外维护一条双向链表。每次 put 新元素时,除了照常放入哈希桶,还会把新节点追加到双向链表尾部。遍历 LinkedHashMap 时,走的是双向链表而不是哈希桶数组,所以输出顺序 = 插入顺序。
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 不会
equals / hashCode 契约:最经典的 Bug 制造机
先看 HashMap 查找 key 的流程——理解了查找方式,就理解了为什么这两个方法必须成对出现:
① 计算 hashCode → 定位桶下标:hash & (n-1)
② 遍历桶中的链表/红黑树
③ 对每个节点:先比 hash(int 比较),再比 ==(引用比较),最后才 equals()
关键点在第 ③ 步:hashCode 不同的两个对象,HashMap 直接认为它们不相等,根本不会调用 equals()。
Bug 实例:只重写 equals 不重写 hashCode
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 沿用 Object 默认(比引用地址),那么 get(u2) 在桶内遍历时,u1.equals(u2) 返回 false——同样找不到。所以两个方法必须同时重写,且逻辑一致。
Java 官方的契约规则
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 后修改字段会导致"丢失"
线程安全: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<>();
1.8 改用尾插法后,并发扩容不再形成环形链表——这是事实。但 size 不准确和数据覆盖的问题依然存在。所以"JDK 1.8 的 HashMap 可以安全用在多线程环境"是错误结论。面试时如果被追问,一定要明确区分"修复了死循环"和"线程安全"是两码事。
高频面试陷阱题
完整答案有三层:
- 数学等价:当 n = 2k 时,
hash & (n-1)等价于hash % n,位运算比取模快几个数量级 - 均匀分布:2 的幂减 1 的二进制全是 1(如 15 = 1111₂),hash 的每一位都参与运算,碰撞最小化
- 扩容优化:1.8 扩容时利用
hash & oldCap直接判断新位置,省去重新 hash——这个优化也依赖"2 的幂"的前提
hashCode 相同 ≠ 同一个 key。HashMap 的处理方式:
- 两个 key 的 hash 值相同 → 落到同一个桶
- 桶里形成链表(或红黑树),两个节点共存
get(key)时,先定位到桶,再遍历链表用equals()逐个比对
这就是哈希冲突的正常处理方式,不会丢数据。只有当大量 key 碰撞到同一个桶时,性能才从 O(1) 退化为 O(n) 或 O(log n)。
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 永远不变。
生产环境最佳实践
初始容量:别偷懒用默认值
阿里开发手册明确要求:初始化 HashMap 时必须指定容量。公式如下:
示例:预计存 100 个元素 → (int)(100 / 0.75) + 1 = 134
HashMap 构造器内部会调 tableSizeFor() 向上取到 2 的幂 → 实际容量 256
全程零扩容,性能最优
// ❌ 默认容量 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 引入的工厂方法更简洁安全:
// 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) |
| 多线程并发读写 | ConcurrentHashMap | CAS + synchronized,桶级锁 |
| 创建后只读 | Map.of() / Map.copyOf() | 不可变,线程安全,内存紧凑 |
| 任何新代码 | ❌ 不要用 Hashtable | 全方法 synchronized,性能极差 |
全篇速查表
面试前快速过一遍,确保每个点都能讲清楚"为什么":
| 问题 | 核心答案 |
|---|---|
| 为什么允许 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 类型