ConcurrentHashMap 为什么线程安全?
面试频率:★★★★★
工作频率:★★★★★
⚡30 秒速记(复习只看这里)
🟢 一句话
ConcurrentHashMap 通过 CAS + synchronized 保证线程安全,并且只锁当前桶(数组下标对应的位置),避免锁整个 Map,提高并发性能。
🟡 JDK7 和 JDK8 区别
JDK7
使用:
Segment 分段锁
结构:
ConcurrentHashMap
Segment0 🔒
Segment1 🔒
Segment2 🔒
锁的是:
一段数据
JDK8
取消 Segment。
结构:
table
0
1
2
3
5 🔒
6
锁的是:
某一个桶(bin)
也就是:
数组某个下标对应的链表或红黑树。
🟠 JDK8 加锁流程
情况1:桶为空
table[5] == null
↓
CAS 插入
↓
成功
不需要加锁。
情况2:桶已有数据
table[5]
↓
Node
↓
Node
修改链表/红黑树:
synchronized(桶头节点)
锁当前桶。
🔴 一句话
JDK8 ConcurrentHashMap 不是先 synchronized 再 CAS,而是根据场景选择:空桶使用 CAS,有冲突修改桶结构时使用 synchronized。
📚 完整知识
一、为什么需要 ConcurrentHashMap?
HashMap:
线程A
put()
线程B
put()
同时修改:
数组
链表
红黑树
可能导致:
- 数据覆盖
- 数据丢失
- JDK1.7 扩容死循环
所以:
HashMap 不适合多线程环境。
二、为什么不用 synchronized 给整个 Map 加锁?
例如:
synchronized(map){
map.put("A",1);
}
虽然安全。
但是:
锁太大。
例如:
线程A 修改 table[5]
线程B 修改 table[20]
两个操作没有关系。
但是整张 Map 被锁住。
并发性能下降。
三、ConcurrentHashMap 核心思想
一句话:
缩小锁的范围。
不是:
整个 Map 一把锁
而是:
一个桶一把锁
例如:
table
0
1
2 🔒
3
4
5 🔒
线程A:
修改:
table[2]
线程B:
修改:
table[5]
可以同时执行。
四、JDK7 分段锁
JDK7:
结构:
ConcurrentHashMap
-----------------
| | |
Seg0 Seg1 Seg2
🔒 🔒 🔒
每个 Segment:
拥有自己的锁。
例如:
线程A:
修改 Segment0
线程B:
修改 Segment1
可以同时执行。
缺点:
锁粒度还是比较大。
五、JDK8 实现原理
JDK8:
取消 Segment。
结构类似 HashMap:
table
0
1
2
3
4
5
采用:
CAS
+
synchronized
六、为什么桶为空使用 CAS?
假设:
table[5] == null
两个线程:
同时 put:
线程A
发现为空
线程B
发现为空
如果都直接写:
可能冲突。
所以使用 CAS:
比较:
现在是不是还是 null?
如果是:
替换成 Node
如果不是:
失败重试
结果:
线程A CAS成功
线程B CAS失败
七、为什么桶有数据使用 synchronized?
例如:
table[5]
↓
A
↓
B
现在插入:
C
需要修改:
A.next
B.next
链表结构
甚至可能:
红黑树调整
涉及多个状态。
CAS 不适合。
所以:
synchronized (头节点){
}
锁住当前桶。
八、为什么不是先 CAS 再 synchronized?
因为:
两者解决的问题不同。
CAS
适合:
单个变量修改。
例如:
null → Node
synchronized
适合:
复杂结构修改。
例如:
链表插入
红黑树调整
所以:
CAS 和 synchronized 没有固定先后,根据场景选择。
九、ConcurrentHashMap 锁在哪里?
JDK8:
锁的是:
table[index]
对应的桶。
例如:
table[5]
↓
NodeA
↓
NodeB
锁:
NodeA(头节点)
不是:
❌ 整个 Map
❌ 一段数组
❌ Segment
十、为什么 CAS 不能修改链表?
CAS 适合:
单个变量更新。
例如:
count:
0 → 1
但是链表修改:
需要改变:
Node.next
尾节点
链表结构
多个状态。
所以:
使用 synchronized。
💼 工作场景
SpringBoot 全局缓存
错误:
@Service
public class UserService {
private Map<String,Object> cache =
new HashMap<>();
}
多个 HTTP 请求:
请求1 → 线程A
请求2 → 线程B
同时修改:
HashMap
可能出现线程安全问题。
正确:
private Map<String,Object> cache =
new ConcurrentHashMap<>();
🎤 面试回答(30秒)
ConcurrentHashMap 是为了解决 HashMap 在并发环境下线程不安全的问题。JDK7 使用 Segment 分段锁,把数据拆分成多个段,每个段独立加锁。JDK8 取消 Segment,采用 CAS 和 synchronized 实现并发控制。对于空桶插入使用 CAS,提高并发性能;对于已经存在节点的桶,因为需要修改链表或红黑树结构,所以使用 synchronized 锁住当前桶。相比 Hashtable 锁整个 Map,ConcurrentHashMap 锁粒度更小,并发性能更好。
🎯 面试追问
Q1:JDK8 锁的是整个 Map 吗?
答:
不是。
锁的是:
table[index]
对应的桶。
Q2:两个线程同时操作 table[5] 怎么办?
答:
竞争同一个桶。
一个线程获取 synchronized 锁。
另一个等待。
Q3:为什么不用 synchronized 给整个 Map 加锁?
答:
锁粒度太大。
不同桶之间没有竞争。
降低并发性能。
Q4:CAS 和 synchronized 谁优先?
答:
没有固定优先级。
桶为空使用 CAS。
桶已有数据修改结构使用 synchronized。
Q5:ConcurrentHashMap 可以完全替代 HashMap 吗?
答:
不能。
单线程场景 HashMap 性能更好。
只有并发修改场景才使用 ConcurrentHashMap。
⚠️ 工作踩坑
1. 多线程环境使用 HashMap
问题:
数据异常
数据丢失
2. 认为 JDK8 HashMap 线程安全
错误。
JDK8 只是解决扩容死循环。
3. 认为 ConcurrentHashMap 所有操作都是原子
错误:
if(!map.containsKey(key)){
map.put(key,value);
}
多个线程仍可能重复执行。
应该:
map.putIfAbsent(key,value);
❓ 自测
- ConcurrentHashMap 为什么线程安全?
因为 JDK8 使用 CAS + synchronized。
空桶插入使用 CAS,同一个桶发生竞争时使用 synchronized 锁住当前桶。
- JDK7 和 JDK8 区别?
JDK7 使用 Segment 分段锁,锁的是一段数据。
JDK8 去掉 Segment,使用 CAS + synchronized,锁粒度降低到桶级别。
- JDK8 锁哪里?
锁的是数组某个下标对应的桶,也就是链表头节点或者红黑树节点。
- 为什么桶为空使用 CAS?
多个线程可能同时初始化空桶,CAS保证只有一个线程成功。
- 为什么桶有数据使用 synchronized?
因为修改链表或红黑树涉及多个节点变化,CAS不适合。
- 为什么CAS不适合链表?
CAS适合单个变量修改,链表需要修改多个引用关系。
- Hashtable为什么慢?
Hashtable方法级 synchronized,相当于整个Map一把锁。
- SpringBoot全局Map为什么用ConcurrentHashMap?
Spring Bean默认单例,多请求共享同一个Map,需要保证线程安全。
Spring Bean 单例和线程安全
Spring Bean 默认单例。
单例本身不是问题。
问题是:
单例 Bean 中存在共享可变成员变量。
例如:
private HashMap cache;
多个 HTTP 请求线程同时访问。
需要:
ConcurrentHashMap / 加锁 / 外部缓存(Redis)