当前位置: 首页 > news >正文

Java 面试:ConcurrentHashMap 为什么线程安全?

摘要

ConcurrentHashMap是 Java 面试里非常高频的并发集合类。很多人知道它线程安全,也知道它比Hashtable性能更好,但真正面试时容易答散。本文从HashMap为什么不安全、Hashtable为什么性能差、ConcurrentHashMap如何保证线程安全、JDK 1.7 和 JDK 1.8 的区别、put/get 流程、null 值限制和实际代码案例几个角度,梳理这个高频面试题。


前言

前面我们已经聊过HashMap的底层原理。

简单回顾一下:

HashMap底层是数组 + 链表 + 红黑树,但它不是线程安全的。

那如果在多线程环境下,多个线程同时读写同一个 Map,该怎么办?

这时候就会引出一个非常经典的并发集合类:

ConcurrentHashMap

它是 Java 并发包里非常常用的线程安全 Map,也是面试里经常会被拿来和HashMapHashtable对比的集合类。

这篇还是按“少废话、直接抓重点”的方式来整理。


一、面试官一般怎么问?

关于ConcurrentHashMap,常见问法有这些:

  • ConcurrentHashMap为什么线程安全?
  • ConcurrentHashMapHashMap有什么区别?
  • ConcurrentHashMapHashtable有什么区别?
  • JDK 1.7 和 JDK 1.8 的ConcurrentHashMap有什么区别?
  • ConcurrentHashMap的 put 流程大概是什么?
  • ConcurrentHashMap的 get 操作需要加锁吗?
  • 为什么ConcurrentHashMapHashtable性能好?
  • 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 中,它主要通过下面几种方式保证线程安全:

  • CAS
  • synchronized
  • volatile
  • 桶级别加锁
  • 多线程协助扩容

简单理解:

读操作尽量不加锁 写操作尽量只锁当前桶 扩容时多个线程可以一起帮忙迁移数据

这就是它比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 分开执行,这组操作不是原子的。

更推荐的写法是使用AtomicIntegerLongAdder

例如:

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;
  • 组合操作为什么仍然要注意原子性;

基本就不会乱。

大家加油:)


http://www.jsqmd.com/news/1132387/

相关文章:

  • Lua--基础入门
  • 库存并发安全控制的架构设计
  • 谷歌两款AI学习工具大揭秘:NotebookLM与Learn About谁更胜一筹?
  • MySQL视图学习笔记——视图与数据表增删改操作对比
  • 多服务上线日记一:
  • Windows 7 Problem Steps Recorder
  • 5分钟掌握Spectralizer:OBS直播音频可视化插件终极配置指南
  • 大语言模型解码策略与低资源部署技术详解
  • 机器人操作鲁棒性:当灵巧手遇上真实世界的不确定性
  • LinkedIn钓鱼攻击深度解析:识别伪装官方通知与账户安全防护指南
  • 别再硬写提示词了!LangChain PromptTemplate从入门到实战
  • 在ASP.NET MVC中对表进行通用的增删改
  • Selenium 高级进阶操作详解
  • p006-py文件编译成pyd
  • Linux内核CFS完全公平调度器:从vruntime到负载均衡的深度实现分析
  • How-To: Using the N* Stack, part 3
  • GEO代理接单后总部负责落地吗
  • PowerShell 路径规则详解:从基础到高级
  • 2026杭州初中毕业女生暑假学什么好?选对方向比努力更重要
  • 剪映专业版教程:制作西施跳广场舞效果
  • IPC-2152 标准深度解析:3大常见误区与5个影响通流的关键PCB设计参数
  • MLflow在LLM评估中的工程实践:实现可追溯、可比较、可归因的模型管理
  • 06-高级模式与实战项目——01. Render Props - 共享渲染逻辑
  • AI产品设计的底层逻辑:认知减负与人机信任感构建
  • Windows Mobile下访问Sqlite的Native C++封装
  • 数据分析转大模型:换个角度,从方案设计到上线检查
  • 域名与DNS批量管理实战:OpenClaw自动解析检测、批量修改与监控全攻略
  • Google chrome OS vmdk文件在WMware下运行的办法
  • TFT-LCD 驱动架构对比:4 种 Cs 存储电容布局的优缺点与选型指南
  • 高空航拍地面建筑物数据集7682张VOC+YOLO格式