Java 面试:ConcurrentHashMap 为什么线程安全?
摘要
ConcurrentHashMap 是 Java 面试里非常高频的并发集合类。很多人知道它线程安全,也知道它比 Hashtable 性能更好,但真正面试时容易答散。本文从 HashMap 为什么不安全、Hashtable 为什么性能差、ConcurrentHashMap 如何保证线程安全、JDK 1.7 和 JDK 1.8 的区别、put/get 流程、null 值限制和实际代码案例几个角度,梳理这个高频面试题。
前言
前面我们已经聊过 HashMap 的底层原理。
简单回顾一下:
HashMap 底层是数组 + 链表 + 红黑树,但它不是线程安全的。
那如果在多线程环境下,多个线程同时读写同一个 Map,该怎么办?
这时候就会引出一个非常经典的并发集合类:
ConcurrentHashMap
它是 Java 并发包里非常常用的线程安全 Map,也是面试里经常会被拿来和 HashMap、Hashtable 对比的集合类。
这篇还是按“少废话、直接抓重点”的方式来整理。
一、面试官一般怎么问?
关于 ConcurrentHashMap,常见问法有这些:
ConcurrentHashMap为什么线程安全?ConcurrentHashMap和HashMap有什么区别?ConcurrentHashMap和Hashtable有什么区别?- JDK 1.7 和 JDK 1.8 的
ConcurrentHashMap有什么区别? ConcurrentHashMap的 put 流程大概是什么?ConcurrentHashMap的 get 操作需要加锁吗?- 为什么
ConcurrentHashMap比Hashtable性能好? ConcurrentHashMap能不能存 null?ConcurrentHashMap一定没有并发问题吗?
二、先给结论
一句话先记住:
ConcurrentHashMap 是线程安全的 Map,它通过更细粒度的锁控制、CAS、volatile 等机制,保证并发读写安全,同时尽量减少锁竞争。
在 JDK 1.8 中,ConcurrentHashMap 的底层结构和 HashMap 类似:
数组 + 链表 + 红黑树
但是并发控制方式不一样。
JDK 1.8 中,ConcurrentHashMap 主要依赖:
CAS + synchronized + volatile
简单说:
- 查询操作一般不加锁;
- 插入时,如果桶为空,优先使用 CAS;
- 如果桶不为空,对当前桶节点加锁;
- 锁粒度不是整张表,而是尽量缩小到桶级别;
- 扩容时支持多个线程协助迁移数据。
面试时可以先这样答:
ConcurrentHashMap 底层也是数组 + 链表 + 红黑树。
JDK 1.8 中,它主要通过 CAS + synchronized 保证并发写入安全。
如果桶为空,使用 CAS 插入;如果桶不为空,就对当前桶加 synchronized。
它不是锁整张表,而是尽量只锁当前桶,所以并发性能比 Hashtable 更好。
三、为什么 HashMap 不线程安全?
先看 HashMap。
HashMap 本身没有任何并发控制。
如果多个线程同时修改同一个 HashMap,可能出现:
- 数据覆盖;
- 数据丢失;
- 读取到不一致数据;
- 扩容时结构异常;
- 统计结果不准确。
下面写个简单例子。
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
public class HashMapUnsafeDemo {
public static void main(String[] args) throws InterruptedException {
Map<Integer, Integer> map = new HashMap<>();
int threadCount = 10;
int eachThreadCount = 10000;
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
int start = i * eachThreadCount;
new Thread(() -> {
for (int j = 0; j < eachThreadCount; j++) {
map.put(start + j, j);
}
latch.countDown();
}).start();
}
latch.await();
System.out.println("理论数量:" + threadCount * eachThreadCount);
System.out.println("实际数量:" + map.size());
}
}
多运行几次,可能会发现:
理论数量:100000
实际数量:99982
或者出现其他不稳定结果。
这就是因为多个线程同时修改 HashMap,没有任何线程安全保证。
所以:
多线程共享修改 Map 时,不建议使用 HashMap。
四、Hashtable 为什么不推荐?
既然 HashMap 不是线程安全的,那早期可以用 Hashtable。
Hashtable 是线程安全的,因为它很多方法都加了 synchronized。
类似这样:
public synchronized V put(K key, V value) {
// ...
}
问题也在这里。
它锁的是整个对象。
也就是说,多个线程操作同一个 Hashtable 时,即使访问的是不同 key,也可能互相阻塞。
简单理解:
线程 A 操作 key1,要等锁
线程 B 操作 key2,也要等同一把锁
线程 C 操作 key3,还是要等同一把锁
所以 Hashtable 虽然线程安全,但锁粒度太粗,并发性能不好。
一句话总结:
Hashtable 线程安全,但锁的是整张表,性能较差,现在实际开发中基本不推荐使用。
五、ConcurrentHashMap 怎么解决问题?
ConcurrentHashMap 的核心思路是:
不要一上来锁整张表,能无锁就无锁,必须加锁时尽量缩小锁范围。
在 JDK 1.8 中,它主要通过下面几种方式保证线程安全:
CASsynchronizedvolatile- 桶级别加锁
- 多线程协助扩容
简单理解:
读操作尽量不加锁
写操作尽量只锁当前桶
扩容时多个线程可以一起帮忙迁移数据
这就是它比 Hashtable 并发性能更好的核心原因。
六、JDK 1.7 和 JDK 1.8 有什么区别?
这个是面试重点。
1. JDK 1.7:Segment 分段锁
JDK 1.7 中,ConcurrentHashMap 使用的是:
Segment 分段锁
结构大概是:
ConcurrentHashMap
↓
Segment[]
↓
HashEntry[]
每个 Segment 可以理解成一个小的 HashMap。
不同线程访问不同 Segment 时,可以并发执行。
所以 JDK 1.7 的核心是:
分段锁
优点是:
- 不锁整张表;
- 不同 Segment 可以并发访问;
- 比
Hashtable性能更好。
2. JDK 1.8:CAS + synchronized
JDK 1.8 取消了 Segment 分段锁。
底层结构变成:
数组 + 链表 + 红黑树
并发控制主要靠:
CAS + synchronized
锁粒度进一步缩小到桶级别。
也就是说:
JDK 1.8 不再锁一整个 Segment,而是尽量只锁当前桶。
面试时可以这样答:
JDK 1.7 的 ConcurrentHashMap 主要通过 Segment 分段锁实现线程安全。
JDK 1.8 取消了 Segment,底层结构变成数组 + 链表 + 红黑树,线程安全主要通过 CAS + synchronized 实现,锁粒度更细。
七、put 流程大概是什么?
不用死背源码,面试时能说清楚大概流程就行。
JDK 1.8 中,put 大概流程如下:
1. 判断 table 是否初始化
2. 根据 key 计算 hash
3. 根据 hash 定位数组下标
4. 如果桶为空,使用 CAS 放入节点
5. 如果桶不为空,对当前桶节点加 synchronized
6. 在链表或红黑树中插入或覆盖
7. 判断是否需要树化或扩容
简单版回答:
put 时先根据 key 计算 hash,然后定位到数组桶。
如果桶为空,就用 CAS 尝试插入。
如果桶不为空,说明发生冲突,就对当前桶加 synchronized,然后在链表或红黑树中插入。
插入完成后,再判断是否需要树化或扩容。
八、get 操作需要加锁吗?
一般不需要。
ConcurrentHashMap 的 get 操作通常是无锁的。
它主要依赖 volatile 保证可见性。
get 大概流程:
1. 根据 key 计算 hash
2. 定位桶位置
3. 如果第一个节点就是目标 key,直接返回
4. 否则在链表或红黑树中继续查找
面试时可以这样答:
ConcurrentHashMap 的 get 操作一般不加锁,主要依赖 volatile 保证可见性,所以读性能比较好。
这也是 ConcurrentHashMap 读性能比较高的一个原因。
九、ConcurrentHashMap 基本使用示例
最基础使用方式:
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class ConcurrentHashMapBasicDemo {
public static void main(String[] args) {
Map<String, String> map = new ConcurrentHashMap<>();
map.put("Java", "后端开发");
map.put("Redis", "缓存");
map.put("MySQL", "数据库");
System.out.println(map.get("Java"));
System.out.println(map.get("Redis"));
System.out.println(map.get("MySQL"));
}
}
输出:
后端开发
缓存
数据库
这种用法和普通 HashMap 很像。
区别是:
ConcurrentHashMap 更适合多线程共享读写场景。
十、多线程写入示例
用 ConcurrentHashMap 改造前面的例子。
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
public class ConcurrentHashMapSafeDemo {
public static void main(String[] args) throws InterruptedException {
Map<Integer, Integer> map = new ConcurrentHashMap<>();
int threadCount = 10;
int eachThreadCount = 10000;
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
int start = i * eachThreadCount;
new Thread(() -> {
for (int j = 0; j < eachThreadCount; j++) {
map.put(start + j, j);
}
latch.countDown();
}).start();
}
latch.await();
System.out.println("理论数量:" + threadCount * eachThreadCount);
System.out.println("实际数量:" + map.size());
}
}
输出一般是:
理论数量:100000
实际数量:100000
这说明在多线程并发写入时,ConcurrentHashMap 能保证单次 put 操作的线程安全。
十一、ConcurrentHashMap 能不能存 null?
不能。
ConcurrentHashMap 不允许:
- null key;
- null value。
示例:
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class ConcurrentHashMapNullDemo {
public static void main(String[] args) {
Map<String, String> map = new ConcurrentHashMap<>();
map.put(null, "Java");
}
}
运行会报:
NullPointerException
再看 value 为 null:
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class ConcurrentHashMapNullValueDemo {
public static void main(String[] args) {
Map<String, String> map = new ConcurrentHashMap<>();
map.put("Java", null);
}
}
也会报:
NullPointerException
为什么不允许 null?
主要是为了避免并发场景下产生歧义。
比如:
map.get("A");
如果返回 null,到底表示:
key 不存在?
还是 value 本身就是 null?
在并发环境下,这个判断会更复杂。
所以 ConcurrentHashMap 直接禁止 null key 和 null value。
面试回答:
ConcurrentHashMap 不允许 null key 和 null value。
主要是为了避免并发环境下 get 返回 null 时产生歧义,无法判断是 key 不存在,还是 value 本身就是 null。
十二、组合操作不一定线程安全
这个点很重要。
ConcurrentHashMap 能保证单次操作线程安全,比如:
map.put(key, value);
map.get(key);
map.remove(key);
但它不能保证你写的一组组合逻辑天然线程安全。
比如下面这种写法:
if (!map.containsKey("Java")) {
map.put("Java", "后端开发");
}
这不是原子操作。
可能线程 A 和线程 B 都判断不存在,然后都执行 put。
看个例子。
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
public class ContainsKeyAndPutDemo {
public static void main(String[] args) throws InterruptedException {
Map<String, Integer> map = new ConcurrentHashMap<>();
int threadCount = 10;
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
int value = i;
new Thread(() -> {
if (!map.containsKey("count")) {
map.put("count", value);
}
latch.countDown();
}).start();
}
latch.await();
System.out.println(map);
}
}
这段代码不一定能体现特别明显的问题,但逻辑上它不是原子的。
更推荐写法是:
map.putIfAbsent("count", 1);
完整示例:
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class PutIfAbsentDemo {
public static void main(String[] args) {
Map<String, Integer> map = new ConcurrentHashMap<>();
map.putIfAbsent("count", 1);
map.putIfAbsent("count", 2);
System.out.println(map.get("count"));
}
}
输出:
1
因为第一次插入成功后,第二次发现 key 已存在,就不会覆盖。
所以面试时要补一句:
ConcurrentHashMap 保证的是单次操作线程安全。
如果是先判断再修改这种组合操作,要使用 putIfAbsent、computeIfAbsent 这类原子方法。
十三、computeIfAbsent 示例
computeIfAbsent 也是实际开发里很常用的方法。
它的作用是:
如果 key 不存在,就根据 key 计算一个 value 放进去;如果 key 已存在,就直接返回旧值。
示例:
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class ComputeIfAbsentDemo {
public static void main(String[] args) {
Map<String, String> map = new ConcurrentHashMap<>();
String value1 = map.computeIfAbsent("Java", key -> key + " 后端开发");
String value2 = map.computeIfAbsent("Java", key -> key + " 新值");
System.out.println(value1);
System.out.println(value2);
System.out.println(map);
}
}
输出:
Java 后端开发
Java 后端开发
{Java=Java 后端开发}
第二次不会重新计算,因为 key 已经存在。
实际开发中,可以用它做缓存初始化。
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class LocalCacheDemo {
private static final Map<Long, String> userCache = new ConcurrentHashMap<>();
public static void main(String[] args) {
String userName = getUserName(1001L);
System.out.println(userName);
String userName2 = getUserName(1001L);
System.out.println(userName2);
}
public static String getUserName(Long userId) {
return userCache.computeIfAbsent(userId, id -> queryUserNameFromDb(id));
}
private static String queryUserNameFromDb(Long userId) {
System.out.println("查询数据库,userId=" + userId);
return "用户" + userId;
}
}
输出:
查询数据库,userId=1001
用户1001
用户1001
可以看到,同一个 userId 第二次不会再查数据库。
当然,真实项目里如果做本地缓存,还要考虑:
- 数据过期;
- 数据一致性;
- 内存占用;
- 是否需要 Caffeine、Redis 这类缓存组件。
这里主要是演示 computeIfAbsent 的用法。
十四、并发计数不要直接 get 后 put
有些人会这样写计数逻辑:
Integer count = map.get("success");
if (count == null) {
map.put("success", 1);
} else {
map.put("success", count + 1);
}
这在并发场景下是不安全的。
因为多个线程可能同时读到同一个旧值,然后覆盖写入。
错误示例:
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
public class WrongCounterDemo {
public static void main(String[] args) throws InterruptedException {
Map<String, Integer> map = new ConcurrentHashMap<>();
int threadCount = 10;
int eachThreadCount = 10000;
CountDownLatch latch = new CountDownLatch(threadCount);
map.put("success", 0);
for (int i = 0; i < threadCount; i++) {
new Thread(() -> {
for (int j = 0; j < eachThreadCount; j++) {
Integer count = map.get("success");
map.put("success", count + 1);
}
latch.countDown();
}).start();
}
latch.await();
System.out.println("理论数量:" + threadCount * eachThreadCount);
System.out.println("实际数量:" + map.get("success"));
}
}
可能输出:
理论数量:100000
实际数量:42631
原因是:
get 和 put 分开执行,这组操作不是原子的。
更推荐的写法是使用 AtomicInteger 或 LongAdder。
例如:
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.LongAdder;
public class RightCounterDemo {
public static void main(String[] args) throws InterruptedException {
Map<String, LongAdder> map = new ConcurrentHashMap<>();
int threadCount = 10;
int eachThreadCount = 10000;
CountDownLatch latch = new CountDownLatch(threadCount);
map.put("success", new LongAdder());
for (int i = 0; i < threadCount; i++) {
new Thread(() -> {
for (int j = 0; j < eachThreadCount; j++) {
map.get("success").increment();
}
latch.countDown();
}).start();
}
latch.await();
System.out.println("理论数量:" + threadCount * eachThreadCount);
System.out.println("实际数量:" + map.get("success").sum());
}
}
输出:
理论数量:100000
实际数量:100000
也可以结合 computeIfAbsent:
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.LongAdder;
public class CounterWithComputeIfAbsentDemo {
public static void main(String[] args) throws InterruptedException {
Map<String, LongAdder> map = new ConcurrentHashMap<>();
int threadCount = 10;
int eachThreadCount = 10000;
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
new Thread(() -> {
for (int j = 0; j < eachThreadCount; j++) {
map.computeIfAbsent("success", key -> new LongAdder()).increment();
}
latch.countDown();
}).start();
}
latch.await();
System.out.println("理论数量:" + threadCount * eachThreadCount);
System.out.println("实际数量:" + map.get("success").sum());
}
}
这也是实际项目里比较常见的写法。
十五、ConcurrentHashMap 和 HashMap 的区别
简单对比:
|
对比项 |
HashMap |
ConcurrentHashMap |
|---|---|---|
|
线程安全 |
不安全 |
安全 |
|
并发场景 |
不适合 |
适合 |
|
null key |
允许一个 null key |
不允许 |
|
null value |
允许 null value |
不允许 |
|
底层结构 |
数组 + 链表 + 红黑树 |
数组 + 链表 + 红黑树 |
|
典型用途 |
普通 Map |
并发共享 Map |
一句话:
HashMap 适合单线程或局部变量场景;ConcurrentHashMap 适合多线程共享读写场景。
十六、ConcurrentHashMap 和 Hashtable 的区别
|
对比项 |
Hashtable |
ConcurrentHashMap |
|---|---|---|
|
线程安全 |
安全 |
安全 |
|
锁粒度 |
整表锁 |
桶级别锁 |
|
性能 |
较差 |
更好 |
|
null key/value |
不允许 |
不允许 |
|
推荐程度 |
不推荐 |
推荐 |
一句话:
Hashtable 是早期线程安全 Map,方法级 synchronized 锁粒度太粗;
ConcurrentHashMap 锁粒度更细,并发性能更好,实际开发中更推荐。
十七、实际开发中怎么用?
1. 多线程任务结果记录
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class TaskResultDemo {
private static final Map<String, String> taskResultMap = new ConcurrentHashMap<>();
public static void main(String[] args) {
taskResultMap.put("task_001", "SUCCESS");
taskResultMap.put("task_002", "FAIL");
System.out.println(taskResultMap.get("task_001"));
}
}
2. 本地缓存
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class UserCacheDemo {
private static final Map<Long, UserInfo> userCache = new ConcurrentHashMap<>();
public static void main(String[] args) {
UserInfo user = getUserInfo(1001L);
System.out.println(user);
}
public static UserInfo getUserInfo(Long userId) {
return userCache.computeIfAbsent(userId, UserCacheDemo::queryUserFromDb);
}
private static UserInfo queryUserFromDb(Long userId) {
System.out.println("模拟查询数据库,userId=" + userId);
return new UserInfo(userId, "用户" + userId);
}
static class UserInfo {
private Long userId;
private String userName;
public UserInfo(Long userId, String userName) {
this.userId = userId;
this.userName = userName;
}
@Override
public String toString() {
return "UserInfo{" +
"userId=" + userId +
", userName='" + userName + '\'' +
'}';
}
}
}
3. 批处理分组统计
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.LongAdder;
public class GroupCountDemo {
public static void main(String[] args) {
List<String> statusList = Arrays.asList(
"SUCCESS", "FAIL", "SUCCESS", "PROCESSING",
"SUCCESS", "FAIL", "SUCCESS"
);
Map<String, LongAdder> countMap = new ConcurrentHashMap<>();
statusList.parallelStream().forEach(status -> {
countMap.computeIfAbsent(status, key -> new LongAdder()).increment();
});
countMap.forEach((key, value) -> {
System.out.println(key + " = " + value.sum());
});
}
}
输出类似:
PROCESSING = 1
SUCCESS = 4
FAIL = 2
这个例子里:
ConcurrentHashMap保证并发访问安全;LongAdder适合高并发计数;computeIfAbsent保证初始化逻辑更简洁。
十八、ConcurrentHashMap 一定不会有并发问题吗?
不是。
这一点面试里很加分。
ConcurrentHashMap 只能保证 Map 自身提供的单个操作是线程安全的。
但业务层面的组合逻辑,不一定线程安全。
例如:
if (!map.containsKey(key)) {
map.put(key, value);
}
这就是典型的组合操作,不是原子的。
更推荐:
map.putIfAbsent(key, value);
或者:
map.computeIfAbsent(key, k -> value);
再比如计数:
错误写法:
map.put(key, map.get(key) + 1);
推荐:
map.computeIfAbsent(key, k -> new LongAdder()).increment();
所以面试可以补一句:
ConcurrentHashMap 不是万能的。
它保证的是容器内部操作的线程安全,但如果业务代码由多个操作组合而成,仍然要考虑原子性。
十九、面试回答模板
如果面试官问:
ConcurrentHashMap 为什么线程安全?
可以这样回答:
ConcurrentHashMap 是线程安全的 Map,适合多线程并发读写场景。
JDK 1.7 中主要通过 Segment 分段锁实现线程安全,不同 Segment 可以并发访问,避免像 Hashtable 一样锁整张表。
JDK 1.8 中取消了 Segment,底层结构变成数组 + 链表 + 红黑树,线程安全主要通过 CAS + synchronized 实现。put 时,如果桶为空,会使用 CAS 插入;如果桶不为空,会对当前桶节点加 synchronized,然后在链表或红黑树中完成插入或更新。
它的 get 操作一般不加锁,主要依赖 volatile 保证可见性,所以读性能比较好。
相比 Hashtable 直接锁整个方法,ConcurrentHashMap 锁粒度更细,并发性能更好。
不过 ConcurrentHashMap 只能保证单次操作线程安全,如果是 containsKey 再 put 这种组合操作,还是要用 putIfAbsent、computeIfAbsent 这类原子方法。
这段回答基本就够用了。
二十、一句话总结
ConcurrentHashMap 的核心不是简单“加锁”。
它真正的重点是:
CAS + synchronized + 更细粒度锁控制
JDK 1.7 靠分段锁。
JDK 1.8 靠 CAS + synchronized,锁粒度缩小到桶级别。
面试时只要把下面几个点讲清楚:
- 为什么
HashMap不安全; Hashtable为什么性能差;- JDK 1.7 和 JDK 1.8 的区别;
- put 和 get 的大概流程;
- 为什么不能存 null;
- 组合操作为什么仍然要注意原子性;
基本就不会乱。
大家加油:)
更多推荐
所有评论(0)