HashMap vs ConcurrentHashMap:面试官最喜欢深挖的那20个追问
HashMap vs ConcurrentHashMap:面试官最喜欢深挖的那20个追问
适读人群:Java中高级开发 | 难度:★★★★☆ | 出现频率:极高
开篇故事
去年我面试一个候选人,简历上写着"精通Java并发编程",我就拿HashMap下手了。
"HashMap和ConcurrentHashMap有什么区别?"
他答得很流畅:HashMap线程不安全,ConcurrentHashMap线程安全,JDK8之前用分段锁,之后改成CAS加synchronized。
我点点头,然后问:"HashMap在多线程下会有什么具体问题?"
他说会有数据丢失。
我继续:"JDK7的HashMap为什么会形成死循环?"
他愣了三秒,说是因为扩容。
我再追:"扩容的时候链表是怎么转移的?头插法为什么在并发下会成环?"
他开始支支吾吾了。
这就是面试的现实。很多人停留在"知道结论"的层面,但面试官真正想考察的是你有没有深入看过源码,有没有真正理解并发问题背后的原理。今天我把这15年里被问过的、问过别人的HashMap与ConcurrentHashMap相关问题全部整理出来,帮你做到真正的融会贯通。
一、高频考点拆解
这道题考察的维度其实非常多,我把它分成四个层次:
第一层:基础概念 HashMap的底层数据结构、扩容机制、哈希冲突处理。这是入门,答不上来直接减分。
第二层:线程安全问题 HashMap在多线程下的具体风险——不只是"不安全"这三个字,要能说出数据覆盖、死循环(JDK7)、数据不可见等具体场景。
第三层:ConcurrentHashMap的演进 JDK7的Segment分段锁,JDK8的CAS+synchronized节点锁,这两种实现的本质差异和性能对比。
第四层:源码级追问 putVal的流程、size()的计算方式、红黑树的触发条件、transfer方法的并发风险……这一层才是大厂真正的分水岭。
二、深度原理分析
2.1 HashMap的底层结构
JDK8的HashMap底层是数组+链表+红黑树的复合结构。数组默认容量16,负载因子0.75,当链表长度超过8且数组长度超过64时,链表树化为红黑树。
关键字段:
DEFAULT_INITIAL_CAPACITY = 1 << 4:默认容量16MAXIMUM_CAPACITY = 1 << 30:最大容量DEFAULT_LOAD_FACTOR = 0.75f:负载因子TREEIFY_THRESHOLD = 8:树化阈值UNTREEIFY_THRESHOLD = 6:退树化阈值MIN_TREEIFY_CAPACITY = 64:最小树化数组容量
2.2 JDK7 HashMap死循环的根本原因
JDK7使用头插法插入链表。扩容时,transfer方法会把旧数组的元素逐个迁移到新数组。在单线程下没问题,但并发时会出现环形链表。
JDK8改用尾插法,彻底解决了这个死循环问题,但依然存在数据丢失的风险。
2.3 ConcurrentHashMap JDK7 vs JDK8
JDK7的分段锁:
- Segment继承ReentrantLock,默认16个Segment
- 并发度就是Segment数量,最多16个线程同时写
- 每个Segment内部就是一个小HashMap
JDK8的改进:
- 完全抛弃Segment,锁粒度降低到单个桶(数组槽位)
- 空槽位插入用CAS,无锁操作
- 已有节点的链表/红黑树操作用synchronized锁头节点
- 理论并发度等于数组长度
三、标准答案 + 代码验证
3.1 HashMap并发数据丢失验证
import java.util.HashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicInteger;
public class HashMapConcurrentTest {
public static void main(String[] args) throws InterruptedException {
// 测试1:数据丢失
testDataLoss();
}
static void testDataLoss() throws InterruptedException {
HashMap<Integer, Integer> map = new HashMap<>();
int threadCount = 10;
int perThread = 1000;
CountDownLatch latch = new CountDownLatch(threadCount);
AtomicInteger errorCount = new AtomicInteger(0);
for (int t = 0; t < threadCount; t++) {
final int start = t * perThread;
new Thread(() -> {
try {
for (int i = start; i < start + perThread; i++) {
map.put(i, i);
}
} finally {
latch.countDown();
}
}).start();
}
latch.await();
int expected = threadCount * perThread;
int actual = map.size();
System.out.println("期望数量: " + expected + ",实际数量: " + actual);
System.out.println("丢失数据: " + (expected - actual));
// 输出类似:期望数量: 10000,实际数量: 9876,丢失数据: 124
}
}3.2 ConcurrentHashMap线程安全验证
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
public class ConcurrentHashMapTest {
public static void main(String[] args) throws InterruptedException {
ConcurrentHashMap<Integer, Integer> map = new ConcurrentHashMap<>();
int threadCount = 10;
int perThread = 1000;
CountDownLatch latch = new CountDownLatch(threadCount);
for (int t = 0; t < threadCount; t++) {
final int start = t * perThread;
new Thread(() -> {
try {
for (int i = start; i < start + perThread; i++) {
map.put(i, i);
}
} finally {
latch.countDown();
}
}).start();
}
latch.await();
System.out.println("期望: " + (threadCount * perThread) + ",实际: " + map.size());
// 输出:期望: 10000,实际: 10000
}
}3.3 ConcurrentHashMap的size()为什么不精确
import java.util.concurrent.ConcurrentHashMap;
public class ConcurrentHashMapSizeTest {
// JDK8 ConcurrentHashMap size()的实现原理:
// 使用CounterCell数组分散计数,类似LongAdder的思想
// baseCount + sum(counterCells) = 近似总数
// 在统计过程中如果有并发修改,结果不保证精确
public static void main(String[] args) {
ConcurrentHashMap<String, String> map = new ConcurrentHashMap<>();
// size()是弱一致性的,在高并发下可能不精确
// 但mappingCount()返回long,更适合大容量场景
long count = map.mappingCount(); // 推荐使用这个
System.out.println("元素数量: " + count);
}
}3.4 HashMap扩容机制详解
// HashMap扩容的核心逻辑(简化版)
// 当size > capacity * loadFactor时触发扩容
// 新容量 = 旧容量 * 2
// JDK8扩容的优化:不需要重新计算hash
// 元素在新数组的位置只有两种情况:
// 1. 原位置(高位bit为0)
// 2. 原位置 + oldCap(高位bit为1)
public class HashMapResizeDemo {
public static void main(String[] args) {
// 演示扩容位置计算
int oldCap = 16;
int newCap = 32;
// 假设一个key的hash = 0b...1_0101 = 21
int hash = 21;
// 旧数组位置:hash & (16-1) = 21 & 15 = 5
int oldIndex = hash & (oldCap - 1);
System.out.println("旧数组位置: " + oldIndex); // 5
// JDK8优化:检查高位bit(第5位,即oldCap对应的bit)
boolean highBit = (hash & oldCap) != 0;
System.out.println("高位bit为1: " + highBit); // true (21 & 16 = 16 != 0)
// 新位置 = 原位置 + oldCap = 5 + 16 = 21
int newIndex = highBit ? oldIndex + oldCap : oldIndex;
System.out.println("新数组位置: " + newIndex); // 21
// 验证:hash & (32-1) = 21 & 31 = 21 ✓
System.out.println("直接计算新位置: " + (hash & (newCap - 1))); // 21
}
}四、面试官追问
追问1:HashMap的初始容量为什么必须是2的幂次方?
我的回答:这是为了让取模运算变成位运算。hash % capacity 等价于 hash & (capacity-1),但后者效率远高于前者。当capacity是2的幂时,capacity-1的二进制全是1,与hash做AND运算就等于取低位,计算出索引位置。如果容量不是2的幂,这个等价关系就不成立了。另外,2的幂也让扩容时的位置计算更简单——只需判断高位bit是0还是1。
追问2:负载因子为什么默认是0.75,而不是1或者0.5?
我的回答:这是时间和空间的折中。负载因子越大,哈希冲突越多,链表越长,查询性能下降;负载因子越小,数组空间浪费越多,扩容频率越高。0.75是实验数据证明的一个比较好的平衡点。从泊松分布的角度看,负载因子0.75时,链表长度达到8的概率约为0.00000006,极小,也是红黑树阈值设为8的数学依据。
追问3:ConcurrentHashMap的put操作在什么情况下会用synchronized,什么情况下用CAS?
我的回答:分三种情况。第一,如果目标槽位为空(tabAt返回null),使用CAS尝试插入,因为CAS本身是原子操作,不需要加锁。第二,如果目标槽位已有节点,使用synchronized锁住该节点(链表头或红黑树根),然后遍历处理。第三,如果检测到节点的hash值为MOVED(-1),说明正在扩容,当前线程会帮助一起迁移数据(helpTransfer)。
追问4:JDK8的ConcurrentHashMap是如何实现扩容期间的并发读?
我的回答:这是ConcurrentHashMap最精妙的设计之一。扩容时会创建一个ForwardingNode节点,hash值为MOVED。已经完成迁移的槽位会被替换成ForwardingNode,其中保存了新数组的引用nextTable。读操作(get)遇到ForwardingNode时,会直接去nextTable中查找,不会被阻塞。这样读操作在整个扩容过程中都是无锁的。
追问5:为什么建议使用ConcurrentHashMap而不是Collections.synchronizedMap(HashMap)?
我的回答:synchronizedMap本质是用synchronized包装所有方法,整个Map只有一把锁,所有读写操作都互斥,并发度为1。而ConcurrentHashMap锁粒度是单个桶,读操作完全无锁(volatile读),理论并发度等于数组容量(默认16到256到...),在高并发场景性能差距可以达到数十倍。另外synchronizedMap的迭代器也不是线程安全的,遍历过程中修改会抛ConcurrentModificationException,而ConcurrentHashMap的迭代器是弱一致性的,不会抛这个异常。
五、同类题目举一反三
1. Hashtable和ConcurrentHashMap有什么区别?
Hashtable是JDK1.0的遗留类,所有方法用synchronized修饰,单锁,性能极差,现代代码不应该使用。ConcurrentHashMap是专门为并发场景设计的,JDK8实现中锁粒度细到每个桶,读无锁,写时只锁对应桶。
2. HashMap的hash()方法为什么要做扰动?
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}hashCode是32位整数,但数组长度通常较小,取模后只用了低位。高位信息被浪费,会导致更多哈希冲突。h ^ (h >>> 16) 把高16位和低16位混合,让高位信息也参与到索引计算中,降低冲突概率。
3. LinkedHashMap是如何保证有序的?
LinkedHashMap继承HashMap,在每个Node节点中增加了before和after指针,形成一条双向链表贯穿所有节点。插入新节点时同时维护这条链表,遍历时按链表顺序返回,而非数组顺序。通过accessOrder参数可以控制是插入顺序还是访问顺序,访问顺序模式下每次get都会把节点移到链表尾部,这正是LRU缓存的实现基础。
六、踩坑实录
坑一:HashMap用作缓存,高并发下CPU飙升
这是我亲身经历的一次线上事故。当时用了一个全局的静态HashMap做本地缓存,压测时发现CPU直接打满。原因正是JDK7的死循环——多个线程同时触发扩容,某个线程的链表形成了环,get操作在遍历这个环形链表时陷入死循环,CPU自然爆了。排查过程用了整整4个小时。修复方案是换成ConcurrentHashMap,上线后CPU立刻恢复正常。
从那以后,但凡多线程共享的Map,我一律用ConcurrentHashMap,绝不侥幸。
坑二:ConcurrentHashMap的computeIfAbsent嵌套调用死锁
JDK8的ConcurrentHashMap有一个已知的bug,在computeIfAbsent中嵌套调用同一个map的computeIfAbsent,会造成死锁。比如:
ConcurrentHashMap<String, String> map = new ConcurrentHashMap<>();
// 死锁!同一个key触发了嵌套的computeIfAbsent
map.computeIfAbsent("key", k -> map.computeIfAbsent("key", k2 -> "value"));原因是JDK8的实现中,computeIfAbsent在计算value时会持有当前桶的锁,而内部再次调用computeIfAbsent时也需要获取同一个桶的锁,但外层已经持有,内层永远获取不到,形成死锁。这个问题在JDK9中已修复。
坑三:用HashMap的keySet做并发遍历和删除
// 这样写会抛ConcurrentModificationException
HashMap<String, Object> map = new HashMap<>();
for (String key : map.keySet()) {
if (someCondition(key)) {
map.remove(key); // 遍历中删除,fast-fail机制抛异常
}
}
// 正确做法1:用迭代器
Iterator<Map.Entry<String, Object>> it = map.entrySet().iterator();
while (it.hasNext()) {
Map.Entry<String, Object> entry = it.next();
if (someCondition(entry.getKey())) {
it.remove(); // 迭代器的remove,不会触发fast-fail
}
}
// 正确做法2:Java8 removeIf
map.entrySet().removeIf(e -> someCondition(e.getKey()));HashMap的modCount字段记录结构性修改次数,迭代器在创建时记录当前modCount,每次next()时检查是否发生变化,发现变化就抛ConcurrentModificationException,这是fail-fast机制。
坑四:ConcurrentHashMap的putIfAbsent和computeIfAbsent的选择
我见过不少人用putIfAbsent来实现"不存在才初始化"的逻辑:
// 性能差的写法:value对象无论key存不存在都会被创建
map.putIfAbsent("key", new ExpensiveObject());
// 推荐写法:value只在需要时创建
map.computeIfAbsent("key", k -> new ExpensiveObject());putIfAbsent每次都会先创建value对象,再判断key是否存在,如果key已经存在则新创建的对象直接被丢弃。如果value的创建成本很高(比如需要建立数据库连接、加载大量数据),这种浪费就很明显。
七、总结
HashMap和ConcurrentHashMap是Java集合框架里考察频率最高的话题,没有之一。这道题能把候选人分成三个层次:
第一层:知道结论,能说出"不安全"和"线程安全"。这是大多数人的水平,不加分。
第二层:理解JDK7死循环的原因、JDK8的改进、ConcurrentHashMap分段锁到节点锁的演进。这个层次能通过多数公司的面试。
第三层:能从源码角度讲清楚扰动函数、扩容时的位置计算、ForwardingNode、CounterCell、CAS失败时的自旋逻辑……这才是大厂P7+的水准。
我给你的建议是:把这篇文章里的代码全部自己跑一遍,然后去看一遍JDK源码里HashMap.putVal()和ConcurrentHashMap.putVal(),两者对比着看,你会有豁然开朗的感觉。
