Java 从入门到精通(九):集合框架(下)——Map 家族与 HashMap 源码剖析

上一篇我们把 List 与 Set 的底层结构、扩容机制、fail-fast 迭代器讲透了。本篇进入 Map 家族——这是 Java 面试中出现频率最高、源码最值得逐行精读的一块。全文代码均在 JDK 8 与 JDK 17 上实测通过,涉及版本差异的地方会明确标注。建议把文中的源码片段对照你本地 rt.jar / src.zip 里的 java.util.HashMap 一起读,尤其是 putVal、resize、treeifyBin 这三段,读完你会明白”为什么容量必须是 2 的幂””为什么树化阈值是 8”这些结论根本不需要死记硬背。另外文中会出现大量位运算,看不懂的地方先把二进制写出来。

一、Map 家族全景

1.1 八种 Map 实现的语义与适用场景

Map 不是 Collection 的子接口,它是独立的顶层接口,存储的是”键到值”的映射(key-value mapping)。JDK 提供了八种常用实现,它们的核心差异体现在四个方面:是否有序、是否允许 null、底层数据结构、是否线程安全。

实现类 底层结构 是否有序 key/value 可否为 null 线程安全 时间复杂度 典型场景
HashMap 数组 + 链表 + 红黑树 无序 key 允许 1 个 null,value 允许多个 null 否 get/put 期望 O(1) 绝大多数 KV 场景,默认首选
LinkedHashMap HashMap + 双向链表 插入序 / 访问序 同 HashMap 否 O(1),多一份链表指针开销 LRU 缓存、需要保持插入顺序的序列化
TreeMap 红黑树 key 自然序 / 定制序 key 不可为 null,value 可以 否 get/put O(log n) 范围查询、排行榜、区间统计
Hashtable 数组 + 链表 无序 key/value 均不可为 null 是(全表 synchronized) 期望 O(1) 遗留代码,新项目不应使用
ConcurrentHashMap 数组 + 链表 + 红黑树 + CAS 无序 key/value 均不可为 null 是(分段/CAS + 细粒度锁) 期望 O(1) 高并发计数、本地缓存
IdentityHashMap 开放定址数组 无序 key 允许 null 否 期望 O(1) 按引用身份(==)判定相等,序列化图算法
WeakHashMap 数组 + 链表,弱引用 key 无序 允许 null 否 期望 O(1) 临时元数据、ClassLoader 级缓存,防内存泄漏
EnumMap 紧凑数组,下标即 ordinal 枚举声明序 key 不可为 null,value 可以 否 O(1),无哈希计算 枚举作为 key,最快且最省内存

一句话选型口诀:默认 HashMap,要顺序用 LinkedHashMap,要排序用 TreeMap,要并发用 ConcurrentHashMap,枚举 key 用 EnumMap,其余都是特定场景的专用工具。

1.2 Map 接口的常用方法与默认方法

JDK 8 为 Map 接口引入了一批默认方法(default method),它们把”取不到就放一个”这类样板代码压缩成一行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import java.util.*;

public class MapDefaultMethodsDemo {
public static void main(String[] args) {
Map<String, Integer> stock = new HashMap<>();
stock.put("apple", 10);
stock.put("banana", 3);

// 1. getOrDefault:取不到就返回默认值,不改变 Map 本身
int pear = stock.getOrDefault("pear", 0);
System.out.println("pear = " + pear); // 0
System.out.println("map 未被修改: " + stock); // {banana=3, apple=10}

// 2. putIfAbsent:仅当 key 不存在(或映射为 null)时才写入
stock.putIfAbsent("apple", 99); // 已存在,不生效
stock.putIfAbsent("orange", 7); // 不存在,写入
System.out.println(stock); // {banana=3, orange=7, apple=10}

// 3. computeIfAbsent:key 不存在时,用函数计算 value 并写入;返回当前 value
// 经典用法:Map<String, List<T>> 的自动初始化,替代三层 if
Map<String, List<String>> index = new HashMap<>();
index.computeIfAbsent("java", k -> new ArrayList<>()).add("HashMap");
index.computeIfAbsent("java", k -> new ArrayList<>()).add("TreeMap");
index.computeIfAbsent("go", k -> new ArrayList<>()).add("goroutine");
System.out.println(index); // {java=[HashMap, TreeMap], go=[goroutine]}

// 4. compute:无论 key 是否存在,都用函数重算 value;返回 null 则删除该映射
stock.compute("banana", (k, v) -> v == null ? 1 : v * 2); // 3 -> 6
stock.compute("pear", (k, v) -> null); // 返回 null,不插入
System.out.println(stock.containsKey("pear")); // false

// 5. merge:把新值与旧值按函数合并;key 不存在则直接放入新值
stock.merge("apple", 5, Integer::sum); // 10 + 5 = 15
stock.merge("grape", 5, Integer::sum); // 不存在,直接放 5

// 6. forEach:BiConsumer 遍历,比 entrySet + for 更简洁,且局部变量更少
stock.forEach((k, v) -> System.out.println(k + " -> " + v));

// 7. replace 系列:仅在 key 已存在时才替换
stock.replace("apple", 20);
stock.replace("apple", 20, 30); // 旧值必须是 20 才替换为 30
}
}

注意 computeIfAbsent 与 putIfAbsent 的关键区别:putIfAbsent 的第二个参数是已经算好的值,无论是否需要都会被求值;computeIfAbsent 的第二个参数是函数,只有 key 不存在时才执行。所以当构造 value 代价很高(比如查库、建集合)时,必须用 computeIfAbsent。

1.3 用 merge 做词频统计

merge 是为”计数聚合”量身定做的方法,一行顶过去五行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import java.util.*;
import java.util.stream.*;

public class WordCounter {
public static void main(String[] args) {
String text = "java java hashmap tree hashmap java map tree";

// 写法一:merge,最简洁,无装箱拆箱之外的额外开销
Map<String, Integer> freq = new HashMap<>();
for (String w : text.split(" ")) {
freq.merge(w, 1, Integer::sum);
}
System.out.println(freq); // {java=3, hashmap=2, tree=2, map=1}

// 写法二:Stream API 的 groupingBy,内部同样依赖 merge
Map<String, Long> freq2 = Arrays.stream(text.split(" "))
.collect(Collectors.groupingBy(s -> s, Collectors.counting()));
System.out.println(freq2);

// 写法三:找出频次最高的词
freq.entrySet().stream()
.max(Map.Entry.comparingByValue())
.ifPresent(e -> System.out.println("TOP: " + e.getKey() + " x " + e.getValue()));
}
}

二、哈希表原理

2.1 哈希函数与哈希冲突

哈希表的理想模型是:给一个 key,通过哈希函数 h(key) 直接算出数组下标,一次寻址拿到值,时间复杂度 O(1)。但现实有两个绕不开的问题:

  1. 哈希函数不可能单射:key 的取值空间远大于数组长度,必然存在 k1 != k2 但 h(k1) == h(k2),这就是哈希冲突(collision)。
  2. 数组不能无限大:如果开一个 40 亿长度的数组来装所有可能的 key,空间直接爆炸。

于是工程上必须做两件事:设计分布均匀的哈希函数,以及选择冲突解决策略。

冲突解决有两大流派:

策略 做法 代表实现 优点 缺点
开放定址法(Open Addressing) 冲突时按探测序列(线性/二次/双哈希)找下一个空槽 ThreadLocalMap、IdentityHashMap 无指针开销,缓存局部性好 删除需 tombstone 标记;装载因子稍高就急剧退化
链地址法(Separate Chaining) 每个槽挂一个链表,冲突元素串起来 HashMap、Hashtable 实现简单,删除 O(1),容忍高装载 指针开销 + 缓存不友好;极端冲突退化为 O(n)

HashMap 选择链地址法,并在 JDK 8 引入红黑树作为”链表过长时的兜底”,形成数组 + 链表 + 红黑树的三段式结构:数组负责 O(1) 定位,链表负责解决少量冲突,红黑树负责防止恶意哈希攻击把单次操作退化成 O(n)。

2.2 装载因子与 O(1) 的推导

装载因子(load factor)定义为 α = 元素个数 / 数组容量。在链地址法下,一次成功查找的平均探测次数约为 1 + α/2,一次失败查找约为 α。也就是说查找代价只与 α 有关,与元素总量无关——这正是 O(1) 的来源,注意它是摊还期望意义下的 O(1),不是最坏情况。

那为什么 DEFAULT_LOAD_FACTOR = 0.75f?这是空间与时间的折中点:

  • α 太小(如 0.5):冲突极少,但一半数组空着,内存浪费严重,且扩容更频繁;
  • α 太大(如 1.0):数组利用率高,但链表迅速变长,查找退化,且泊松分布下出现长链的概率陡增;
  • 0.75 恰好让”链表长度超过 8”的概率降到千万分之一量级(下一节会给数据),同时数组利用率尚可。JDK 源码注释里明确写了这是”a decent tradeoff between time and space costs”。

2.3 扰动函数:让高 16 位参与运算

HashMap 没有直接用 key.hashCode() 取模,而是先做了一次扰动(disturbance):

1
2
3
4
5
6
// java.util.HashMap#hash,JDK 8 / JDK 17 完全一致
static final int hash(Object key) {
int h;
// key 为 null 时 hash 固定为 0,这就是为什么 HashMap 允许一个 null key
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

为什么要 h ^ (h >>> 16)?因为真正的下标计算是 (n - 1) & hash。当数组容量 n 较小时(默认 16),n - 1 = 15 = 0b1111,只有低 4 位参与运算,高 28 位全部被丢弃。如果 key 的 hashCode 只有高位变化(这非常常见,比如 Float、Long 的低位常常是 0,或者自定义对象只在高位写入了 ID),所有元素就会挤在同一个槽里。扰动函数把高 16 位右移后与低 16 位异或,把高位的随机性”混入”低位,用一次异或(单周期指令)换来了低位雪崩效应,性价比极高。

用实验直观验证一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class HashDisturbDemo {
public static void main(String[] args) {
// 构造一批"只有高位不同"的 hashCode:低 16 位全是 0
int[] codes = new int[8];
for (int i = 0; i < codes.length; i++) {
codes[i] = (i << 28); // 高 4 位变化,低 28 位为 0
}

int n = 16; // 初始容量
System.out.println("== 无扰动:直接 (n-1) & hashCode ==");
for (int c : codes) {
System.out.printf("hashCode=%08x index=%d%n", c, (n - 1) & c);
}
// 输出全部是 index=0 → 8 个元素全挤在一个槽,退化成链表

System.out.println("== 有扰动:(n-1) & (h ^ (h>>>16)) ==");
for (int c : codes) {
int h = c ^ (c >>> 16);
System.out.printf("hashCode=%08x hash=%08x index=%d%n", c, h, (n - 1) & h);
}
// 分布到 0,1,2,3... 不同槽位,冲突消失
}
}

JDK 7 的扰动函数做了 4 次异或(把高低位反复混合),JDK 8 简化为 1 次。原因有二:一是 JDK 8 引入了红黑树,即使低位碰撞严重也有 O(log n) 兜底;二是实测表明 1 次异或对常见 hashCode 已经足够,多做无谓的 CPU 消耗。

2.4 为什么容量必须是 2 的幂

这是最经典的一道题,答案是为了用位运算取代取模,而位运算的成立需要 n 是 2 的幂。

数学等价性证明:设 n = 2^k,则 n - 1 = 2^k - 1,其二进制为 k 个连续的 1(例如 k=4 时,n-1 = 15 = 0b1111)。对任意非负整数 h,把它写成 h = q · 2^k + r,其中 0 ≤ r < 2^k(这正好是带余除法定义,r 为余数)。由于 2^k 的二进制是第 k 位为 1、后面全 0,所以 q · 2^k 的低 k 位全为 0,而 r 恰好占据低 k 位。因此:

1
h & (2^k - 1) = (q·2^k + r) & (2^k - 1) = r = h mod 2^k

也就是说,当且仅当 n = 2^k 时,h & (n-1) 与 h % n 完全等价。若 n 不是 2 的幂(比如 n=10),n-1 = 9 = 0b1001,第 1、2 位恒为 0,那些位永远是 0,槽位 2、3、6、7 永远用不上,分布直接崩坏。

性能上,在 x86 上 & 是 1 个时钟周期,而 % 需要调用整数除法指令,约 20~40 个周期。HashMap 每次 get/put 都要算一次下标,这是热路径上的关键优化。

容量取 2 的幂还带来一个额外红利,这是 JDK 8 resize 能做到”免重哈希”的前提:容量从 oldCap 翻倍到 2·oldCap,相当于多参与了一位(第 k 位)。因此每个元素的新下标只有两种可能——原位 j(第 k 位为 0)或 j + oldCap(第 k 位为 1)。判断条件就是 (e.hash & oldCap) == 0,一次与运算即可,不必重新调用 hash()。这个巧思在 3.5 节展开。

三、HashMap 源码精读(JDK 8)

3.1 核心字段与常量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// java.util.HashMap 静态常量与实例字段(节选,JDK 8)
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 默认初始容量 16
static final int MAXIMUM_CAPACITY = 1 << 30; // 最大容量 2^30
static final float DEFAULT_LOAD_FACTOR = 0.75f; // 默认装载因子

static final int TREEIFY_THRESHOLD = 8; // 链表长度 >= 8 时考虑树化
static final int UNTREEIFY_THRESHOLD = 6; // 树中节点 <= 6 时退化为链表
static final int MIN_TREEIFY_CAPACITY = 64; // 树化的最小表容量,否则优先扩容

transient Node<K,V>[] table; // 哈希桶数组,长度恒为 2 的幂
transient Set<Map.Entry<K,V>> entrySet;
transient int size; // 实际键值对个数
transient int modCount; // 结构化修改次数,用于 fail-fast
int threshold; // 扩容阈值 = capacity * loadFactor
final float loadFactor; // 装载因子,final 一经构造不可变

table 用 transient 修饰,因为 JDK 自己实现了 writeObject/readObject,只序列化有效节点以节省空间。modCount 记录结构性修改(put、remove、clear),迭代器创建时会拷贝一份 expectedModCount,遍历期间两者不等就抛 ConcurrentModificationException。

3.2 tableSizeFor:向上取整到 2 的幂

构造方法传入的 initialCapacity 可以是任意正整数,HashMap 必须把它”规整”为不小于它的最小 2 的幂。

1
2
3
4
5
6
7
8
9
static final int tableSizeFor(int cap) {
int n = cap - 1; // 先减 1:保证 cap 本身是 2 的幂时结果仍是 cap,而不是翻倍
n |= n >>> 1; // 最高位 1 向右扩散 1 位 → 连续 2 个 1
n |= n >>> 2; // 再扩散 2 位 → 连续 4 个 1
n |= n >>> 4; // 再扩散 4 位 → 连续 8 个 1
n |= n >>> 8; // → 连续 16 个 1
n |= n >>> 16; // → 连续 32 个 1
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

原理是位扩散:只要最高位有一个 1,5 次”或右移”就能把它右边所有位全部填成 1,此时 n 的形如 0b00...0111...1,再加 1 就得到 0b00...1000...0,即大于等于原 cap 的最小 2 的幂。先 cap - 1 是为了处理 cap 恰好是 2 的幂的边界:若不减 1,传入 16 会得到 32,白白浪费一半空间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class TableSizeForDemo {
static int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1; n |= n >>> 2; n |= n >>> 4; n |= n >>> 8; n |= n >>> 16;
return (n < 0) ? 1 : (n >= (1 << 30)) ? (1 << 30) : n + 1;
}
public static void main(String[] args) {
int[] inputs = {0, 1, 3, 7, 8, 9, 12, 16, 17, 1000, 1 << 30};
for (int c : inputs) {
System.out.printf("cap=%-10d -> tableSizeFor=%-10d %s%n",
c, tableSizeFor(c), Integer.toBinaryString(tableSizeFor(c)));
}
// cap=9 -> 16(10000);cap=16 -> 16(10000);cap=17 -> 32(100000)
}
}

需要强调:tableSizeFor 只在构造时把结果赋给 threshold,真正的数组要等到第一次 put 时由 resize() 分配。这是 HashMap 的懒加载(lazy init)设计,避免 new HashMap<>(1000) 却一个元素都不放的内存浪费。

3.3 putVal:一次 put 的完整生命周期

这是 HashMap 最重要的方法,逐行拆解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;

// 【步骤 1】表为空或长度为 0 → 先扩容(懒初始化就发生在这里)
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;

// 【步骤 2】目标槽位为空 → 直接新建节点放进去,无冲突,最快路径
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);

else { // 【步骤 3】槽位非空,发生冲突
Node<K,V> e; K k;

// 3.1 先看桶中第一个节点是不是就是要找的 key
if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
e = p;

// 3.2 若是红黑树节点,走树的插入逻辑
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>) p).putTreeVal(this, tab, hash, key, value);

// 3.3 否则是链表:从第二个节点开始逐个遍历
else {
for (int binCount = 0; ; ++binCount) {
// 遍历到尾部仍没找到 → 尾插法插入新节点(JDK 8 改为尾插)
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// binCount 从 0 起,插入后链表长度为 binCount+2;
// 当 binCount >= 7 即链表长度达到 9 时触发 treeifyBin(内部还会判断 64)
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, hash);
break;
}
// 找到相同 key,跳出循环,交给下面的覆盖逻辑
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}

// 【步骤 4】e != null 说明是"覆盖"而非"新增"
if (e != null) {
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e); // LinkedHashMap 的钩子:访问序维护
return oldValue; // 返回旧值,这是 put 返回值的来源
}
}

// 【步骤 5】新增节点,结构化修改 + 判断是否需要扩容
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict); // LinkedHashMap 的钩子:淘汰最老节点
return null; // 新增返回 null
}

判断 key 相等的条件是 p.hash == hash && (p.key == key || key.equals(k)),两个条件缺一不可:先比 hash 是为了快(一个 int 比较就能过滤掉绝大多数不等的 key),再比 equals 是为了准(hash 相等不代表对象相等)。这也解释了为什么重写 equals 必须重写 hashCode——否则两个逻辑相等的对象 hash 不同,会被当成两个 key。

3.4 treeifyBin 与泊松分布:阈值为什么是 8

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
// 表容量还不到 64:优先扩容而不是树化
// 因为小表上链表长多半是"容量太小"造成的,扩容能直接把链拆开,代价更低
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null;
do { // 第一步:把普通 Node 逐个替换为 TreeNode,仍保持链表形态(prev/next)
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null) hd = p;
else { p.prev = tl; tl.next = p; }
tl = p;
} while ((e = e.next) != null);
// 第二步:真正把这条双向链表转换成红黑树
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}

树化阈值的选取不是拍脑袋,JDK 源码注释里给出了依据:在装载因子 0.75 下,单个桶中元素个数服从参数约为 0.5 的泊松分布,长度达到 8 的概率仅约千万分之六。

桶中元素个数 泊松分布概率(λ≈0.5) 累计影响
0 0.60653066 60.7% 的桶是空的
1 0.30326533 —
2 0.07581633 —
3 0.01263606 —
4 0.00157952 —
5 0.00015795 —
6 0.00001316 —
7 0.00000094 —
8 0.00000006 约 6e-8,几乎不可能自然发生

结论很清晰:正常 hashCode 下链表长度几乎不可能到 8。一旦到了,几乎可以断定是哈希攻击(恶意构造同 hash 的 key)或 hashCode 实现极差,此时树化把 O(n) 降到 O(log n) 是必要的防护。

两个细节:

  • 为什么退化阈值是 6 而不是 8? 留出 2 的缓冲带( hysteresis,滞后区间)。若在 8 附近反复增删,没有缓冲就会频繁链表↔树互转,每次转换都要重建结构,代价很高。
  • 为什么还要 MIN_TREEIFY_CAPACITY=64? 表容量小于 64 时,链表变长的根因往往是表太小而非 hash 太差,此时一次 resize() 就能把长链一分为二,比建树划算得多。

3.5 resize:JDK 8 的高低位拆分

扩容是 HashMap 最精妙的部分。JDK 8 的核心优化是:不需要重新计算 hash,只用一个与运算就能把一条链拆成两条。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// java.util.HashMap#resize(链表拆分部分,节选)
else { // preserve order —— 保持链表原有相对顺序
Node<K,V> loHead = null, loTail = null; // 低位链:留在原下标 j
Node<K,V> hiHead = null, hiTail = null; // 高位链:迁到新下标 j + oldCap
Node<K,V> next;
do {
next = e.next;
// 关键判断:hash 在 oldCap 那一位上是 0 还是 1
if ((e.hash & oldCap) == 0) {
if (loTail == null) loHead = e; else loTail.next = e;
loTail = e;
} else {
if (hiTail == null) hiHead = e; else hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);

if (loTail != null) { loTail.next = null; newTab[j] = loHead; }
if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; }
}

为什么容量翻倍能让元素均匀拆分? 因为容量从 oldCap = 2^k 变为 2^(k+1),掩码从 2^k - 1(k 个 1)变为 2^(k+1) - 1(k+1 个 1),多参与了恰好一个新的二进制位——也就是 oldCap 那一位(值为 2^k)。对于原下标为 j 的元素,新下标只可能是:

  • 若 hash & oldCap == 0(该位为 0)→ 新下标仍为 j;
  • 若 hash & oldCap != 0(该位为 1)→ 新下标为 j | oldCap,即 j + oldCap。

举例:oldCap = 16,某 key 的 hash 低 5 位为 10101。原掩码 15 = 0b01111,下标 = 0b0101 = 5;新掩码 31 = 0b11111,新下标 = 0b10101 = 21 = 5 + 16。一次扩容相当于”把一个桶按第 k 位劈成两半”,且由于 hash 的随机性,两半元素数量期望各占一半——扩容后链表长度直接减半,这正是扩容能降低冲突的根本原因。

树节点也有对应的 TreeNode.split(),逻辑相同,多了一步判断拆分后的链表长度是否 ≤ UNTREEIFY_THRESHOLD(6),是则 untreeify 退化回链表。

3.6 getNode 与 removeNode

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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) {

// 1. 先检查桶头,命中率最高(多数桶只有 1 个元素)
if (first.hash == hash && ((k = first.key) == key || (key != null && key.equals(k))))
return first;

if ((e = first.next) != null) {
// 2. 红黑树走 O(log n) 查找
if (first instanceof TreeNode)
return ((TreeNode<K,V>) first).getTreeNode(hash, key);
// 3. 链表线性遍历
do {
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}

removeNode 的结构与 getNode 完全对称,只是多了一句 ++modCount 和 --size,以及在红黑树删除后调用 moveRootToFront 保证桶头始终是树根。删除后不会自动退化成链表,退化只发生在扩容的 split 阶段(JDK 8 的行为)。

3.7 JDK 7 头插法与并发死循环推演

这是 Java 面试史上最著名的一道题。JDK 7 的扩容用 transfer 方法,采用头插法且不加锁:

1
2
3
4
5
6
7
8
9
10
11
12
13
// JDK 7 java.util.HashMap#transfer(已废弃,仅用于说明问题)
void transfer(Entry<K,V>[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) { // 遍历旧表每个桶
while (null != e) {
Entry<K,V> next = e.next; // ① 记住下一个
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i]; // ② 头插:新节点指向桶中原头节点
newTable[i] = e; // ③ 新节点成为桶头
e = next; // ④ 处理下一个
}
}
}

头插法本身在单线程下没问题(还能利用缓存局部性),但它会反转链表顺序。一旦两个线程同时扩容,反转 + 共享引用就会织出环。

环形链表形成过程推演(设旧桶中有 A → B → C 三个节点,它们在新表中仍映射到同一桶):

  1. 线程 T1 执行到 ① 处挂起,此时 e = A,next = B;
  2. 线程 T2 完整跑完 transfer。由于头插反转,新桶中的顺序变为 C → B → A,即 C.next = B、B.next = A、A.next = null。注意此时是同一个堆内存上的节点对象,T1 持有的 e/next 引用依然指向 A 和 B;
  3. T1 恢复执行,e = A,next = B。执行 ②:A.next = newTable[i],而 newTable[i] 现在是 C,于是 A.next = C;执行 ③:newTable[i] = A;执行 ④:e = B;
  4. T1 下一轮,next = B.next,而 B.next 在 T2 的处理后指向 A,于是 next = A。执行 ②:B.next = A(已经是了);③:newTable[i] = B;④:e = A;
  5. T1 再下一轮,next = A.next,而第 3 步已经把 A.next 改成了 C,所以 next = C。执行 ②:A.next = C;③:newTable[i] = A;④:e = C;
  6. 此时结构为 newTable[i] = A,A.next = C,C.next = B,B.next = A——A → C → B → A,环形成了。

后续任何一次 get() 命中这个桶,就会在环里无限循环,while (e != null) 永不终止,CPU 直接打满 100%。这就是传说中的”HashMap 并发死循环导致 CPU 100%”。同时因为两个线程互相覆盖 newTable[i],还会造成数据丢失。

JDK 8 改成尾插法,扩容时保持链表原有顺序(preserve order 注释即来源于此),从根源上消除了成环的可能。但必须强调:JDK 8 的 HashMap 依然不是线程安全的,它只是把”死循环”降级成了”数据覆盖/丢失”,多线程场景仍然要 ConcurrentHashMap。

面试时最容易答错的一点:JDK 8 修复的不是线程安全问题,只是环形链表。多线程同时 put 仍会丢数据(两个线程同时算出同一槽位为空,后写的覆盖先写的);size 字段非 volatile、非原子,计数会偏小;put 与 get 并发还可能读到半初始化状态。结论不变——并发用 ConcurrentHashMap,或者外部加锁。

四、HashMap 使用禁忌与实战

4.1 key 的选择:为什么推荐 String / Integer

String 和 Integer 是最理想的 key,原因有三:不可变(immutable)、hashCode 缓存且分布良好、equals 语义稳定。

可变对象作 key 是一场灾难——对象状态改变后 hashCode 随之改变,导致它”躺在错误的桶里”,get 永远找不到,remove 也删不掉,最终内存泄漏。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import java.util.*;

public class MutableKeyDisaster {
static class User {
int id;
String name;
User(int id, String name) { this.id = id; this.name = name; }
@Override public int hashCode() { return Objects.hash(id, name); }
@Override public boolean equals(Object o) {
if (!(o instanceof User)) return false;
User u = (User) o;
return u.id == this.id && Objects.equals(u.name, this.name);
}
@Override public String toString() { return "User{id=" + id + ", name='" + name + "'}"; }
}

public static void main(String[] args) {
Map<User, String> map = new HashMap<>();
User u = new User(1, "Tom");
map.put(u, "VIP");

System.out.println("修改前 get: " + map.get(u)); // VIP
System.out.println("修改前 containsKey: " + map.containsKey(u)); // true

u.id = 2; // 修改了参与 hashCode 计算的字段!

System.out.println("修改后 get: " + map.get(u)); // null —— 找不到了
System.out.println("修改后 containsKey: " + map.containsKey(u)); // false
System.out.println("size 仍是: " + map.size()); // 1 —— 元素还在,但永远访问不到
System.out.println("map.remove(u) 返回: " + map.remove(u)); // null,删不掉 → 内存泄漏
System.out.println("remove 后 size: " + map.size()); // 仍然是 1
}
}

铁律:作为 key 的对象必须不可变,或者至少保证参与 equals/hashCode 的字段在放入 Map 后不再改变。 如果非要用可变对象,推荐用其中的不可变字段(如 id)单独作为 key。

4.2 重写 equals 必须重写 hashCode

这不是”建议”,而是 Object.hashCode() 的通用契约:

  1. 同一对象多次调用 hashCode() 必须返回相同值(前提是用于 equals 比较的信息未被修改);
  2. a.equals(b) 为 true,则 a.hashCode() == b.hashCode() 必须成立;
  3. a.hashCode() == b.hashCode() 不要求 a.equals(b)(允许哈希碰撞)。

只重写 equals 会违反第 2 条:两个逻辑相等的对象 hash 不同,HashMap 会把它们放进不同桶,导致 map.get(new User(1,"Tom")) 返回 null,或者出现”逻辑上重复”的两个条目。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import java.util.*;

public class EqualsHashCodeContract {
/** 只重写 equals 的错误示范 */
static class BadKey {
int id;
BadKey(int id) { this.id = id; }
@Override public boolean equals(Object o) {
return o instanceof BadKey && ((BadKey) o).id == this.id;
}
}

/** 正确示范:equals 与 hashCode 使用同一组字段 */
static class GoodKey {
private final int id;
private final String name;
GoodKey(int id, String name) { this.id = id; this.name = name; }
@Override public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof GoodKey)) return false;
GoodKey g = (GoodKey) o;
return id == g.id && Objects.equals(name, g.name);
}
// 用 31 作为乘子:31 是奇素数,且 31*i 可被 JVM 优化为 (i<<5)-i
@Override public int hashCode() {
int result = Integer.hashCode(id);
result = 31 * result + (name != null ? name.hashCode() : 0);
return result;
}
}

public static void main(String[] args) {
Map<BadKey, String> bad = new HashMap<>();
bad.put(new BadKey(1), "A");
System.out.println("只重写 equals: " + bad.get(new BadKey(1))); // null,错误!

Map<GoodKey, String> good = new HashMap<>();
good.put(new GoodKey(1, "Tom"), "A");
System.out.println("equals+hashCode 全重写: " + good.get(new GoodKey(1, "Tom"))); // A
}
}

JDK 7+ 可以直接用 Objects.hash(f1, f2, ...),它内部就是 Arrays.hashCode,等价但更简洁(代价是会创建 Object[],极端热路径可手写)。

4.3 遍历方式与性能对比

四种遍历方式里,entrySet 明显优于 keySet——后者对每个 key 都要再查一次表,等于做了 N 次 getNode。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import java.util.*;

public class TraversalBenchmark {
public static void main(String[] args) {
Map<Integer, Integer> map = new HashMap<>();
int n = 2_000_000;
for (int i = 0; i < n; i++) map.put(i, i);

long sum = 0, t;

// 方式一:keySet + get —— 最慢,多 N 次哈希查找
t = System.currentTimeMillis(); sum = 0;
for (Integer k : map.keySet()) sum += map.get(k);
System.out.println("keySet+get: " + (System.currentTimeMillis() - t) + "ms, sum=" + sum);

// 方式二:entrySet + for-each —— 经典写法,推荐
t = System.currentTimeMillis(); sum = 0;
for (Map.Entry<Integer, Integer> e : map.entrySet()) sum += e.getKey() + e.getValue();
System.out.println("entrySet: " + (System.currentTimeMillis() - t) + "ms, sum=" + sum);

// 方式三:forEach(BiConsumer) —— JDK 8,最快,无迭代器对象开销
long[] box = {0};
t = System.currentTimeMillis();
map.forEach((k, v) -> box[0] += k + v);
System.out.println("forEach: " + (System.currentTimeMillis() - t) + "ms, sum=" + box[0]);

// 方式四:Iterator 显式迭代 —— 与方式二等价,唯一支持安全 remove
t = System.currentTimeMillis(); sum = 0;
for (Iterator<Map.Entry<Integer, Integer>> it = map.entrySet().iterator(); it.hasNext(); ) {
Map.Entry<Integer, Integer> e = it.next();
sum += e.getValue();
// it.remove(); // 只有迭代器的 remove 不会触发 ConcurrentModificationException
}
System.out.println("Iterator: " + (System.currentTimeMillis() - t) + "ms, sum=" + sum);
}
}

结论:只读遍历用 forEach 最快;需要边遍历边删除用 Iterator.remove() 或 JDK 8 的 removeIf;永远不要在 for-each 里调用 map.remove(k)。另外在 key 为 null 或 value 很大时,values() 与 keySet() 视图仍有价值——它们是零拷贝的视图,修改会反映到原 Map。

4.4 初始容量设置与内存开销

反复扩容的代价是:一次 resize 要重建数组并搬迁全部元素。如果预知要放 N 个元素,应在构造时就给出容量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class InitialCapacity {
/**
* 计算避免扩容的初始容量:使 capacity * 0.75 >= expectedSize
* 即 capacity >= expectedSize / 0.75 = expectedSize * 4 / 3
* HashMap 内部还会 tableSizeFor 向上取整到 2 的幂,所以这里直接给期望值即可
*/
static int capacityFor(int expectedSize) {
return (int) ((float) expectedSize / 0.75f + 1.0f);
}

public static void main(String[] args) {
for (int n : new int[]{3, 12, 100, 1000, 10_000, 1_000_000}) {
int cap = capacityFor(n);
System.out.printf("expected=%-9d 建议 initialCapacity=%-9d 实际 table 长度=%d%n",
n, cap, Integer.highestOneBit(Math.max(1, cap - 1)) << 1);
}
// 注意:new HashMap<>(capacityFor(n)) 是"容量",不是"元素个数"
Map<String, String> m = new HashMap<>(capacityFor(1000));
}
}

常见误区:把”预计元素个数”直接传给构造器。new HashMap<>(1000) 的实际阈值是 1024 * 0.75 = 768,放 1000 个元素仍会扩容一次。正确写法是 new HashMap<>((int)(1000 / 0.75f) + 1),即 1334。阿里巴巴 Java 开发手册也强制要求”集合初始化时指定初始值大小”。

内存开销估算(64 位 HotSpot,开启压缩指针 -XX:+UseCompressedOops):

组成 大小 说明
Node 对象头 8 字节 Mark Word 4B + Klass Pointer 4B
Node.hash (int) 4 字节 缓存的扰动后 hash
Node.key 引用 4 字节 压缩指针
Node.value 引用 4 字节 压缩指针
Node.next 引用 4 字节 压缩指针
对齐填充 4 字节 对齐到 8 字节倍数
单个 Node 合计 32 字节 —
table 数组槽位 4 字节/槽 引用数组,即使为 null 也占空间
数组实际占用 cap × 4 ÷ 0.75 期望 装载因子导致约 1/4 槽为空

粗略公式:总开销 ≈ 32 × size + 4 × capacity。存 100 万个 Entry,Node 本身约 32 MB,数组约 4~8 MB。所以在大容量场景下,HashMap<Long, Long> 的空间放大率可达 4~8 倍,这时应考虑 long 原生类型 map(fastutil、Eclipse Collections)或改用数组/堆外内存。

五、LinkedHashMap:顺序与 LRU 缓存

5.1 双向链表的维护与三个钩子

LinkedHashMap 继承 HashMap,几乎不重写核心算法,而是通过 HashMap 预留的三个钩子方法注入顺序语义:

  • afterNodeAccess(Node e):节点被访问(get/put 覆盖)后调用,把节点移到链表尾部(仅 accessOrder=true 时);
  • afterNodeInsertion(boolean evict):插入后调用,配合重写 removeEldestEntry 可实现自动淘汰;
  • afterNodeRemoval(Node e):删除后调用,从双向链表摘除。

它在 Node 基础上扩展了 before/after 两个指针,并维护 head(最老)与 tail(最新)两个引用。代价是每节点多 8 字节、每次操作多几次指针赋值,换来的是可预测的迭代顺序——HashMap 的迭代顺序是不确定的,扩容后会变。

5.2 用 accessOrder 实现 LRU 缓存

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import java.util.*;

/**
* 基于 LinkedHashMap 的固定容量 LRU 缓存
* accessOrder=true:按访问顺序排序,get/put 都会把元素移到尾部
*/
public class LruCache<K, V> extends LinkedHashMap<K, V> {
private final int maxSize;

public LruCache(int maxSize) {
// 第三个参数 accessOrder 必须为 true,否则是插入序(FIFO)而非访问序
super((int) (maxSize / 0.75f) + 1, 0.75f, true);
this.maxSize = maxSize;
}

/** 每次 put/putAll 后由 afterNodeInsertion 回调,返回 true 则淘汰头节点(最久未访问) */
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > maxSize;
}

public static void main(String[] args) {
LruCache<Integer, String> cache = new LruCache<>(3);
cache.put(1, "Java");
cache.put(2, "Python");
cache.put(3, "Go");
System.out.println(cache.keySet()); // [1, 2, 3]

cache.get(1); // 访问 1,把它移到最新
System.out.println(cache.keySet()); // [2, 3, 1]

cache.put(4, "Rust"); // 超出容量,淘汰最久未访问的 2
System.out.println(cache.keySet()); // [3, 1, 4]

cache.put(3, "Golang"); // 覆盖已存在的 key,也会刷新到尾部
System.out.println(cache.keySet()); // [1, 4, 3]
}
}

写 LRU 有两个坑:一是 accessOrder 忘了传 true,退化成 FIFO;二是 removeEldestEntry 里用 >= 而不是 >,导致容量永远差 1。

LRU 与 LFU 的区别:LRU(Least Recently Used)只看”最后一次访问时间”,淘汰最久没被碰过的;LFU(Least Frequently Used)看”访问总次数”,淘汰访问频率最低的。LRU 对”偶发批量扫描”不友好(一次全表扫描会把热数据全挤走),LFU 则对”历史热点”不友好(老热点永远不淘汰)。生产环境(Caffeine、Redis)一般使用 W-TinyLFU 或 LRU + 分段(Segmented LRU) 来兼顾两者。LinkedHashMap 只能实现 LRU,要实现 LFU 需要自己维护频次表或使用 Caffeine。

另外必须提醒:这个 LRU 不是线程安全的。多线程场景要么用 Collections.synchronizedMap 包裹(但迭代仍要手动加锁),要么直接用 Caffeine/Guava Cache。

六、TreeMap:有序映射与范围查询

6.1 红黑树的五条性质

TreeMap 底层是红黑树(Red-Black Tree),一种自平衡二叉查找树。它必须满足五条性质:

  1. 每个节点要么是红色,要么是黑色;
  2. 根节点是黑色;
  3. 所有叶子节点(NIL 空节点)是黑色;
  4. 红色节点的两个子节点都必须是黑色(不能有连续的红节点);
  5. 从任一节点到其所有后代 NIL 节点的路径上,黑色节点数量相同(黑高一致)。

性质 4 与性质 5 共同约束了树的形态:最长路径不超过最短路径的 2 倍,因此树高始终为 O(log n),保证了 get/put/remove 的最坏时间复杂度是 O(log n)。红黑树不像 AVL 树那样追求严格平衡(AVL 要求左右子树高度差 ≤ 1),它的旋转次数更少,插入删除性能更稳定,这也是 std::map、Linux 内核、HashMap 的树化桶都选择它的原因。

6.2 有序性操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import java.util.*;

public class TreeMapRangeDemo {
public static void main(String[] args) {
NavigableMap<Integer, String> scores = new TreeMap<>();
scores.put(95, "Alice");
scores.put(72, "Bob");
scores.put(88, "Carol");
scores.put(60, "Dave");
scores.put(100, "Eve");

System.out.println("自然升序: " + scores); // {60=Dave, 72=Bob, 88=Carol, 95=Alice, 100=Eve}

// 端点访问
System.out.println("firstKey: " + scores.firstKey()); // 60
System.out.println("lastKey: " + scores.lastKey()); // 100

// 就近查找:floor <= key,ceiling >= key,lower < key,higher > key
System.out.println("floorKey(85): " + scores.floorKey(85)); // 72 最接近且 <= 85
System.out.println("ceilingKey(85): " + scores.ceilingKey(85)); // 88 最接近且 >= 85
System.out.println("lowerKey(88): " + scores.lowerKey(88)); // 72 严格小于
System.out.println("higherKey(88): " + scores.higherKey(88)); // 95 严格大于

// 范围视图(左闭右开,与原 Map 联动,是视图不是拷贝!)
SortedMap<Integer, String> sub = scores.subMap(70, 95);
System.out.println("[70,95): " + sub); // {72=Bob, 88=Carol}
System.out.println("headMap(88): " + scores.headMap(88)); // 严格小于 88
System.out.println("tailMap(88): " + scores.tailMap(88)); // 大于等于 88

// 降序遍历与端点弹出
System.out.println("降序: " + scores.descendingMap());
System.out.println("pollFirstEntry: " + ((TreeMap<Integer, String>) scores).pollFirstEntry());

// sub 是视图:修改视图会反映到原 Map,反之亦然;对视图越界写入会抛 IllegalArgumentException
// sub.put(99, "Frank"); // IllegalArgumentException: key out of range
}
}

TreeMap 的 key 不能为 null(自然排序下会抛 NullPointerException),因为 compareTo 无法与 null 比较。若必须支持 null key,需要传入一个能处理 null 的 Comparator,此时 null 会被当作正常值参与排序——但这是”第二棵树”的语义,官方并不推荐。

6.3 Comparable 与 Comparator 的选型

TreeMap 有两种排序来源:

排序方式 定义位置 侵入性 适用场景
Comparable(自然排序) key 类内部实现 compareTo 强侵入,一个类只能有一种 有唯一天然顺序,如 Integer、String、按 id 排序的实体
Comparator(定制排序) 外部传入构造器 new TreeMap<>(cmp) 无侵入,可定义多种 多维度排序、第三方类的 key、临时反序需求
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import java.util.*;

public class TreeMapComparatorDemo {
static class Student {
String name; int score; int age;
Student(String name, int score, int age) { this.name = name; this.score = score; this.age = age; }
int getScore() { return score; } // Comparator 的方法引用需要实例 getter
int getAge() { return age; }
@Override public String toString() { return name + "(" + score + ")"; }
}

public static void main(String[] args) {
// 定制排序:分数降序,分数相同按年龄升序
Comparator<Student> cmp = Comparator
.comparingInt(Student::getScore).reversed()
.thenComparingInt(Student::getAge);

NavigableMap<Student, Integer> rank = new TreeMap<>(cmp);
rank.put(new Student("Alice", 95, 20), 1);
rank.put(new Student("Bob", 95, 18), 2);
rank.put(new Student("Carol", 88, 22), 3);

rank.forEach((s, i) -> System.out.println(s + " -> rank " + i));
// Alice(95) rank1, Bob(95) rank2, Carol(88) rank3 —— Bob 年龄小排在 Alice 之后?
// 注意 reversed() 只作用于第一个比较器,thenComparing 的年龄仍是升序,
// 所以同为 95 分时年龄小的 Bob 排在前面

// 陷阱示范:Comparator 必须与 equals 保持一致
// 若 cmp 认为 a == b(返回 0),TreeMap 会把它们视为同一个 key,
// 即使 a.equals(b) 为 false,后 put 的会覆盖前者
Map<Student, Integer> byNameOnly = new TreeMap<>(Comparator.comparing((Student s) -> s.name));
byNameOnly.put(new Student("Alice", 95, 20), 1);
byNameOnly.put(new Student("Alice", 60, 30), 2); // 覆盖了上面那条
System.out.println("size = " + byNameOnly.size()); // 1,而不是 2
}
}

最后一个陷阱在实战中极常见:TreeMap 判断 key 相等的准则是 compare(a,b) == 0,与 equals 完全无关。如果 Comparator 写得比 equals 宽松(比如只比 name 不比 score),就会出现”看起来不同的两个对象被当成同一个 key”。官方建议 compareTo 与 equals 保持一致(SortedMap 文档明确写了这点),不一致时应显式注明。

性能上,TreeMap 的 get/put/remove 为 O(log n),且没有哈希计算、没有扩容、没有红黑树与链表的转换,因此元素量不大(几千以内)且需要排序时,TreeMap 往往是比”HashMap + 排序”更省心的选择。排序稳定性:TreeMap 的迭代顺序完全由比较器决定,只要比较器是确定的,顺序就是确定且稳定的。

七、Hashtable、Properties 与线程安全 Map

7.1 Hashtable 为什么被淘汰

Hashtable 是 JDK 1.0 的遗留类,被淘汰有三个硬伤:

  1. 全表锁:几乎所有 public 方法都用 synchronized 修饰,锁的是整个 this,并发度为 1。100 个线程同时读都会串行;
  2. 不允许 null:key 与 value 都不能为 null,否则 NullPointerException(它的 hash 直接调用 key.hashCode());
  3. API 老旧:继承自 Dictionary 抽象类而非 Map 体系(JDK 2 才被改造适配 Map),还有 elements()、keys() 这种返回 Enumeration 的历史方法,无法与 Collections 工具类无缝配合。

另外它的默认容量是 11(不是 2 的幂),扩容是 2n+1,用的是取模而非位运算,性能也不如 HashMap。

7.2 Collections.synchronizedMap 的装饰器陷阱

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import java.util.*;

public class SynchronizedMapTrap {
public static void main(String[] args) throws InterruptedException {
Map<String, Integer> raw = new HashMap<>();
Map<String, Integer> sync = Collections.synchronizedMap(raw);

// 正确:单个操作是线程安全的(内部 synchronized(mutex))
sync.put("a", 1);

// 陷阱一:复合操作(check-then-act)不是原子的,即使每个方法都加锁
// 下面这段在并发下会覆盖:两个线程同时判断为 null,然后都 put
if (sync.get("b") == null) {
sync.put("b", 2); // 非原子!
}
// 正确写法必须手动在 mutex 上加锁
synchronized (sync) {
if (sync.get("b") == null) {
sync.put("b", 2);
}
}
// 更简洁的正确写法:用并发容器
ConcurrentHashMap<String, Integer> chm = new ConcurrentHashMap<>();
chm.putIfAbsent("b", 2);
chm.computeIfAbsent("c", k -> 3);

// 陷阱二:迭代时仍必须手动同步,否则 ConcurrentModificationException
// 官方 Javadoc 明确要求:
synchronized (sync) {
for (Map.Entry<String, Integer> e : sync.entrySet()) {
System.out.println(e.getKey() + "=" + e.getValue());
}
}

// 陷阱三:sync 与 raw 共享同一份数据,绕过 sync 直接操作 raw 就失去了保护
raw.put("unsafe", 0); // 危险!
}
}

Collections.synchronizedMap 返回的是 SynchronizedMap 内部类,它持有 mutex 对象(默认就是自身),把每个方法包在 synchronized (mutex) 里。它解决的是”单个方法的原子性”,解决不了”多个方法组成的复合逻辑的原子性”,也解决不了迭代期间的一致性。

7.3 ConcurrentHashMap 的演进

ConcurrentHashMap 的锁粒度经历了两代演进:

版本 并发控制 结构 并发度 读操作
JDK 7 Segment 分段锁(继承 ReentrantLock) Segment 数组 + HashEntry 数组 + 链表 默认 16,构造后不可变 无锁 volatile 读
JDK 8 / 17 CAS + synchronized 锁单个桶头 Node 数组 + 链表 + 红黑树,废弃 Segment 等于桶数量,随扩容增长 无锁 volatile 读 + 树遍历

JDK 8 的思路是把锁的粒度降到单个桶:插入时先 CAS 尝试写入空桶;失败说明有冲突,就 synchronized (f) 锁住桶头节点再挂链表。由于同一时刻竞争同一个桶的线程极少,实际并发度远高于 JDK 7 的固定 16。size() 用 CounterCell 分片计数(类似 LongAdder),避免单点竞争。扩容还支持多线程协同迁移(ForwardingNode + transferIndex),这是另一个大话题,我们留到并发篇展开。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import java.util.concurrent.*;

public class ConcurrentMapDemo {
public static void main(String[] args) throws InterruptedException {
ConcurrentHashMap<String, Long> counter = new ConcurrentHashMap<>();

// 单 key 计数:用 LongAdder / AtomicLong 避免 CAS 反复失败
int threads = 8, loops = 100_000;
CountDownLatch latch = new CountDownLatch(threads);
for (int t = 0; t < threads; t++) {
new Thread(() -> {
for (int i = 0; i < loops; i++) {
// 推荐:LongAdder 分段累加,高并发下比 AtomicLong 快一个量级
counter.computeIfAbsent("pv", k -> 0L);
counter.merge("pv", 1L, Long::sum);
}
latch.countDown();
}).start();
}
latch.await();
System.out.println("pv = " + counter.get("pv")); // 800000,精确

// 注意:ConcurrentHashMap 不允许 null key / null value
// counter.put("x", null); // NullPointerException
// 原因:get 返回 null 必须能无歧义地表示"不存在",这在并发下是必需的语义保证

// 弱一致性迭代器:遍历期间允许并发修改,不会抛 CME,但可能看不到最新写入
counter.forEach((k, v) -> System.out.println(k + "=" + v));
}
}

为什么推荐用 ConcurrentHashMap 替代 Hashtable:并发度从 1 提升到接近桶数;读操作完全无锁;提供 putIfAbsent/computeIfAbsent/merge 等原子复合操作,不用外部加锁;迭代器是弱一致性的,不会抛 ConcurrentModificationException,适合大 map 的遍历。唯一代价是不能存 null 值——这是刻意的取舍,因为 get 返回 null 必须能无歧义地表示”key 不存在”。

Properties 是 Hashtable<Object,Object> 的子类,专用于读写 .properties 配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.Properties;

public class PropertiesDemo {
public static void main(String[] args) throws IOException {
Properties props = new Properties();
props.setProperty("db.url", "jdbc:mysql://localhost:3306/test");
props.setProperty("db.user", "root");
props.setProperty("db.poolSize", "20");

// 写出到文件(JDK 9+ 支持指定字符集,避免默认 ISO-8859-1 导致中文乱码)
try (Writer w = new OutputStreamWriter(
new FileOutputStream("app.properties"), StandardCharsets.UTF_8)) {
props.store(w, "demo config");
}

// 从 classpath 读取
try (InputStream in = PropertiesDemo.class.getClassLoader()
.getResourceAsStream("app.properties")) {
if (in != null) {
props.load(new InputStreamReader(in, StandardCharsets.UTF_8));
System.out.println(props.getProperty("db.url"));
System.out.println(props.getProperty("db.poolSize", "10")); // 带默认值
}
}
}
}

注意 Properties 的 getProperty 返回 String,而继承自 Hashtable 的 get/put 返回 Object,不要混用——用 put 存非 String 值会导致 store() 写不出来。

八、高频面试题解析

  1. HashMap 的容量为什么必须是 2 的幂?
    为了用 (n-1) & hash 取代 hash % n。当 n = 2^k 时,n-1 的二进制是 k 个连续的 1,按带余除法 h = q·2^k + r,q·2^k 低 k 位全 0,故 h & (2^k-1) = r = h % 2^k,两者严格等价。位运算比取模快一个数量级。副产品是扩容时可用 (e.hash & oldCap) 免重哈希拆分。

  2. HashMap 什么时候扩容?扩容做什么?
    当 ++size > threshold(threshold = capacity × loadFactor)时扩容为 2 倍。JDK 8 的 resize 分三步:计算新容量与新阈值、新建数组、搬迁元素。搬迁时对每个桶按 (e.hash & oldCap) == 0 拆成低位链(留原位)和高位链(移到 j + oldCap),链表保持原顺序(尾插),树节点走 split,拆分后长度 ≤ 6 则退化成链表。

  3. 树化阈值为什么是 8,退化为什么是 6?
    源码注释依据泊松分布:装载因子 0.75 下单桶元素数达 8 的概率约 6e-8,正常 hashCode 几乎不可能出现,出现即说明哈希被恶意攻击,需要红黑树把 O(n) 降到 O(log n)。退化取 6 而非 8 是为了留出滞后区间,避免在阈值附近频繁互转。

  4. 为什么还有个 MIN_TREEIFY_CAPACITY = 64?
    表容量 < 64 时链表变长的根因通常是”表太小”,此时一次 resize 就能把长链劈成两半,代价远低于建树。所以 treeifyBin 里先判断容量,不够就只扩容不树化。

  5. JDK 7 的 HashMap 为什么会在并发下死循环?
    JDK 7 的 transfer 用头插法迁移,会反转链表。两个线程同时扩容时,T2 先完成使链表变为 C→B→A,T1 恢复后按自己持有的旧引用继续头插,最终让 A.next 指回 C,形成 A→C→B→A 的环。此后任何命中该桶的 get 都会在 while (e != null) 里无限循环,CPU 100%。JDK 8 改为尾插法保留原序,从根上消除了成环。

  6. JDK 8 的 HashMap 线程安全了吗?
    没有。只是把”死循环”降级为”数据覆盖丢失”。多线程同时 put 同一空槽会互相覆盖;size++ 非原子导致计数偏小;扩容时也可能丢数据。并发场景请用 ConcurrentHashMap。

  7. 装载因子为什么是 0.75?
    时间与空间的折中。链地址法下查找代价约为 1 + α/2,α 越大冲突越多;但 α 过小则数组利用率低、扩容频繁。0.75 使”链表长度 ≥ 8”的概率降到千万分之一,同时空间利用率可接受,源码注释称其为 “a decent tradeoff”。

  8. 重写 equals 为什么必须重写 hashCode?
    Object.hashCode 契约规定 a.equals(b) == true ⇒ a.hashCode() == b.hashCode()。只重写 equals 会破坏该契约,导致两个逻辑相等的对象算出不同下标,HashMap 把它们存成两条记录,get 返回 null。反过来只重写 hashCode 不重写 equals 同样错误。

  9. HashMap 的 key 可以为 null 吗?Hashtable 和 ConcurrentHashMap 呢?
    HashMap 允许一个 null key(hash() 中特判为 0,固定落在 table[0])和多个 null value。Hashtable 与 ConcurrentHashMap 都不允许 null key/value,因为它们的 get 语义要求”返回 null 无歧义地表示 key 不存在”,并发环境下若允许 null 就无法区分”值为 null”和”不存在”。

  10. put 方法的返回值是什么?
    若 key 原本不存在,返回 null(新增);若 key 已存在,返回被覆盖的旧值。所以不能用 put 返回 null 来判断”原来有没有这个 key”,正确做法是先 containsKey,或用 putIfAbsent。

  11. computeIfAbsent 与 putIfAbsent 有什么区别?
    putIfAbsent(key, value) 的 value 是已计算好的值,无论是否需要都会被求值,构造昂贵时白白浪费;computeIfAbsent(key, Function) 只在缺失时才调用函数,且返回的是当前(新计算或已有的)value。另外 computeIfAbsent 的函数里不能对本 map 做递归修改,否则可能 ConcurrentModificationException 或死锁。

  12. HashMap 与 TreeMap、LinkedHashMap 如何选型?
    默认 HashMap(O(1),无序);需要保持插入顺序或做 LRU 用 LinkedHashMap(accessOrder=true + removeEldestEntry);需要按键排序或做范围查询(floorKey/ceilingKey/subMap)用 TreeMap(O(log n),key 不能为 null)。枚举 key 用 EnumMap,并发用 ConcurrentHashMap。

  13. HashMap 的遍历为什么推荐 entrySet 而不是 keySet?
    keySet 遍历后还要对每个 key 调 get(k),等于多做 N 次哈希定位与比较,时间复杂度从 O(n) 变成”N 次 O(1) 但常数翻倍”。entrySet 直接拿到 key 与 value,最快的是 JDK 8 的 forEach(BiConsumer),没有迭代器对象开销。

  14. 已知要存 1000 个元素,HashMap 初始容量应该设多少?
    设 1334((int)(1000 / 0.75f) + 1),HashMap 内部会 tableSizeFor 向上取整到 2048。直接传 1000 会导致实际阈值 768 < 1000,仍扩容一次。

本篇我们把 Map 家族从接口语义一路读到 JDK 8 的字节码级实现:容量取 2 的幂的数学证明、扰动函数的雪崩效应、tableSizeFor 的位扩散、resize 的高低位拆分、泊松分布支撑的树化阈值,以及 JDK 7 头插法成环的完整推演。下一篇(第十篇)我们将进入并发编程的世界:JMM 内存模型、happens-before、volatile 的可见性与禁止重排序、synchronized 的锁升级(偏向锁→轻量级锁→重量级锁)、CAS 与 AQS 原理,以及本篇预告的 ConcurrentHashMap 多线程协同扩容的完整实现。建议在此之前,把本文的 putVal 与 resize 源码对照本地 src.zip 再精读一遍,那是理解整个并发容器体系的基石。