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;
- 组合操作为什么仍然要注意原子性;
基本就不会乱。
大家加油:)