【Java每日一练-Day17】并发容器全解析!ConcurrentHashMap、CopyOnWrite核心原理
哈喽,刷题小伙伴们!Day16咱们吃透了线程池的核心原理、七大参数与实战选型,掌握了并发编程中的“效率神器”。今天咱们聚焦并发编程的另一核心考点——并发容器!在多线程场景下,ArrayList、HashMap这些普通容器是线程不安全的,直接使用会导致数据错乱、程序崩溃等问题。Java提供了专门的并发容器(如ConcurrentHashMap、CopyOnWriteArrayList等),它们通过特殊的设计保证线程安全,同时兼顾性能。今天咱们从“普通容器的线程安全问题”入手,逐一拆解核心并发容器的底层实现、核心优势与适用场景,彻底搞懂不同场景下该选哪种容器!
今日核心目标
-
理解普通容器(ArrayList、HashMap)的线程安全问题;
-
掌握并发容器的核心设计思想(对比同步容器的弊端);
-
吃透ConcurrentHashMap的底层原理(JDK1.8版本重点);
-
搞懂CopyOnWriteArrayList/CopyOnWriteArraySet的实现逻辑与适用场景;
-
明确各并发容器的选型原则(面试高频)。
一、前置认知:普通容器的线程安全问题(避坑第一步)
在单线程场景下,ArrayList、HashMap是常用的高效容器,但在多线程场景下,它们存在严重的线程安全问题,核心原因是“操作非原子性+无锁保护”。咱们通过两个典型案例拆解:
1. 案例1:ArrayList的线程安全问题(并发添加元素)
import java.util.ArrayList;
import java.util.List;
public class ArrayListThreadSafeDemo {
public static void main(String[] args) {
List<Integer> list = new ArrayList<>();
// 10个线程并发添加元素
for (int i = 0; i < 10; i++) {
int finalI = i;
new Thread(() -> {
for (int j = 0; j < 1000; j++) {
list.add(finalI * 1000 + j);
}
}).start();
}
// 等待所有线程执行完成
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 预期大小:10*1000=10000
System.out.println("ArrayList实际大小:" + list.size()); // 结果小于10000,且可能抛出异常
}
问题分析:
-
数据丢失:ArrayList的add()方法包含“扩容判断+元素赋值”两步操作(非原子性),多线程并发时可能出现“两个线程同时赋值到同一位置”,导致部分元素丢失;
-
数组越界异常:当线程A执行扩容时,线程B同时执行add(),可能访问到扩容前的数组末尾,触发IndexOutOfBoundsException。
2. 案例2:HashMap的线程安全问题(并发put元素)
import java.util.HashMap;
import java.util.Map;
public class HashMapThreadSafeDemo {
public static void main(String[] args) {
Map<Integer, Integer> map = new HashMap<>();
// 10个线程并发put元素
for (int i = 0; i< 10; i++) {
int finalI = i;
new Thread(() -> {
for (int j = 0; j < 1000; j++) {
map.put(finalI * 1000 + j, j);
}
}).start();
}
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("HashMap实际大小:" + map.size()); // 结果小于10000,数据错乱
}
问题分析:
-
数据丢失:put()方法中,多个线程可能同时计算出相同的哈希值和索引位置,后插入的元素会覆盖先插入的元素;
-
死循环风险(JDK1.7及之前):HashMap扩容时会重新哈希并迁移元素,多线程并发扩容可能导致链表形成环形结构,后续get()操作会进入死循环。
3. 临时解决方案:同步容器(Vector、Hashtable)
为了解决普通容器的线程安全问题,Java早期提供了同步容器(Vector、Hashtable),它们的核心是“给所有方法加synchronized锁”。但同步容器存在严重的性能问题——多线程并发访问时,所有操作都会竞争同一把锁,导致线程阻塞严重,吞吐量极低。
核心结论:同步容器(Vector、Hashtable)是“伪并发容器”,性能极差,阿里巴巴开发手册明确禁止使用!推荐使用Java并发包(java.util.concurrent)下的并发容器。
二、并发容器的核心设计思想(为什么比同步容器快?)
Java并发容器(JDK1.5及之后引入)的核心优势是“锁粒度细化+无锁设计”,对比同步容器的“全局锁”,并发容器通过以下设计提升性能:
-
锁粒度细化:不使用全局锁,而是将容器分割成多个“小片段”,每个片段单独加锁(如ConcurrentHashMap的分段锁/Node锁),多线程访问不同片段时无需竞争锁,可并行执行;
-
无锁设计:部分操作通过CAS(compare-and-swap)实现原子性,无需加锁(如CopyOnWrite容器的读操作、ConcurrentHashMap的部分写操作),避免线程阻塞;
-
读写分离:区分读操作和写操作,读操作无需加锁(或加轻量级锁),写操作加锁,兼顾读多写少场景的性能(如CopyOnWrite容器);
-
故障安全(Fail-Safe):并发容器的迭代器是故障安全的,迭代时不会抛出ConcurrentModificationException(普通容器和同步容器的迭代器是故障快速的,迭代时修改容器会抛异常)。
三、核心考点1:ConcurrentHashMap(最常用的并发Map)
ConcurrentHashMap是HashMap的线程安全版本,也是并发场景下的首选Map容器。其底层实现在JDK1.7和JDK1.8有较大差异,面试重点考察JDK1.8版本的实现。
1. JDK1.7 vs JDK1.8 核心差异
|
对比维度 |
JDK1.7版本 |
JDK1.8版本 |
|
核心结构 |
Segment数组 + HashEntry数组(分段存储) |
Node数组 + 链表/红黑树(与HashMap结构类似) |
|
锁机制 |
分段锁(Segment锁),每个Segment一把锁 |
Node锁(synchronized)+ CAS,锁粒度更细 |
|
并发度 |
由Segment数量决定(默认16),最多16个线程并行 |
理论上无上限(每个Node都可作为锁),并发度更高 |
|
扩容机制 |
每个Segment独立扩容,复杂且效率低 |
全局扩容(类似HashMap),通过CAS和锁配合实现高效扩容 |
2. JDK1.8 ConcurrentHashMap 核心实现
JDK1.8 ConcurrentHashMap摒弃了分段锁,采用“Node数组 + 链表/红黑树 + synchronized锁 + CAS”的核心设计,兼顾线程安全和性能。
(1)核心结构
-
Node数组:存储哈希桶的数组,每个索引位置对应一个链表或红黑树;
-
Node节点:存储键值对的基本节点,value和next字段用volatile修饰,保证可见性;
-
TreeNode节点:当链表长度超过8时,链表转为红黑树(提升查询效率,从O(n)到O(logn));
-
ForwardingNode节点:扩容时使用的占位节点,标记当前桶已被迁移。
(2)核心锁机制(synchronized + CAS)
JDK1.8通过“锁粒度最小化”提升并发度,核心逻辑如下:
-
写操作(put/remove):
-
计算键的哈希值,定位到Node数组的索引位置;
-
若当前桶为空(Node为null),通过CAS操作直接插入节点(无需加锁);
-
若当前桶不为空,对该桶的头节点加synchronized锁,保证同一时间只有一个线程操作该桶(其他桶可并行操作);
-
操作完成后,释放锁,通过volatile保证其他线程可见。
-
-
读操作(get):完全无锁!通过volatile修饰的Node节点保证数据可见性,无需加锁即可安全读取。
(3)核心优势(对比Hashtable和HashMap)
-
线程安全:通过synchronized和CAS保证多线程环境下的数据一致性;
-
高性能:读操作无锁,写操作仅锁单个桶,并发度极高;
-
无死循环风险:JDK1.8的红黑树结构避免了JDK1.7 HashMap的死循环问题;
-
支持高并发:理论上支持无限个线程并行操作不同桶,吞吐量远高于Hashtable。
四、核心考点2:CopyOnWrite容器(CopyOnWriteArrayList/CopyOnWriteArraySet)
CopyOnWrite(简称COW)容器的核心设计思想是“写时复制”:当执行写操作(add/remove/modify)时,先复制一份原容器的副本,在副本上执行写操作,操作完成后将原容器的引用指向副本;读操作直接访问原容器,无需加锁。CopyOnWrite容器适合“读多写少”的场景(如配置缓存、日志收集)。
1. CopyOnWriteArrayList 核心实现
CopyOnWriteArrayList是ArrayList的线程安全版本,底层通过“volatile数组 + 重入锁(ReentrantLock)”实现。
(1)核心结构
-
volatile Object[] array:存储元素的核心数组,用volatile修饰保证读操作的可见性;
-
ReentrantLock lock:写操作时加锁,保证同一时间只有一个线程执行写操作(避免多个线程同时复制数组)。
(2)核心方法解析
① 写操作(add()方法)
public boolean add(E e) {
final ReentrantLock lock = this.lock;
lock.lock(); // 写操作加锁,保证原子性
try {
Object[] elements = getArray(); // 获取原数组
int len = elements.length;
// 复制原数组到新数组(新数组长度+1)
Object[] newElements = Arrays.copyOf(elements, len + 1);
newElements[len] = e; // 在新数组中添加元素
setArray(newElements); // 将原数组引用指向新数组
return true;
} finally {
lock.unlock(); // 释放锁
}
}
② 读操作(get()方法)
public E get(int index) {
// 读操作无锁,直接访问原数组
return get(getArray(), index);
}
2. CopyOnWriteArraySet 核心实现
CopyOnWriteArraySet的底层是基于CopyOnWriteArrayList实现的(通过组合方式,而非继承),核心是“利用CopyOnWriteArrayList的addIfAbsent()方法保证元素唯一性”——添加元素时先判断元素是否存在,不存在则添加,存在则忽略。
核心注意:CopyOnWriteArraySet的去重逻辑是通过遍历数组实现的,性能较差,适合元素数量少、写操作极少的场景。
3. CopyOnWrite容器的核心优缺点
(1)优点
-
读操作无锁,性能极高(适合读多写少场景);
-
线程安全,无需担心并发修改问题;
-
迭代器故障安全,迭代时修改容器不会抛出ConcurrentModificationException。
(2)缺点
-
写操作性能差:每次写操作都要复制整个数组,数组越大,复制开销越大;
-
数据一致性问题:读操作可能读取到旧数据(写操作执行时,读操作访问的是原数组,新数据写入副本后才会切换引用);
-
内存占用高:写操作时会同时存在原数组和副本数组,内存消耗翻倍。
五、其他常用并发容器(简单了解)
-
ConcurrentLinkedQueue:
-
基于链表的无界并发队列,底层通过CAS实现无锁操作;
-
优点:并发性能极高,适合高并发场景下的任务队列;
-
适用场景:线程池的任务队列、生产者-消费者模型。
-
-
LinkedBlockingQueue:
-
基于链表的有界/无界并发队列,底层通过ReentrantLock实现线程安全;
-
优点:支持阻塞的put()和take()方法(队列满时put阻塞,队列空时take阻塞);
-
适用场景:线程池的任务队列(如FixedThreadPool默认使用)、需要阻塞等待的场景。
-
-
ConcurrentSkipListMap:
-
基于跳表的有序并发Map,支持按键排序;
-
优点:并发性能优于TreeMap(TreeMap是同步容器,全局锁);
-
适用场景:需要有序性的并发场景(如按时间排序的日志存储)。
-
六、核心选型原则(面试必背)
不同并发容器的设计理念不同,适用场景也不同,记住以下选型原则,面试和实战都能用到:
-
并发Map场景:
-
优先选ConcurrentHashMap:大部分并发场景(读多写少、读写均衡)都适用,性能最优;
-
需要有序性选ConcurrentSkipListMap:如按键排序、需要获取子集的场景。
-
-
并发List场景:
-
读多写少选CopyOnWriteArrayList:如配置缓存、日志列表(读操作极多,写操作极少);
-
读写均衡/写多读少选ConcurrentLinkedQueue(若允许队列结构):或手动通过Collections.synchronizedList()包装ArrayList(性能较差,万不得已才用)。
-
-
并发Set场景:
-
读多写少、元素少选CopyOnWriteArraySet;
-
其他场景:可通过ConcurrentHashMap实现(将值设为固定对象,利用键的唯一性去重)。
-
-
并发队列场景:
-
高并发无界队列选ConcurrentLinkedQueue;
-
需要阻塞等待选LinkedBlockingQueue(有界);
-
需要按优先级排序选PriorityBlockingQueue。
-
七、今日打卡
评论区留下你的答案:以下场景该选哪种并发容器?为什么?✅
场景:电商系统的商品配置缓存(商品配置一旦发布,极少修改,但会被大量用户查询);
可选容器:ConcurrentHashMap、CopyOnWriteArrayList、HashMap(Collections.synchronizedMap包装)、Hashtable。
提示:结合场景的“读多写少”特性和各容器的核心优势分析~
文末预告
Day18预告:学完了并发容器,咱们进入并发编程的“通信神器”——线程间通信!线程间如何协作?wait()/notify()和Condition的区别是什么?CountDownLatch、CyclicBarrier、Semaphore这些工具类的核心作用是什么?明天咱们拆解线程间通信的核心原理与实战用法!
更多推荐




所有评论(0)