一文讲透 Java 面试中出镜率最高的集合类:HashMap
你有没有过这种经历——自信满满地去面试,前半段聊项目聊得眉飞色舞,突然面试官轻描淡写来了一句:"聊聊 HashMap 吧。" 你心想这题简单啊,脱口而出"数组+链表+红黑树",正准备展开讲讲,对方直接打断:"那你说说,为什么链表转红黑树的阈值偏偏是 8,不是 7 也不是 9?" 你瞬间愣住,大脑一片空白。
据统计,HashMap 是 Java 面试中出镜率最高的集合类,没有之一。从阿里、美团到字节跳动,从初级到资深,几乎所有 Java 面试都会问到它。更关键的是,面试官问 HashMap,从来不是想听你背八股文,也不是想看你有没有读过源码,他们想要知道的是你到底有没有真正理解设计者的权衡。
这篇文章的目的很纯粹:带你从零开始,把 HashMap 的底裤——不对,底层原理——扒个精光。读完你会发现,HashMap 不仅不难,它的设计思路甚至称得上优雅。
一、先别翻源码——搞清楚 Map 到底是干嘛的
在钻进 HashMap 的源码之前,我们先把最基础的概念对齐一下。
1.1 生活中无处不在的"映射"
想象你有一个通讯录,你想根据"姓名"快速找到"电话号码"。你会怎么做?
- 笨办法:从第一页开始翻,一行一行找,直到找到那个名字。1000 个联系人,最坏情况要翻 1000 次。
- 聪明办法:通讯录本身就是按姓氏拼音排序的,"张"开头的都在某个范围,你直接翻到那个区域,很快就找到了。
这个"根据姓名找电话"的需求,在 Java 里就叫 Map(映射)。姓名是 key,电话是 value,合在一起就是一个键值对(key-value pair)。
// 生活中:姓名 → 电话号码
// Java 中:key → value
Map<String, String> phoneBook = new HashMap<>();
phoneBook.put("张三", "13800138000");
phoneBook.put("李四", "13900139000");
// 想找张三的电话?一句话搞定
String phone = phoneBook.get("张三"); // "13800138000"
1.2 Map 家族的"族谱"
Java 集合框架里,Map 接口有几个重要的实现类,面试也常问它们的区别:
| 实现类 | 底层结构 | 是否有序 | 是否允许 null 键 | 线程安全 | 一句话 |
|---|---|---|---|---|---|
| HashMap | 数组+链表+红黑树 | 无序 | 允许一个 null 键 | 否 | 日常开发首选,面试最爱问 |
| LinkedHashMap | HashMap + 双向链表 | 插入/访问有序 | 允许 | 否 | LRU 缓存的实现基础 |
| TreeMap | 红黑树 | 按键自然排序 | 不允许 | 否 | 需要排序时用它 |
| Hashtable | 数组+链表 | 无序 | 不允许 | 是 | 上古遗产,基本淘汰 |
这篇文章主角是 HashMap,但面试官经常接着问"那 LinkedHashMap 和 TreeMap 呢?",上表请刻在 DNA 里。
好了,概念对齐完成。现在让我们正式进入 HashMap 的世界——从最核心的数据结构开始。
二、HashMap 的骨骼:数组 + 链表 + 红黑树
2.1 为什么不能只用数组?
先思考一个最简单的问题:如果让你自己实现"根据 key 找 value",第一反应是什么?
大概率是数组——把所有键值对放进数组,找的时候遍历数组比对 key。这个方法能用,但查找的时间复杂度是 O(n),数据量大了性能直接拉胯。
想要 O(1) 的查找速度,你需要一种能直接根据 key 算出位置的数据结构。这就是哈希表的思路:
key → [哈希函数] → 数组下标 → 直接定位到 value
理想很丰满,现实很骨感。天底下没有不冲突的哈希函数——两个不同的 key 完全可能算出同一个下标,这就是哈希冲突(Hash Collision)。
2.2 链表登场:拉链法解决冲突
HashMap 采用的是经典的 拉链法(Separate Chaining) 来处理冲突:
数组:[0] [1] [2] [3] ...
↓ ↓ ↓ ↓
链表 链表 链表 链表
↓ ↓
节点 节点
↓
节点
- 数组的每个位置(桶/bucket)存的是一个链表的头节点
- 当哈希冲突发生时,新节点追加到链表尾部
- 查找时,先定位桶位置,再沿着链表一个个比对 key
这样一来,只要哈希函数设计得足够均匀,大部分桶里只有一两个节点,查找接近 O(1)。
2.3 红黑树救场:当链表太长时
拉链法有一个致命缺陷:如果运气不好(或者有人恶意构造大量哈希冲突的 key),某个桶的链表可能变得非常长。最坏情况下,HashMap 退化成 LinkedList,查找从 O(1) 变成 O(n)——这可不是开玩笑的。
JDK 8 的解决方案是:当链表长度超过阈值(8),链表自动转成红黑树。
链表阶段: 红黑树阶段:
[桶] → A → B → C → ... → H [桶] → 红黑树(O(log n) 查找)
查找 O(n),n 越大越慢 查找 O(log n),即使 n 很大也很快
关键认知:红黑树不是为了让 HashMap "更快",而是为了防止 HashMap "变慢"。正常情况下链表根本到不了 8,红黑树是一道"保险丝"。
2.4 一张图看懂 HashMap 的全貌
HashMap 内部结构:
Node<K,V>[] table ←── 主干数组,每个位置叫"桶(bucket)"
|
├── table[0] → Node → Node → ...(链表)
| ↓
| 链表长度>8 且 数组长度≥64 时
| ↓
| TreeNode ↔ TreeNode ↔ ...(红黑树)
|
├── table[1] → Node → TreeNode ↔ TreeNode(已经是红黑树)
├── table[2] → null(空桶)
├── table[3] → Node(单节点)
└── ...
好了,骨架已经搭好。接下来我们进入最刺激的部分——读源码。别怕,我会逐行拆解,保证你看得懂。
三、源码深潜:HashMap 的核心成员变量
翻开 JDK 8 的 HashMap 源码(你可以在 IDE 里直接打开 java.util.HashMap),我们先看几个至关重要的成员变量。面试官问 HashMap,几乎每个问题都跟这些变量有关。
3.1 默认容量与负载因子
// HashMap.java 关键常量
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 16,默认初始容量
static final float DEFAULT_LOAD_FACTOR = 0.75f; // 默认负载因子
static final int MAXIMUM_CAPACITY = 1 << 30; // 最大容量,2^30
面试高频问题:为什么默认容量是 16?
两个原因:
- 16 是 2 的幂,HashMap 用
(n - 1) & hash代替取模运算(后面会详解),2 的幂能保证这个位运算等价于取模。 - 16 是一个经验上的平衡点——太小了频繁扩容,太大了浪费内存。经过大量实战检验,16 作为起手值效果最好。
面试高频问题:为什么负载因子是 0.75?
0.75 是时间与空间的折中点:
- 负载因子
= size / capacity,表示桶的"拥挤程度" - 设为 1.0:桶几乎满了才扩容,空间利用率高,但哈希冲突严重,查找效率低
- 设为 0.5:桶用到一半就扩容,冲突少、查找快,但一半空间被浪费
- 0.75:两者之间的"甜点",在大多数场景下表现最优
这个 0.75 不是拍脑袋定的。JDK 注释里明确提到,这个值符合泊松分布的数学推导,在时间和空间之间取得了最佳平衡。
3.2 树化的两个阈值
static final int TREEIFY_THRESHOLD = 8; // 链表转红黑树的阈值
static final int UNTREEIFY_THRESHOLD = 6; // 红黑树退化为链表的阈值
static final int MIN_TREEIFY_CAPACITY = 64; // 树化前数组的最小长度
面试高频问题:为什么树化阈值是 8?
这是 HashMap 面试中最经典也最能拉开区分度的问题。答案分两层:
第一层(基础答案):根据泊松分布,链表长度达到 8 的概率极低。
JDK 源码注释里有这样一段话:
* 0: 0.60653066
* 1: 0.30326533
* 2: 0.07581633
* 3: 0.01263606
* 4: 0.00157952
* 5: 0.00015795
* 6: 0.00001316
* 7: 0.00000094
* 8: 0.00000006
在良好的哈希函数下,桶中元素数量的分布遵循泊松分布,链表长度达到 8 的概率约为 0.00000006(亿分之六)——也就是说,正常情况下几乎不可能出现。如果真的出现了,说明要么哈希函数出了问题,要么有人恶意攻击,此时转为红黑树来兜底。
第二层(加分答案):平均查找长度的权衡。
| 链表长度 | 平均查找长度(链表) | 平均查找长度(红黑树) |
|---|---|---|
| 7 | 3.5 | ~2.8 |
| 8 | 4.0 | ~3.0 |
| 9 | 4.5 | ~3.2 |
在长度为 8 时,链表查找和树查找的差距已经能体现出红黑树的优势(log₂8 = 3 ≈ 4 的一半),此时转换收益明显。同时,8 和泊松分布的极小概率也呼应上了——"正常不会到 8,到了 8 说明该树化了"。
面试高频问题:退化阈值为什么是 6 而不是 8?
如果退化阈值也是 8,会出现什么问题?想象一下:链表长度在 7 和 8 之间反复横跳,红黑树和链表来回转换——每次转换都是昂贵的操作。设置一个 "缓冲区间"(6 到 8),避免了频繁的树化/退化。
这个设计思想叫迟滞(Hysteresis),跟家里空调不会在设定温度上反复开关是一个道理。
3.3 核心内部类 Node
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; // key 的哈希值(存起来,避免重复计算)
final K key; // 键
V value; // 值
Node<K,V> next; // 指向下一个节点(链表结构)
// 构造方法、getter、setter 等...
}
每个 Node 就是 HashMap 里存的一个键值对。注意 hash 字段是预先计算并存储的 —— 这是一个重要的性能优化,避免了每次比较时都重新计算哈希值。
四、源码深潜:put() 方法——HashMap 的心脏
put() 是 HashMap 最核心的方法,理解了它,HashMap 就理解了一大半。我们来看看 JDK 8 的 put() 到底做了什么。
4.1 put() 方法的完整调用链
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
put() 本身很简单,就调用了 putVal()。但我们先停一下,看看 hash(key) 这个方法——它是整个 HashMap 的基石。
4.2 哈希函数:HashMap 凭什么快
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
就这么两行代码,信息量巨大。
第 1 步:调用 key.hashCode()
由 key 所属的类决定如何生成哈希值。String 的 hashCode 是逐字符计算的,Integer 的 hashCode 直接返回数值本身。
第 2 步:高 16 位与低 16 位做异或(h ^ (h >>> 16))
这是 HashMap 的核心优化。为什么要这样设计?
先看 HashMap 怎么计算数组下标:
// 不是用取模,而是用位运算
index = (n - 1) & hash // n 是数组长度(2 的幂)
当 n = 16 时,n - 1 = 15,二进制是 0000 0000 0000 0000 0000 0000 0000 1111。任何一个哈希值跟 15 做与运算,只有低 4 位参与计算,高位全部被"丢弃"了。
这就糟了:如果 key 的 hashCode 只在低位有变化(这种情况很常见),高位差异完全被浪费,冲突会很严重。
解决方案:让高位也参与进来。 h >>> 16 把高 16 位移到低 16 位,然后跟原值做异或。这样高 16 位的特征被"混入"到了低 16 位中:
hashCode: 0001 0010 0011 0100 | 0101 0110 0111 1000 (32位)
↓
h >>> 16: 0000 0000 0000 0000 | 0001 0010 0011 0100 (高16位移到低16位)
↓
异或结果: 0001 0010 0011 0100 | 0100 0100 0100 1100 (高位特征混入低位)
↓
& (n-1): 0000 0000 0000 0000 | 0000 0000 0000 1100 (取低4位 = 12)
这个操作被称为扰动函数(Perturbation Function),目的是让哈希值的高位和低位都参与桶下标计算,让最终分布更均匀。
4.3 putVal() 逐行拆解
下面我们逐段拆解 putVal() 方法。别怕长,我会把每一段都讲清楚。
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;
步骤 1 解读:HashMap 是懒加载的——new HashMap() 的时候不会创建数组,第一次 put 才创建。这是一种常见的性能优化,避免你 new 了一个 Map 但从来没使用的情况。
// 【步骤 2】计算桶下标,如果桶为空,直接放进去
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
步骤 2 解读:用 (n - 1) & hash 计算桶位置。如果这个位置还没有人占,直接新建一个 Node 放进去。这是最快的情况——O(1)。
else {
// 【步骤 3】桶里已经有东西了,需要处理冲突
Node<K,V> e; K k;
// 情况 A:桶里第一个节点的 key 就跟我们要插入的一样
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p; // 记录下来,后面统一处理覆盖
步骤 3A 解读:先比较桶的第一个节点。注意这里的比较逻辑非常讲究:先比较 hash,hash 相同再比较 ==(引用相等),还不相等才用 equals()。hash 不同的直接跳过,避免调用代价更高的 equals()——这是一个精巧的性能优化。
// 情况 B:桶里是红黑树,走红黑树的插入逻辑
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
步骤 3B 解读:如果当前桶已经树化了,调用红黑树的插入方法。红黑树的复杂度是 O(log n)。
// 情况 C:桶里是链表,沿链表查找
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
// 走到链表尾部还没找到,新建节点追加到尾部
p.next = newNode(hash, key, value, null);
// 插入后链表长度达到 8,树化
if (binCount >= TREEIFY_THRESHOLD - 1) // binCount从0开始,>=7 即第8个
treeifyBin(tab, hash);
break;
}
// 在链表中找到了相同的 key
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
步骤 3C 解读:这是最复杂的情况——遍历链表。注意两个关键细节:
- JDK 8 用的是尾插法(新节点追加到链表尾部),而 JDK 7 是头插法(新节点插入链表头部)。这个差异在并发场景下会导致致命问题,后面会详细讲。
- binCount 从 0 开始,当
binCount >= 7时,链表上已经有 8 个节点(从第 1 个开始算),触发树化。
// 【步骤 4】找到了已存在的 key,覆盖旧值
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value; // 新值覆盖旧值
afterNodeAccess(e);
return oldValue; // 返回旧值
}
}
// 【步骤 5】这是新增节点(不是覆盖),modCount++,size++
++modCount;
if (++size > threshold)
resize(); // 超过阈值,扩容!
afterNodeInsertion(evict);
return null;
}
4.4 一张流程图帮你记住 put() 全流程
put(key, value)
│
▼
计算 hash 值:hash = (key == null) ? 0 : key.hashCode() ^ (key.hashCode() >>> 16)
│
▼
数组为空?──是──▶ resize() 初始化
│
否
│
▼
计算桶位置:i = (n - 1) & hash
│
▼
table[i] == null?──是──▶ 直接放新节点 ──▶ 结束
│
否
│
▼
桶首节点的 key 相同?──是──▶ 记录待覆盖
│
否
│
▼
桶首节点是 TreeNode?──是──▶ 红黑树插入/查找
│
否
│
▼
遍历链表:
├── 找到相同 key → 记录待覆盖
└── 走到尾部没找到 → 尾部插入新节点
└── 链表长度 ≥ 8?──是──▶ treeifyBin() 树化
│
▼
有要覆盖的节点?──是──▶ 新值覆盖旧值,返回旧值
│
否
│
▼
size > threshold?──是──▶ resize() 扩容
│
▼
返回 null(新增成功)
五、源码深潜:get() 方法——比 put 简单多了
好消息是,get() 的逻辑比 put() 简单得多——本质就是"先定位桶,再沿结构查找"。
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
// 数组不为空且长度>0 且 定位到的桶不为空
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))))
return first;
// 第一个不是,看看后面还有没有
if ((e = first.next) != null) {
// 红黑树查找
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
// 链表查找
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null; // 没找到
}
关键细节:get() 先比较第一个节点是有深意的。在哈希分布良好的情况下,大多数桶里只有一个节点,先查第一个能用最小的代价命中大多数请求。如果第一个不是且 first.next != null,才去判断是链表还是红黑树。
get() 的时间复杂度:理想情况 O(1),链表 O(n),红黑树 O(log n)。
六、扩容机制:HashMap 的性能杀手锏
扩容(resize)是 HashMap 最消耗性能的操作,也是面试官最爱深入追问的地方。
6.1 什么时候扩容?
if (++size > threshold)
resize();
threshold = capacity * loadFactor。以默认值来说,容量 16、负载因子 0.75,那么 threshold = 12。当 HashMap 中元素个数超过 12 时,触发扩容。
注意:是超过 threshold,不是超过 capacity。负载因子 0.75 相当于给 HashMap 留了 25% 的"缓冲空间"。
6.2 扩容做了什么?
扩容的本质是:创建一个更大的数组(通常是原来的 2 倍),然后把旧数组里的所有元素重新迁移过去。
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) {
// 已经达到最大容量,不扩了,把阈值设为 Integer.MAX_VALUE
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 新容量 = 旧容量 * 2
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // 新阈值也 * 2
}
// ... 省略初始化的分支 ...
// 创建新数组
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
// 迁移旧数据到新数组
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
// ... 遍历每个桶,把元素迁移到新数组 ...
}
}
return newTab;
}
6.3 数据迁移的核心优化:不用重新 hash
这是 JDK 8 相对于 JDK 7 的一个重大性能优化。
在 JDK 7 中,扩容时需要重新计算每个元素在新数组中的位置(hash & (newCapacity - 1)),因为数组长度变了,取模结果自然变了。
在 JDK 8 中,利用了一个巧妙的数学特性:
- 数组长度始终是 2 的幂,扩容时长度翻倍
- 对于任意 hash 值,
hash & (2n - 1)的结果,要么等于原位置,要么等于原位置 + n
为什么?来看个例子:
假设旧容量 n = 16,新容量 2n = 32
旧下标计算:hash & (16 - 1) = hash & 1111 (取低 4 位)
新下标计算:hash & (32 - 1) = hash & 11111 (取低 5 位)
新下标 = 旧下标 + (hash 第5位 == 1 ? 16 : 0)
也就是说,只需要检查 hash 的"第 5 位"是 0 还是 1:
- 0 → 位置不变
- 1 → 位置 + oldCap
// JDK 8 的迁移逻辑(简化版)
Node<K,V> loHead = null, loTail = null; // 位置不变的元素
Node<K,V> hiHead = null, hiTail = null; // 位置 + oldCap 的元素
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) { // hash 的第5位是0
// 放到 lo 链表(位置不变)
} else { // hash 的第5位是1
// 放到 hi 链表(位置 + oldCap)
}
} while ((e = next) != null);
// lo 链表放到 newTab[j](原位置)
// hi 链表放到 newTab[j + oldCap](原位置 + 旧容量)
这个优化避免了大量重新计算 hash 的开销,是 JDK 8 HashMap 性能提升的关键之一。
七、JDK 7 vs JDK 8:两版 HashMap 的关键差异
面试官特别喜欢问这个问题,因为它能看出候选人对 HashMap 理解的深度。
7.1 结构差异
| 对比维度 | JDK 7 | JDK 8 |
|---|---|---|
| 底层结构 | 数组 + 链表 | 数组 + 链表 + 红黑树 |
| 插入方式 | 头插法 | 尾插法 |
| 哈希函数 | 多次扰动(4次位运算+5次异或) | 一次异或(高16位与低16位) |
| 扩容数据迁移 | 重新计算 hash 位置 | 原位置 或 原位置+旧容量 |
| 初始化时机 | 构造时创建数组 | 懒加载,首次 put 才创建 |
7.2 头插法 vs 尾插法:为什么 JDK 8 要改?
这是 HashMap 面试中最重要的问题之一。
JDK 7 的头插法在并发场景下会导致死循环(CPU 100%)。这是一个著名的 bug。
复现场景:两个线程同时进行 put 操作触发了扩容,在迁移链表时,头插法会导致链表的顺序反转,在线程切换的特定时序下,链表会形成一个环形链。后续 get() 时,会在这个环里无限循环,CPU 直接飙到 100%。
JDK 8 改用尾插法,扩容时链表元素保持原有顺序,避免了环形链问题。
重要提醒:JDK 8 的 HashMap 依然不是线程安全的。尾插法只是避免了死循环(不会 CPU 100%),但依然可能出现数据覆盖、size 不一致等问题。并发场景请用
ConcurrentHashMap。
7.3 哈希函数的简化
JDK 7 的哈希函数相当复杂:
// JDK 7 的 hash()
final int hash(Object k) {
int h = hashSeed;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
JDK 8 简化成一行 (h = key.hashCode()) ^ (h >>> 16)。为什么可以简化?因为:
- 红黑树的引入让极端情况下的查找性能有了保障,对哈希函数均匀性的要求可以适当降低
- 更少的位运算意味着更高的计算效率——哈希函数每次 put/get 都要执行,能省一点是一点
- 实践证明简化的版本在大多数场景下已经足够均匀
八、线程安全:HashMap 的"阿喀琉斯之踵"
前面已经提到了 JDK 7 的死循环问题。但即使在 JDK 8 中,HashMap 依然不是线程安全的。我们来系统梳理一下。
8.1 并发问题演示
数据覆盖问题:
// 两个线程同时 put,可能互相覆盖
// 线程A 和 线程B 同时计算到同一个桶位置为空
// 线程A 写入 data1,线程B 写入 data2
// 结果:后写入的覆盖先写入的,一个数据丢失
size 不一致问题:
// size++ 不是原子操作
// 线程A:读取 size=10,准备写入 size=11
// 线程B:读取 size=10,准备写入 size=11
// 结果:两个线程都写入 11,但实际应该有 12 个元素
8.2 替代方案
| 方案 | 适用场景 | 原理 |
|---|---|---|
| Hashtable | 基本不用 | 所有方法加 synchronized,性能差 |
| Collections.synchronizedMap() | 小规模并发 | 包装一层 synchronized |
| ConcurrentHashMap | 推荐 | JDK 7 分段锁,JDK 8 CAS + synchronized |
如果有人问你"ConcurrentHashMap 和 HashMap 的区别",回答的核心就是两点:线程安全 + 不允许 null 键值。
九、HashMap 常见面试题全梳理
前面已经穿插讲了很多面试考点,这里做一个集中梳理和归纳,方便你面试前快速复习。
Q1:HashMap 的底层数据结构是什么?
JDK 8:数组 + 链表 + 红黑树。具体来说是一个 Node<K,V> 数组,每个位置存链表或红黑树的根节点。
Q2:HashMap 的默认初始容量和负载因子是多少?
- 默认初始容量:16(2 的 4 次方)
- 默认负载因子:0.75f
- 扩容阈值:16 × 0.75 = 12
Q3:HashMap 什么时候扩容?扩容机制是怎样的?
- 触发条件:
size > threshold(元素个数超过扩容阈值) - 扩容倍数:2 倍
- JDK 8 优化:元素的新位置 = 原位置 或 原位置 + 旧容量,不需要重新计算 hash
Q4:HashMap 的 put 方法流程?
见第四章的完整流程图。核心步骤:计算 hash → 定位桶 → 冲突处理(链表遍历/红黑树插入)→ 判断是否覆盖 → 判断是否扩容。
Q5:HashMap 是如何解决哈希冲突的?
拉链法:数组的每个位置存链表,冲突的元素挂在链表上。当链表长度 ≥ 8 且数组长度 ≥ 64 时,链表转为红黑树。
Q6:HashMap 为什么不直接使用 key.hashCode(),而要用 hash() 扰动?
因为计算桶下标时用的是 (n - 1) & hash,只有 hash 值的低位参与运算。如果 hashCode 的高位变化但低位不变,会导致大量冲突。扰动函数将高 16 位的特征混入低 16 位,让分布更均匀。
Q7:HashMap 的 key 一般用什么类型?为什么?
String 和 Integer 最常用。原因:
- 不可变性:hashCode 不会变化,put 之后再修改不会导致查找失败
- hashCode 和 equals 已经重写好了:无需担心实现质量
- 分布均匀:String 的 hashCode 设计经过大量验证,冲突少
Q8:能用自定义对象作为 key 吗?需要注意什么?
能。但必须同时重写 hashCode() 和 equals() 方法,且满足以下约束:
- equals 相等的对象,hashCode 必须相等
- hashCode 相等的对象,equals 不一定相等(这是哈希冲突,可以接受)
- hashCode 应该尽量均匀分布(减少冲突)
Q9:JDK 7 和 JDK 8 的 HashMap 有什么主要区别?
| 维度 | JDK 7 | JDK 8 |
|---|---|---|
| 结构 | 数组+链表 | 数组+链表+红黑树 |
| 插入方式 | 头插法(扩容时可能死循环) | 尾插法 |
| hash 函数 | 复杂(多次扰动) | 简单(一次异或) |
| 扩容迁移 | 全部重新 hash | 原位置或原位置+旧容量 |
| 初始化 | 构造时分配数组 | 懒加载(首次 put 分配) |
Q10:为什么 HashMap 的容量一定要是 2 的幂?
两个原因:
- 位运算代替取模:
(n - 1) & hash等价于hash % n(前提是 n 是 2 的幂),位运算比取模快得多 - 扩容时数据迁移更简单:旧位置或旧位置+旧容量,无需重新计算 hash
Q11:HashMap 和 Hashtable 的区别?
| 对比维度 | HashMap | Hashtable |
|---|---|---|
| 线程安全 | 否 | 是(synchronized) |
| 效率 | 高 | 低 |
| null 键/值 | 允许 | 不允许 |
| 出现时间 | JDK 1.2 | JDK 1.0 |
| 迭代器 | fail-fast | fail-safe(Enumeration) |
Q12:HashMap 和 ConcurrentHashMap 的区别?
- 线程安全:ConcurrentHashMap 是线程安全的,HashMap 不是
- null 支持:HashMap 允许 null 键和 null 值,ConcurrentHashMap 不允许
- 性能:JDK 8 的 ConcurrentHashMap 使用 CAS + synchronized 实现细粒度锁,并发性能远超 Hashtable
- 数据结构:基本一致,都是数组+链表+红黑树
十、延伸思考:HashMap 的一些"边角料"知识
以下问题不一定每个面试都会问到,但多知道一点总没坏处——说不定就是你跟其他候选人的区分点。
10.1 LinkedHashMap 怎么实现有序?
LinkedHashMap 继承自 HashMap,在 Node 的基础上增加了 before 和 after 两个指针,形成了一个双向链表来维护插入顺序或访问顺序。
// LinkedHashMap.Entry 比 HashMap.Node 多了两个引用
static class Entry<K,V> extends HashMap.Node<K,V> {
Entry<K,V> before, after; // 双向链表指针
}
面试中常见的"用 LinkedHashMap 实现 LRU 缓存"就是利用了这个特性——将
accessOrder设为 true,每次 get/put 时把被访问的元素移到链表尾部,链表头部就是最久未使用的元素。
10.2 为什么实际开发中推荐指定 HashMap 初始容量?
阿里巴巴 Java 开发手册中有这条规范:
// 不推荐:使用默认容量
Map<String, Object> map = new HashMap<>();
// 推荐:根据预期数据量指定初始容量
// expectedSize / 0.75 + 1 避免了不必要的扩容
Map<String, Object> map = new HashMap<>((int) (expectedSize / 0.75 + 1));
如果你已经知道要存大概 100 个元素,就应该指定初始容量为 100 / 0.75 + 1 ≈ 134,向上取最近的 2 的幂(256)。这样避免了在插入过程中多次扩容的性能开销。
10.3 HashMap 的 fail-fast 机制
你也许在遍历 HashMap 时遇到过 ConcurrentModificationException:
Map<String, String> map = new HashMap<>();
map.put("a", "1");
map.put("b", "2");
for (String key : map.keySet()) {
if ("a".equals(key)) {
map.remove(key); // 💥 ConcurrentModificationException!
}
}
这是因为 HashMap 内部有一个 modCount 变量,每次结构性修改(put/remove)都会自增。迭代器在创建时记录当前的 modCount,每次 next() 时检查是否发生了变化——如果变了,说明有并发修改,抛出异常。
正确的删除方式是用迭代器的 remove() 方法,或者用 Java 8 的 removeIf()。
写在最后
回到开头那个场景——面试官问"为什么树化阈值是 8"。读完整篇文章的你,现在应该能从容地回答:
"两个原因。第一,根据泊松分布,在良好的哈希函数下,链表长度达到 8 的概率约为亿分之六,正常情况几乎不可能出现。如果真的出现了,说明要么哈希函数设计有问题,要么遭遇了哈希碰撞攻击,此时转为红黑树来兜底。第二,在长度为 8 时,红黑树的平均查找次数约为 3(log₂8=3),而链表的平均查找次数约为 4,红黑树的优势开始显现。另外退化阈值设为 6 而不是 8,是为了形成一个缓冲区间,避免链表在 7 和 8 之间反复横跳导致频繁的树化/退化操作。"
这个回答,从数学原理到工程实践,从正向树化到反向退化,展现了你对 HashMap 设计思想的全方位理解。面试官大概率会在心里给你打个高分。
最后想说一句:HashMap 的源码值得你花一个下午认认真真读一遍。不是背八股文,而是去理解 Doug Lea、Josh Bloch 这些大神们在每一个设计决策背后的考量——为什么这么设计?如果是你会怎么做?当你开始这样思考,你就不是在准备面试,而是在提升自己的工程素养。而这,才是面试官真正想看到的。
更多推荐


所有评论(0)