1. 引入场景
你是不是在面试中经常被问到这样的问题:"HashMap的底层实现原理是什么?"、"为什么HashMap在JDK 1.8中要引入红黑树?"、"HashMap是线程安全的吗?"当你回答"HashMap用数组+链表实现"时,面试官会继续追问:"那为什么要用数组+链表?rehash的时候会发生什么?"
这些问题背后,考的不只是API使用,而是对数据结构设计思想的理解。HashMap作为Java中使用频率最高的集合类之一,几乎是Java面试的必考题。更重要的是,它的设计思想(哈希表、冲突解决、动态扩容、并发安全)是计算机基础中非常经典的知识点,搞懂它,你就理解了一大片基础知识。
2. 快速理解
通俗版: HashMap就像一个超级智能的电话簿,你只需要告诉它一个人的名字(key),它就能极快地找到这个人的电话号码(value),而不需要从头到尾翻一遍。
技术定义: HashMap是基于哈希表(Hash Table)实现的Map接口的非同步实现,它允许使用null键和null值,存储键值对(key-value pair),通过哈希函数将key映射到数组的某个位置,实现O(1)时间复杂度的快速查找、插入和删除操作。
3. 为什么需要HashMap
解决什么痛点
在实际开发中,我们经常需要通过某个标识(key)快速查找对应的数据(value)。如果使用普通数组或链表:
- 数组查找:需要遍历整个数组,时间复杂度O(n)
- 有序数组+二分查找:查找O(log n),但插入删除需要移动元素,成本高
- 链表:查找O(n),插入删除虽然快但查找太慢
HashMap通过哈希算法,将查找、插入、删除的平均时间复杂度降到O(1),这是它最大的优势。
方案对比
| 数据结构 | 查找时间 | 插入时间 | 删除时间 | 是否有序 | 空间占用 | 适用场景 |
|---|---|---|---|---|---|---|
| HashMap | O(1)平均 | O(1)平均 | O(1)平均 | 否 | 较高(需要预留空间) | 需要快速查找的KV存储 |
| TreeMap | O(log n) | O(log n) | O(log n) | 是(红黑树) | 中等 | 需要有序遍历的场景 |
| LinkedHashMap | O(1)平均 | O(1)平均 | O(1)平均 | 插入顺序 | 最高(额外维护链表) | 需要保持插入顺序的场景 |
| Hashtable | O(1)平均 | O(1)平均 | O(1)平均 | 否 | 较高 | 旧代码,已被ConcurrentHashMap替代 |
| ArrayList | O(n) | O(1)尾部 | O(n) | 索引有序 | 低 | 顺序访问、索引访问 |
适用场景
✅ 适用:
- 需要通过key快速查找value的场景(如缓存、配置管理)
- 数据量较大且查询频繁
- 不需要保证顺序
- 单线程环境或外部加锁
⚠️ 不适用:
- 需要线程安全(用ConcurrentHashMap)
- 需要有序遍历(用TreeMap或LinkedHashMap)
- 内存敏感且数据量小(数组或ArrayList可能更合适)
- 需要精确控制扩容时机的实时系统
4. 基础用法
import java.util.HashMap;
import java.util.Map;
public class HashMapBasicDemo {
public static void main(String[] args) {
// 1. 创建HashMap - 面试考点:初始容量和负载因子
// 默认初始容量16,负载因子0.75
Map<String, Integer> map1 = new HashMap<>();
// 指定初始容量(建议:已知数据量时指定,避免扩容)
Map<String, Integer> map2 = new HashMap<>(32);
// 指定初始容量和负载因子
Map<String, Integer> map3 = new HashMap<>(32, 0.8f);
// 2. 基本操作
HashMap<String, String> userMap = new HashMap<>();
// 🔥 put - 插入或更新,返回旧值(面试高频)
userMap.put("001", "张三");
userMap.put("002", "李四");
String oldValue = userMap.put("001", "张三三"); // 返回 "张三"
// 🔥 putIfAbsent - 仅当key不存在时插入(JDK 8新增)
userMap.putIfAbsent("003", "王五"); // 插入成功
userMap.putIfAbsent("003", "王五五"); // 不会插入,003已存在
// 🔥 get - 获取值,不存在返回null
String name = userMap.get("001"); // "张三三"
String notExist = userMap.get("999"); // null
// getOrDefault - 不存在时返回默认值(JDK 8新增)
String defaultName = userMap.getOrDefault("999", "未知用户");
// 🔥 remove - 删除,返回被删除的值
String removed = userMap.remove("002"); // 返回 "李四"
// remove(key, value) - 仅当键值匹配时删除(JDK 8新增)
boolean success = userMap.remove("001", "张三"); // false,值不匹配
userMap.remove("001", "张三三"); // true,删除成功
// 3. 🔥 常用判断方法(面试常考)
boolean hasKey = userMap.containsKey("003"); // true
boolean hasValue = userMap.containsValue("王五"); // true
int size = userMap.size(); // 1
boolean empty = userMap.isEmpty(); // false
// 4. 🔥 遍历方式(面试必考:哪种效率最高?)
Map<String, Integer> scores = new HashMap<>();
scores.put("语文", 90);
scores.put("数学", 85);
scores.put("英语", 88);
// 方式1:entrySet遍历(推荐,效率最高)
for (Map.Entry<String, Integer> entry : scores.entrySet()) {
System.out.println(entry.getKey() + ": " + entry.getValue());
}
// 方式2:keySet遍历(需要再次get,效率较低)
for (String key : scores.keySet()) {
System.out.println(key + ": " + scores.get(key));
}
// 方式3:values遍历(只需要值时使用)
for (Integer score : scores.values()) {
System.out.println(score);
}
// 方式4:Lambda + forEach(JDK 8,简洁但性能略低)
scores.forEach((k, v) -> System.out.println(k + ": " + v));
// 5. 🔥 JDK 8 新增实用方法(面试加分项)
// compute - 计算新值
scores.compute("语文", (k, v) -> v == null ? 0 : v + 5); // 95
// computeIfAbsent - 如果不存在则计算并插入
scores.computeIfAbsent("物理", k -> 80); // 插入 "物理" -> 80
// computeIfPresent - 如果存在则计算
scores.computeIfPresent("数学", (k, v) -> v + 10); // 95
// merge - 合并值
scores.merge("英语", 10, Integer::sum); // 88 + 10 = 98
// 6. ⚠️ null的处理(面试陷阱)
HashMap<String, String> nullMap = new HashMap<>();
nullMap.put(null, "null key"); // ✅ 允许null key
nullMap.put("key", null); // ✅ 允许null value
System.out.println(nullMap.get(null)); // "null key"
}
}
🔥 面试常考知识点
-
HashMap允许null吗?
- ✅ 允许一个null key和多个null value
- ❌ Hashtable和ConcurrentHashMap都不允许null
-
遍历方式效率对比
- entrySet > keySet(keySet需要二次get)
- forEach在数据量大时略慢(因为Lambda调用开销)
-
初始容量如何选择?
- 如果知道数据量n,设置为
(int)(n / 0.75) + 1 - 避免频繁扩容(扩容成本高)
- 如果知道数据量n,设置为
5. ⭐ 底层原理深挖(重点)
5.1 核心数据结构
HashMap的底层是 数组 + 链表 + 红黑树(JDK 1.8+)的组合结构。
// HashMap的核心字段(简化版源码)
public class HashMap<K,V> {
// 🔥 哈希桶数组(面试必考)
transient Node<K,V>[] table;
// 实际存储的键值对数量
transient int size;
// 🔥 扩容阈值 = capacity * loadFactor
int threshold;
// 🔥 负载因子(默认0.75)
final float loadFactor;
// 🔥 链表节点(JDK 1.8)
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; // 哈希值(缓存,避免重复计算)
final K key; // 键
V value; // 值
Node<K,V> next; // 链表下一个节点
}
// 🔥 红黑树节点(JDK 1.8新增)
static final class TreeNode<K,V> extends Node<K,V> {
TreeNode<K,V> parent;
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev;
boolean red;
}
}
[配图:HashMap结构示意图 - 展示数组+链表+红黑树的混合结构]
数组索引: [0] [1] [2] [3] [4] ... [15]
| | | | | |
null | Node | null Node
| | | |
Node | Node Node(8个)
| null | |
Node | 转为
| Node 红黑树
null | TreeNode
null
5.2 🔥 哈希计算与数组定位(面试高频)
问题:如何根据key找到数组下标?
// 步骤1:计算hash值(JDK 1.8优化后的算法)
static final int hash(Object key) {
int h;
// 🔥 为什么要异或高16位?(面试必问)
// 因为数组容量通常不大,直接用hashCode定位会导致高位信息丢失
// 异或高16位可以让高位也参与到数组定位中,减少哈希冲突
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
// 步骤2:根据hash值定位数组下标
// 🔥 为什么用 (n - 1) & hash 而不是 hash % n?(面试高频)
int index = (table.length - 1) & hash;
// 原因解析:
// 1. 位运算比取模快得多(CPU指令层面)
// 2. 前提条件:数组长度必须是2的幂次(16, 32, 64...)
// 3. 当n为2的幂次时:hash % n 等价于 hash & (n - 1)
// 例如:n=16时,n-1=15=0b1111
// 任何数 & 0b1111 都只保留低4位,相当于 % 16
为什么HashMap的容量必须是2的幂次?
- 保证
(n - 1) & hash等价于hash % n - 扩容时rehash更高效(后面详解)
- 分布更均匀(所有位都能参与计算)
5.3 🔥 put方法的完整流程(面试核心)
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
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. 🔥 如果table为空,先扩容(懒初始化)
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) {
if ((e = p.next) == null) {
// 链表尾部插入新节点(JDK 1.8改为尾插法)
p.next = newNode(hash, key, value, null);
// 🔥🔥🔥 如果链表长度>=8,转为红黑树(面试必考)
if (binCount >= TREEIFY_THRESHOLD - 1) // TREEIFY_THRESHOLD = 8
treeifyBin(tab, hash);
break;
}
// 找到相同的key,退出循环
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 4. 🔥 如果找到了相同的key,更新value
if (e != null) {
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
return oldValue; // 返回旧值
}
}
// 5. 🔥 size增加,检查是否需要扩容
if (++size > threshold)
resize();
return null;
}
put流程总结:
- 计算hash → 定位数组下标
- 如果位置为空,直接插入
- 如果位置有值:
- key相同 → 覆盖value
- key不同 → 链表/红黑树插入
- 链表长度≥8 → 转红黑树
- size > threshold → 扩容
5.4 🔥 为什么JDK 1.8要引入红黑树?(面试必问)
问题背景:
- JDK 1.7:数组 + 链表
- JDK 1.8:数组 + 链表 + 红黑树
原因分析:
| 场景 | 链表查找时间 | 红黑树查找时间 | 说明 |
|---|---|---|---|
| 正常情况 | O(1) ~ O(4) | O(log n) | 哈希分布均匀,链表很短 |
| 哈希冲突严重 | O(n) | O(log n) | 某个桶的链表特别长 |
| 极端情况(n=1000) | O(1000) | O(10) | 性能差距100倍 |
转换规则:
- 链表 → 红黑树:链表长度 ≥ 8 且数组长度 ≥ 64
- 红黑树 → 链表:树节点数量 ≤ 6(避免频繁转换)
为什么是8和6?
- 阈值8:根据泊松分布,正常情况下链表长度≥8的概率极低(0.00000006)
- 阈值6:中间留缓冲区,避免在7-8之间频繁转换(转换有成本)
// 🔥 树化的两个条件(面试陷阱)
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
// 条件1:数组长度必须 >= 64
// 🔥 如果数组太小,优先扩容而不是树化(扩容可能分散节点)
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY) // 64
resize(); // 扩容
else if ((e = tab[index = (n - 1) & hash]) != null) {
// 条件2:链表长度 >= 8
// 转为红黑树...
}
}
5.5 🔥 扩容机制(面试核心中的核心)
触发条件: size > threshold(threshold = capacity × loadFactor)
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
// 1. 🔥 计算新容量(扩大为原来的2倍)
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) // 2^30
return oldTab; // 已达最大容量,不再扩容
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY)
newThr = oldThr << 1; // 新阈值也翻倍
}
// 2. 创建新数组
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
// 3. 🔥🔥🔥 rehash - 将旧数组的元素迁移到新数组(面试重点)
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
// 情况1:该位置只有一个节点,直接rehash
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
// 情况2:红黑树,拆分树
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
// 情况3:🔥 链表,JDK 1.8的优化(面试重点)
else {
// 🔥 神奇的地方:元素要么在原位置,要么在原位置+oldCap
Node<K,V> loHead = null, loTail = null; // 低位链表
Node<K,V> hiHead = null, hiTail = null; // 高位链表
Node<K,V> next;
do {
next = e.next;
// 🔥 判断新增的那一位是0还是1
if ((e.hash & oldCap) == 0) {
// 0: 位置不变,放入低位链表
if (loTail == null) loHead = e;
else loTail.next = e;
loTail = e;
} else {
// 1: 位置变为 原位置+oldCap,放入高位链表
if (hiTail == null) hiHead = e;
else hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 低位链表放在原位置
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
// 高位链表放在原位置+oldCap
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
🔥 JDK 1.8 扩容优化的精髓(面试加分项):
扩容时,元素的新位置只有两种可能:
- 原位置 index
- 原位置 + oldCap
为什么?
假设oldCap = 16 (0b10000),扩容后newCap = 32 (0b100000)
旧数组定位:hash & (16-1) = hash & 0b01111 (只看低4位)
新数组定位:hash & (32-1) = hash & 0b11111 (只看低5位)
差异在于:新数组多看了一位(第5位)
- 如果 hash的第5位 = 0,新位置 = 旧位置
- 如果 hash的第5位 = 1,新位置 = 旧位置 + 16
判断第5位的方法:hash & oldCap (0b10000)
- 结果为0 → 第5位是0
- 结果非0 → 第5位是1
优势:
- 不需要重新计算hash
- 不需要重新定位(只判断一位)
- 保持链表顺序(JDK 1.7头插法会逆序,导致死循环)
5.6 🔥 JDK 1.7 vs JDK 1.8 关键差异(面试必问)
| 对比维度 | JDK 1.7 | JDK 1.8 | 影响 |
|---|---|---|---|
| 数据结构 | 数组+链表 | 数组+链表+红黑树 | 解决哈希冲突严重时的性能问题 |
| 插入方式 | 头插法 | 尾插法 | 🔥 解决多线程扩容死循环问题 |
| 扩容rehash | 重新计算hash定位 | 只判断一位 | 扩容性能提升 |
| hash计算 | 4次位运算+5次异或 | 1次异或 | 计算性能提升 |
| 树化阈值 | 无 | 链表≥8 且 数组≥64 | 冲突严重时用树 |
🔥 JDK 1.7 头插法导致的死循环问题(面试高频):
// JDK 1.7的插入逻辑(头插法)
void transfer(Entry[] newTable) {
Entry[] src = table;
for (int j = 0; j < src.length; j++) {
Entry<K,V> e = src[j];
if (e != null) {
src[j] = null;
do {
Entry<K,V> next = e.next;
int i = indexFor(e.hash, newTable.length);
e.next = newTable[i]; // 🔥 头插法:新节点放在链表头部
newTable[i] = e;
e = next;
} while (e != null);
}
}
}
多线程场景下的问题:
- 线程A和线程B同时扩容
- 头插法会导致链表顺序反转
- 两个线程交替执行,可能形成环形链表
- get操作时死循环(链表成环,next永远不为null)
JDK 1.8的解决方案:
- 改用尾插法,保持原有顺序
- ⚠️ 注意:HashMap仍然不是线程安全的,只是不会死循环
6. 性能分析与优化
6.1 时间/空间复杂度
| 操作 | 平均时间复杂度 | 最坏时间复杂度 | 空间复杂度 |
|---|---|---|---|
| get | O(1) | O(log n) 红黑树 | - |
| put | O(1) | O(log n) 红黑树 | - |
| remove | O(1) | O(log n) 红黑树 | - |
| containsKey | O(1) | O(log n) | - |
| 扩容 | O(n) | O(n) | O(n) 新数组 |
| 遍历 | O(n) | O(n) | - |
空间占用: 实际占用 ≈ capacity × (32字节/Node + key大小 + value大小)
6.2 🔥 负载因子的选择(面试常问)
默认值0.75的权衡:
| 负载因子 | 空间利用率 | 冲突概率 | 查询性能 | 推荐场景 |
|---|---|---|---|---|
| 0.5 | 低(浪费50%) | 极低 | 最好 | 内存充足,追求极致性能 |
| 0.75 | 中(浪费25%) | 低 | 好 | ✅ 默认值,平衡之选 |
| 0.9 | 高(浪费10%) | 较高 | 一般 | 内存紧张 |
| 1.0 | 满载 | 高 | 差 | ❌ 不推荐,大量冲突 |
为什么默认是0.75?
- 根据泊松分布,0.75时哈希冲突和空间利用达到较好平衡
- 太小:浪费内存
- 太大:冲突增加,性能下降
6.3 性能优化建议
1. 🔥 预估容量,避免扩容
// ❌ 不好:频繁扩容
Map<String, String> map = new HashMap<>();
for (int i = 0; i < 10000; i++) {
map.put("key" + i, "value" + i);
}
// 扩容次数:16→32→64→128→256→512→1024→2048→4096→8192→16384(11次)
// ✅ 好:指定初始容量
int expectedSize = 10000;
int capacity = (int) (expectedSize / 0.75) + 1; // 13334
Map<String, String> map = new HashMap<>(capacity);
for (int i = 0; i < 10000; i++) {
map.put("key" + i, "value" + i);
}
// 扩容次数:0次
// 性能提升:约30-50%(避免了rehash)
2. 🔥 合理选择key的类型
// ✅ 好:使用不可变类作为key(String, Integer, Long等)
Map<String, User> userMap = new HashMap<>();
userMap.put("user123", user); // String是不可变的,hashCode稳定
// ❌ 危险:使用可变对象作为key
class MutableKey {
private int id;
public void setId(int id) { this.id = id; }
@Override public int hashCode() { return id; }
}
Map<MutableKey, String> map = new HashMap<>();
MutableKey key = new MutableKey();
key.setId(100);
map.put(key, "value");
key.setId(200); // 修改key,hashCode改变
map.get(key); // 🔥 找不到了!hash值变了,定位到错误的桶
3. 🔥 重写equals和hashCode必须配套
// ❌ 错误:只重写equals,不重写hashCode
class User {
private String id;
@Override
public boolean equals(Object obj) {
return obj instanceof User && this.id.equals(((User) obj).id);
}
// 没有重写hashCode,使用Object的默认实现(内存地址)
}
User u1 = new User("001");
User u2 = new User("001");
map.put(u1, "value");
map.get(u2); // 🔥 null!equals相等但hashCode不同,定位到不同的桶
// ✅ 正确:equals和hashCode一起重写
class User {
private String id;
@Override
public boolean equals(Object obj) {
return obj instanceof User && this.id.equals(((User) obj).id);
}
@Override
public int hashCode() {
return id.hashCode(); // 与equals保持一致
}
}
4. 实测性能对比
// 测试环境:JDK 17, 1000万次put操作
// 场景1:未指定容量
Map<Integer, String> map1 = new HashMap<>();
long start = System.currentTimeMillis();
for (int i = 0; i < 10_000_000; i++) {
map1.put(i, "value" + i);
}
long time1 = System.currentTimeMillis() - start;
// 结果:约3200ms,扩容24次
// 场景2:指定容量
Map<Integer, String> map2 = new HashMap<>(13_333_333);
start = System.currentTimeMillis();
for (int i = 0; i < 10_000_000; i++) {
map2.put(i, "value" + i);
}
long time2 = System.currentTimeMillis() - start;
// 结果:约2100ms,扩容0次
// 性能提升:约34%
7. 易混淆概念对比
| 对比项 | HashMap | Hashtable | ConcurrentHashMap | TreeMap | LinkedHashMap |
|---|---|---|---|---|---|
| 线程安全 | ❌ 否 | ✅ 是(synchronized) | ✅ 是(分段锁/CAS) | ❌ 否 | ❌ 否 |
| null键 | ✅ 允许1个 | ❌ 不允许 | ❌ 不允许 | ❌ 不允许 | ✅ 允许1个 |
| null值 | ✅ 允许多个 | ❌ 不允许 | ❌ 不允许 | ✅ 允许 | ✅ 允许 |
| 有序性 | ❌ 无序 | ❌ 无序 | ❌ 无序 | ✅ 键有序(红黑树) | ✅ 插入顺序 |
| 底层结构 | 数组+链表+红黑树 | 数组+链表 | 数组+链表+红黑树 | 红黑树 | 数组+链表+红黑树+双向链表 |
| 性能 | O(1) | O(1)(锁开销大) | O(1)(锁开销小) | O(log n) | O(1) |
| 初始容量 | 16 | 11 | 16 | - | 16 |
| 扩容倍数 | 2倍 | 2倍+1 | 2倍 | - | 2倍 |
| 推荐场景 | 单线程KV存储 | ❌ 已过时 | 多线程KV存储 | 需要排序 | 需要保序(如LRU缓存) |
🔥 选型建议:
- 单线程 → HashMap(最快)
- 多线程 → ConcurrentHashMap(不要用Hashtable)
- 需要排序 → TreeMap
- 需要保持插入顺序 → LinkedHashMap
- 实现LRU缓存 → LinkedHashMap(重写removeEldestEntry)
8. 常见坑与最佳实践
⚠️ 坑1:多线程使用HashMap导致死循环(JDK 1.7)
// 错误示例
public class HashMapDeadLoop {
private static Map<Integer, Integer> map = new HashMap<>();
public static void main(String[] args) {
// 两个线程同时put,可能触发同时扩容
Thread t1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
map.put(i, i);
}
});
Thread t2 = new Thread(() -> {
for (int i = 10000; i < 20000; i++) {
map.put(i, i);
}
});
t1.start();
t2.start();
// JDK 1.7:可能死循环(链表成环)
// JDK 1.8:不会死循环,但会丢数据
}
}
// 解决方案
// 方案1:使用ConcurrentHashMap
private static Map<Integer, Integer> map = new ConcurrentHashMap<>();
// 方案2:外部加锁
private static Map<Integer, Integer> map = new HashMap<>();
synchronized(map) {
map.put(key, value);
}
// 方案3:使用Collections.synchronizedMap(不推荐,性能差)
private static Map<Integer, Integer> map = Collections.synchronizedMap(new HashMap<>());
⚠️ 坑2:遍历时修改Map
// ❌ 错误:ConcurrentModificationException
Map<String, Integer> map = new HashMap<>();
map.put("a", 1);
map.put("b", 2);
map.put("c", 3);
for (String key : map.keySet()) {
if (key.equals("b")) {
map.remove(key); // 🔥 抛出ConcurrentModificationException
}
}
// ✅ 正确:使用Iterator.remove()
Iterator<Map.Entry<String, Integer>> it = map.entrySet().iterator();
while (it.hasNext()) {
Map.Entry<String, Integer> entry = it.next();
if (entry.getKey().equals("b")) {
it.remove(); // 使用迭代器的remove方法
}
}
// ✅ 正确:JDK 8的removeIf(推荐)
map.entrySet().removeIf(entry -> entry.getKey().equals("b"));
⚠️ 坑3:key为自定义对象时未正确实现hashCode/equals
// 示例:统计学生成绩
class Student {
String name;
int age;
// ❌ 未重写hashCode和equals
}
Map<Student, Integer> scores = new HashMap<>();
scores.put(new Student("张三", 20), 90);
scores.get(new Student("张三", 20)); // 🔥 返回null!
// 正确做法:使用Lombok或手动实现
@Data // Lombok会自动生成equals和hashCode
class Student {
String name;
int age;
}
✅ 最佳实践
- 使用不可变对象作为key(String、Integer、枚举等)
- 预估容量,避免频繁扩容
- 多线程环境用ConcurrentHashMap,不要自己加锁
- 重写hashCode/equals时确保一致性
- 不要在遍历时修改,用Iterator.remove()或removeIf
- 避免使用null作为key(虽然允许,但容易导致NPE)
9. ⭐ 面试题精选
⭐ Q1:HashMap的底层数据结构是什么?
标准答案:
- JDK 1.7:数组 + 链表
- JDK 1.8+:数组 + 链表 + 红黑树
详细说明:
- 底层是一个
Node<K,V>[]数组,每个数组元素称为"桶"(bucket) - 当发生哈希冲突时,使用链表存储(拉链法)
- JDK 1.8引入红黑树优化:当链表长度≥8且数组长度≥64时,链表转为红黑树
- 红黑树节点数量≤6时退化回链表
⭐⭐ Q2:HashMap的put过程是怎样的?
标准答案(分步作答):
- 计算hash值:调用
hash(key),将key的hashCode高16位异或低16位 - 定位数组下标:
index = (table.length - 1) & hash - 判断该位置是否为空:
- 为空 → 直接插入新节点
- 不为空 → 处理哈希冲突
- 处理哈希冲突:
- 如果key已存在 → 覆盖value
- 如果是红黑树节点 → 按树的方式插入
- 如果是链表 → 遍历链表,尾部插入(JDK 1.8)
- 检查树化条件:链表长度≥8且数组长度≥64 → 转为红黑树
- 检查扩容条件:
size > threshold→ 扩容(容量翻倍)
⭐⭐⭐ Q3:HashMap的扩容机制是怎样的?为什么扩容时容量是2倍?
标准答案:
触发条件: size > threshold(threshold = capacity × loadFactor)
扩容过程:
- 创建新数组,容量为原来的2倍
- rehash:将旧数组的元素重新分配到新数组
- JDK 1.8优化:元素要么在原位置,要么在原位置+oldCap
为什么是2倍?
- 保证数组长度是2的幂次:确保
(n-1) & hash等价于hash % n - rehash优化:扩容时只需判断hash的新增位是0还是1,不需重新计算hash
- 分布均匀:2的幂次可以让所有bit位都参与计算
示例:
oldCap = 16 (0b10000), newCap = 32 (0b100000)
判断条件:hash & oldCap
- 结果为0 → 新位置 = 原位置
- 结果非0 → 新位置 = 原位置 + 16
⭐⭐ Q4:为什么HashMap要引入红黑树?
标准答案:
问题背景: 当哈希冲突严重时,某个桶的链表会很长,查找性能退化为O(n)
解决方案: JDK 1.8引入红黑树,链表长度≥8时转为红黑树,查找性能提升到O(log n)
为什么选择8作为阈值?
- 根据泊松分布,正常情况下链表长度≥8的概率极低(0.00000006)
- 说明哈希算法有问题或遭受了哈希碰撞攻击
- 红黑树转换有成本,不能频繁转换
为什么树化还需要数组长度≥64?
- 如果数组太小,优先扩容而不是树化
- 扩容可能会分散节点,自然解决冲突问题
⭐⭐ Q5:HashMap为什么线程不安全?JDK 1.7和1.8有什么区别?
标准答案:
线程不安全的原因:
- JDK 1.7:多线程扩容时,头插法可能导致链表成环,get操作死循环
- JDK 1.8:虽然改用尾插法,不会死循环,但仍可能丢失数据
JDK 1.7的死循环问题:
- 扩容时使用头插法,会导致链表顺序反转
- 两个线程同时扩容,可能形成环形链表
- get操作时沿着next指针遍历,遇到环则死循环
JDK 1.8的改进:
- 改用尾插法,保持链表原有顺序
- 扩容优化:通过位运算判断新位置,不需重新计算hash
- ⚠️ 但仍然不是线程安全的,多线程环境请使用ConcurrentHashMap
⭐ Q6:HashMap和Hashtable的区别?
标准答案:
| 对比项 | HashMap | Hashtable |
|---|---|---|
| 线程安全 | ❌ 不安全 | ✅ 安全(synchronized) |
| 性能 | 快 | 慢(锁粒度大) |
| null键/值 | ✅ 允许 | ❌ 不允许 |
| 初始容量 | 16 | 11 |
| 扩容倍数 | 2倍 | 2倍+1 |
| 推荐使用 | ✅ 单线程 | ❌ 已过时 |
结论:
- 单线程用HashMap
- 多线程用ConcurrentHashMap
- Hashtable已过时,不推荐使用
⭐⭐⭐ Q7:为什么HashMap的负载因子默认是0.75?
标准答案:
负载因子(loadFactor) 决定了何时扩容:size > capacity × loadFactor
0.75的权衡:
-
空间与时间的平衡:
- 太小(如0.5):频繁扩容,浪费内存
- 太大(如1.0):冲突增加,性能下降
- 0.75:较好的平衡点
-
泊松分布的理论支撑:
- 根据数学计算,0.75时哈希冲突和空间利用率达到较好平衡
- 链表长度≥8的概率极低(0.00000006)
-
工程实践验证:
- 大量实际应用表明0.75是最优选择
- 既不会频繁扩容,也不会过度冲突
⭐⭐ Q8:为什么HashMap的数组长度必须是2的幂次?
标准答案(分点作答):
-
优化取模运算:
// 普通取模 int index = hash % length; // 慢 // 位运算(前提:length是2的幂次) int index = hash & (length - 1); // 快 -
举例说明:
length = 16 = 0b10000 length - 1 = 15 = 0b01111 任何数 & 0b01111 只保留低4位,等价于 % 16 -
扩容优化:
- 扩容时只需判断hash的新增位是0还是1
- 不需要重新计算hash,性能更好
-
分布均匀:
- 2的幂次可以让所有bit位都参与计算
- 如果不是2的幂次,某些位永远是0,分布不均
⭐⭐ Q9:HashMap如何解决哈希冲突?
标准答案:
主要方法:拉链法(Separate Chaining)
-
链表(默认):
- 冲突的元素存储在同一个桶的链表中
- JDK 1.7用头插法,JDK 1.8用尾插法
- 查找时间:O(n)
-
红黑树(JDK 1.8优化):
- 链表长度≥8且数组长度≥64时转为红黑树
- 查找时间:O(log n)
- 树节点数量≤6时退化回链表
其他解决方法(面试加分项):
- 开放地址法:HashMap未使用,但可以说明知道其他方法
- 再哈希法:使用多个哈希函数
- 公共溢出区:单独开辟溢出区存储冲突元素
⭐⭐⭐ Q10:设计题 - 如何用HashMap实现LRU缓存?
思路分析:
方案1:使用LinkedHashMap(推荐)
public class LRUCache<K, V> extends LinkedHashMap<K, V> {
private final int capacity;
public LRUCache(int capacity) {
// 参数说明:容量,负载因子,accessOrder=true(访问顺序)
super(capacity, 0.75f, true);
this.capacity = capacity;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
// 当size超过容量时,自动删除最老的元素
return size() > capacity;
}
}
// 使用
LRUCache<String, String> cache = new LRUCache<>(3);
cache.put("a", "1");
cache.put("b", "2");
cache.put("c", "3");
cache.get("a"); // a变为最新
cache.put("d", "4"); // b被淘汰(最久未使用)
方案2:HashMap + 双向链表(手动实现)
class LRUCache<K, V> {
private final int capacity;
private final Map<K, Node<K, V>> map;
private final Node<K, V> head, tail;
class Node<K, V> {
K key;
V value;
Node<K, V> prev, next;
}
public LRUCache(int capacity) {
this.capacity = capacity;
this.map = new HashMap<>();
// 哨兵节点
head = new Node<>();
tail = new Node<>();
head.next = tail;
tail.prev = head;
}
public V get(K key) {
Node<K, V> node = map.get(key);
if (node == null) return null;
// 移动到头部(表示最新访问)
moveToHead(node);
return node.value;
}
public void put(K key, V value) {
Node<K, V> node = map.get(key);
if (node != null) {
node.value = value;
moveToHead(node);
} else {
node = new Node<>();
node.key = key;
node.value = value;
map.put(key, node);
addToHead(node);
if (map.size() > capacity) {
// 删除尾部节点(最久未使用)
Node<K, V> removed = removeTail();
map.remove(removed.key);
}
}
}
private void moveToHead(Node<K, V> node) {
removeNode(node);
addToHead(node);
}
private void addToHead(Node<K, V> node) {
node.next = head.next;
node.prev = head;
head.next.prev = node;
head.next = node;
}
private void removeNode(Node<K, V> node) {
node.prev.next = node.next;
node.next.prev = node.prev;
}
private Node<K, V> removeTail() {
Node<K, V> node = tail.prev;
removeNode(node);
return node;
}
}
对比分析:
| 方案 | 优点 | 缺点 | 推荐度 |
|---|---|---|---|
| LinkedHashMap | 简洁,代码少 | 功能受限 | ⭐⭐⭐ 面试首选 |
| HashMap+双向链表 | 灵活,可扩展 | 代码量大 | ⭐⭐ 深入理解用 |
10. 总结与延伸
核心要点回顾
- 数据结构:数组 + 链表 + 红黑树(JDK 1.8)
- 哈希定位:
hash(key)→(n-1) & hash→ 数组下标 - 冲突解决:拉链法(链表/红黑树)
- 扩容机制:容量翻倍,rehash优化(判断一位)
- 线程安全:不安全,多线程用ConcurrentHashMap
🔥 面试必背点
- 为什么用(n-1)&hash:位运算快,前提是n为2的幂次
- 为什么引入红黑树:解决哈希冲突严重时的性能问题
- 为什么负载因子是0.75:空间与时间的平衡
- JDK 1.7和1.8的区别:头插→尾插,引入红黑树,扩容优化
- 线程不安全的表现:1.7死循环,1.8丢数据
相关技术栈
同类技术:
- ConcurrentHashMap:线程安全的HashMap(分段锁/CAS)
- TreeMap:基于红黑树,有序
- LinkedHashMap:保持插入/访问顺序
底层原理:
- 红黑树:自平衡二叉搜索树
- 哈希算法:一致性哈希、MurmurHash
- 并发控制:CAS、分段锁、读写锁
进一步学习方向
-
源码阅读:
HashMap.java(JDK 8/17)ConcurrentHashMap.java- 对比版本差异
-
深入主题:
- 红黑树的插入/删除/平衡算法
- ConcurrentHashMap的并发控制机制
- 一致性哈希在分布式系统中的应用
-
实战应用:
- 实现一个简易的HashMap
- 实现LRU/LFU缓存
- 性能测试与调优
-
拓展阅读:
- 《Java核心技术 卷I》- 集合框架章节
- 《Java并发编程实战》- ConcurrentHashMap
- JDK源码注释(英文原版,理解设计思想)
最后的建议:
HashMap不仅是面试高频考点,更是理解数据结构、算法设计、性能优化的绝佳案例。建议:
- 手写一遍核心代码(put/get/resize)
- 画出数据结构演变图
- 总结成自己的话,能流畅讲出来
- 对比不同版本的差异,理解演进原因
搞懂HashMap,你就掌握了哈希表的精髓!🔥