🔥 面试必问!本文深入剖析Java并发编程中最重要的集合类ConcurrentHashMap,从设计理念到实现原理,从源码分析到实战应用,带你全面掌握这个并发编程的必备技能。
📚 系列专栏推荐:
开篇寄语
亲爱的读者朋友们,欢迎来到Java集合夜话系列的第7篇文章!
在上一篇文章中,我们深入探讨了HashMap的实现原理,但也提到了它在并发环境下的局限性。今天,让我们一起探索Java并发编程中的明星产品:ConcurrentHashMap。它就像一位优秀的交通指挥官,在繁忙的十字路口(并发场景)中,巧妙地调度着来往的车流(线程),保证了效率的同时还确保了安全。
带着这些问题,开始今天的探索:
- 为什么HashMap和Hashtable都不适合并发场景?
- ConcurrentHashMap是如何在保证线程安全的同时获得高性能的?
- 为什么JDK8要放弃分段锁,转而使用CAS+Synchronized?
- 如何在实际项目中正确地使用ConcurrentHashMap?
本文亮点
- 🔒 深入解析并发安全原理
- ⚡ 揭秘高性能设计思想
- 🔍 源码级别的实现分析
- 💡 实战经验总结分享
- 📝 面试热点完整剖析
让我们开始这段探索并发集合的奥秘之旅吧!

文章目录
1. 基础认知:并发集合的演进之路
1.1 为什么需要ConcurrentHashMap?
在并发编程中,我们经常需要在多个线程之间共享数据。HashMap虽然性能优秀,但它不是线程安全的。让我们通过一个实际的例子来看看在并发场景下使用HashMap会遇到什么问题:
public class HashMapConcurrencyProblem {
private static Map<String, Integer> map = new HashMap<>();
public static void main(String[] args) throws InterruptedException {
// 创建10个线程并发写入数据
Thread[] threads = new Thread[10];
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 1000; j++) {
map.put("key" + j, j);
}
});
threads[i].start();
}
// 等待所有线程执行完成
for (Thread thread : threads) {
thread.join();
}
// 理论上应该有1000个元素
System.out.println("实际元素个数:" + map.size());
}
}
运行这段代码,你会发现:
- 最终的元素个数可能小于预期
- 可能会抛出ConcurrentModificationException
- 严重时可能导致死循环(在JDK7中)
这就是为什么我们需要一个线程安全的Map实现。
1.2 HashMap的线程安全问题深度剖析
HashMap在并发环境下主要存在三个问题:
-
数据丢失问题
当多个线程同时写入数据时,由于没有同步机制,可能导致后写入的数据覆盖先写入的数据。想象一下,这就像多个人同时在一本账本上记账,但没有任何协调机制,最终账目必然混乱。 -
死循环问题(JDK7)
在JDK7中,HashMap在并发扩容时可能会形成环形链表,导致get操作时陷入死循环。这个问题在JDK8中通过红黑树结构得到了解决,但并发安全的问题依然存在。 -
不一致性问题
HashMap的size可能会不准确,因为在计算size时,可能有其他线程正在进行写操作。就像在统计教室里的学生数量时,学生们还在进进出出一样。
1.3 从Hashtable到ConcurrentHashMap的演进历程
面对HashMap的线程安全问题,Java的解决方案经历了以下演进:
- Hashtable时代(古老的解决方案)
public class HashTableExample {
private Hashtable<String, Integer> table = new Hashtable<>();
public void operate() {
// 所有方法都使用synchronized修饰
table.put("key", 1); // 锁住整个对象
table.get("key"); // 读操作也要加锁
}
}
Hashtable通过对所有方法加synchronized关键字来实现线程安全,但这种方式过于粗暴,导致性能低下。就像一个只有一个出入口的停车场,即使车位还很多,车辆也要排队进入。
- Collections.synchronizedMap时代(过渡方案)
Map<String, Integer> synchronizedMap =
Collections.synchronizedMap(new HashMap<>());
这种方式本质上与Hashtable类似,仍然是对整个Map加锁,性能并没有质的提升。
- ConcurrentHashMap的登场(现代方案)
- JDK7:采用分段锁技术,将Map分为多个Segment
- JDK8:摒弃分段锁,采用CAS+Synchronized实现更细粒度的锁控制
ConcurrentHashMap的设计理念就像一个设计良好的大型停车场:
- 多个入口,减少排队等待
- 分区管理,互不影响
- 精确的车位锁定,而不是封锁整个停车场
深入理解HashMap的死循环问题
在JDK7中,HashMap的死循环问题是这样产生的:
- 初始状态:假设有一个链表 A -> B -> C
// 初始状态示意
Entry {
A.next -> B
B.next -> C
C.next -> null
}
- 并发扩容过程:
Thread1: Thread2:
A -> B -> C A -> B -> C
| |
获取到A 获取到B
A.next = null B.next = A // 致命点!
// 形成环:B -> A -> B -> A ...
这就像两个人同时整理一条铁链,由于没有协调,可能会不小心将铁链扣成环状。这就解释了为什么在JDK7中并发操作HashMap时可能会出现死循环。
- 关于Hashtable的性能问题,可以通过具体数据说明:
@Test
public void performanceComparisonTest() {
Hashtable<String, Integer> table = new Hashtable<>();
ConcurrentHashMap<String, Integer> concurrentMap =
new ConcurrentHashMap<>();
// 10个线程并发写入10000个数据
long tableTime = testConcurrentWrite(table); // 约2000ms
long concurrentMapTime = testConcurrentWrite(concurrentMap); // 约400ms
System.out.printf("Hashtable耗时:%d ms%n", tableTime);
System.out.printf("ConcurrentHashMap耗时:%d ms%n", concurrentMapTime);
}
- 关于分段锁的概念,需要更形象的解释:
想象一个大型图书馆:
- Hashtable的方式:整个图书馆只有一个工作人员,一次只能服务一个读者
- synchronized Map的方式:多了个管理员,但还是要排队
- ConcurrentHashMap (JDK7)的方式:
- 图书馆分成16个区域(默认16个Segment)
- 每个区域都有专门的工作人员
- 张三在A区借书不影响李四在B区借书
- 真正实现了并行处理
- 关于为什么JDK8要放弃分段锁:
JDK8放弃分段锁的原因:
-
粒度问题:
- 分段锁的粒度是基于Segment的
- 即使只需要锁定一个节点,也要锁定整个Segment
- 这在某些场景下还是太重了
-
资源占用:
- 每个Segment都要维护自己的哈希表
- 内存占用更大
- 代码复杂度也更高
-
扩展性:
- 分段数量一旦初始化就不能改变
- 不够灵活
所以JDK8转而采用CAS+synchronized的方案,实现了更细粒度的锁控制。这就像图书馆升级了管理系统:
- 不再是分区管理
- 而是精确到每个书架甚至每本书
- 需要时才锁定,不需要时立即释放
- 大大提高了并发效率
这种演进过程反映了Java并发编程的一个重要思想:从粗粒度锁到细粒度锁,从悲观锁到乐观锁的转变。
在接下来的章节中,我们将深入探讨ConcurrentHashMap是如何实现这种高效的并发控制的。
2. 核心原理:并发编程的智慧结晶
在并发编程中,如何在保证线程安全的同时又能提供良好的性能,一直是一个重要的课题。ConcurrentHashMap的设计演进历程,完美诠释了这个问题的解决思路。
2.1 JDK7的分段锁机制:分而治之的艺术
分段锁,顾名思义,就是将数据分成若干个段,每个段配备一把锁。这种设计的妙处在于:当需要修改数据时,只需要锁定相应的段,而不是整个数据结构。
2.1.1 基本结构解析
在JDK7的实现中,ConcurrentHashMap由多个Segment组成,每个Segment都是一个小型的HashMap。这种设计有几个关键点需要理解:
-
为什么使用Segment?
- Segment继承自ReentrantLock,这意味着每个Segment都是一个可重入锁
- 通过锁分段的方式,实现了更好的并发性能
- 不同Segment之间可以并发访问,相当于将一个大锁拆分成多个小锁
-
Segment的数量为什么是2的幂?
- 这与定位Segment的算法有关
- 通过位运算可以更快地定位Segment
- 默认值16是经过优化后的结果,在大多数场景下都能提供不错的性能
-
数据结构的层次
- 第一层:Segment数组
- 第二层:每个Segment内部的HashEntry数组
- 第三层:HashEntry链表
// 核心结构示意
final Segment<K,V>[] segments;
2.1.2 工作原理深度解析
当我们往ConcurrentHashMap中写入数据时,整个过程是这样的:
-
确定Segment
- 首先对key进行一次hash,确定位于哪个Segment
- 这个过程不需要加锁,因为Segment数组是不可变的
-
锁定Segment
- 在确定Segment后,对该Segment进行加锁
- 这时其他线程仍然可以访问其他Segment
- 体现了分段锁的优势
-
写入数据
- 在获得锁之后,写入过程与HashMap类似
- 但是要注意维护一些并发相关的信息
-
释放锁
- 写入完成后,释放该Segment的锁
- 其他线程可以继续访问该Segment
这个过程就像一个大型图书馆的管理系统:
- 整个图书馆分为多个阅览室(Segment)
- 每个阅览室都有自己的管理员(锁)
- 一个阅览室在整理书架时(写操作),其他阅览室仍然可以正常使用
- 既保证了秩序,又提高了效率
2.2 JDK8的CAS+Synchronized优化:精确制导的锁机制
JDK8中的改进可以说是一次革命性的变化。它抛弃了分段锁的设计,转而采用了更加精细的锁控制机制。这种改变的背后,是并发编程理念的重大转变。
2.2.1 为什么要改变?
-
分段锁的局限性
- Segment的数量在创建时就已确定,无法动态调整
- 即使只需要锁住一个桶,也要锁住整个Segment
- 空间开销较大,每个Segment都要维护自己的哈希表
-
新设计的优势
- 锁的粒度更细,只锁住链表的头节点
- 空间效率更高,不需要额外的Segment对象
- 实现更简单,代码可维护性更好
2.2.2 CAS+Synchronized的协同机制
JDK8的设计堪称精妙,它巧妙地结合了CAS和synchronized两种机制。这就像一个现代化的图书管理系统,既有自助借还(CAS),又有管理员协助(synchronized)。
- CAS操作的运用
CAS(Compare And Swap)是一种乐观锁机制,它的核心思想是:在修改数据时,先比较当前值是否符合预期,只有符合预期才进行修改。这就像在取款机取钱:
- 先检查账户余额是否足够(Compare)
- 如果足够,才进行扣款(Swap)
- 如果余额已经被其他操作改变,就需要重试
// CAS操作示例
do {
oldVal = get(key);
newVal = oldVal + delta;
} while (!compareAndSet(key, oldVal, newVal));
- synchronized的优化
synchronized在JDK8中的应用变得更加精细:
- 只锁定当前链表或红黑树的首节点
- 利用了JDK8中synchronized的多项优化
- 偏向锁
- 轻量级锁
- 适应性自旋
这种设计就像图书馆的智能化升级:
- 不再是锁定整个书架区域
- 而是只锁定需要操作的具体书架
- 其他读者仍可以访问其他书架
- 实际工作流程
让我们看一个具体的put操作是如何工作的:
// 简化的put操作流程
public V put(K key, V value) {
// 1. 计算hash值
int hash = spread(key.hashCode());
// 2. 定位到具体桶
int bin

最低0.47元/天 解锁文章
4882

被折叠的 条评论
为什么被折叠?



