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

发布时间:2026/7/6 3:49:44
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能不能存 nullConcurrentHashMap一定没有并发问题吗二、先给结论一句话先记住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 { MapInteger, 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.7Segment 分段锁JDK 1.7 中ConcurrentHashMap使用的是Segment 分段锁结构大概是ConcurrentHashMap ↓ Segment[] ↓ HashEntry[]每个Segment可以理解成一个小的 HashMap。不同线程访问不同 Segment 时可以并发执行。所以 JDK 1.7 的核心是分段锁优点是不锁整张表不同 Segment 可以并发访问比Hashtable性能更好。2. JDK 1.8CAS synchronizedJDK 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) { MapString, 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 { MapInteger, 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 keynull value。示例import java.util.Map; import java.util.concurrent.ConcurrentHashMap; public class ConcurrentHashMapNullDemo { public static void main(String[] args) { MapString, String map new ConcurrentHashMap(); map.put(null, Java); } }运行会报NullPointerException再看 value 为 nullimport java.util.Map; import java.util.concurrent.ConcurrentHashMap; public class ConcurrentHashMapNullValueDemo { public static void main(String[] args) { MapString, 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 { MapString, 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) { MapString, 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) { MapString, 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 后端开发 {JavaJava 后端开发}第二次不会重新计算因为 key 已经存在。实际开发中可以用它做缓存初始化。import java.util.Map; import java.util.concurrent.ConcurrentHashMap; public class LocalCacheDemo { private static final MapLong, 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; } }输出查询数据库userId1001 用户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 { MapString, 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 { MapString, 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也可以结合computeIfAbsentimport 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 { MapString, 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 的区别简单对比对比项HashMapConcurrentHashMap线程安全不安全安全并发场景不适合适合null key允许一个 null key不允许null value允许 null value不允许底层结构数组 链表 红黑树数组 链表 红黑树典型用途普通 Map并发共享 Map一句话HashMap 适合单线程或局部变量场景ConcurrentHashMap 适合多线程共享读写场景。十六、ConcurrentHashMap 和 Hashtable 的区别对比项HashtableConcurrentHashMap线程安全安全安全锁粒度整表锁桶级别锁性能较差更好null key/value不允许不允许推荐程度不推荐推荐一句话Hashtable 是早期线程安全 Map方法级 synchronized 锁粒度太粗 ConcurrentHashMap 锁粒度更细并发性能更好实际开发中更推荐。十七、实际开发中怎么用1. 多线程任务结果记录import java.util.Map; import java.util.concurrent.ConcurrentHashMap; public class TaskResultDemo { private static final MapString, 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 MapLong, 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) { ListString statusList Arrays.asList( SUCCESS, FAIL, SUCCESS, PROCESSING, SUCCESS, FAIL, SUCCESS ); MapString, 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组合操作为什么仍然要注意原子性基本就不会乱。大家加油