并发编程——11 并发容器(Map、List、Set)实战及其原理分析
CopyOnWriteArrayList(扩展知识:迭代器的`fail-fast`与`fail-safe`机制)、ConcurrentHashMap、ConcurrentSkipListMap(跳表)、电商场景中并发容器的选择
1 JUC包下的并发容器
-
Java 基础集合(如
ArrayList
、LinkedList
、HashMap
)非线程安全。为了解决线程安全问题,Java 最初提供了同步容器(如Vector
、Hashtable
、SynchronizedList
),但它们通过synchronized
实现全局锁,多线程竞争时会“串行化”执行,并发性能差。为了兼顾线程安全和并发性能,JUC(java.util.concurrent
)包提供了并发容器,通过更精细的锁机制或无锁算法,大幅提升多线程场景下的吞吐量; -
根据集合的类别(
List
、Set
、Map
、Queue
),并发容器可分为以下几类:集合类别 并发容器代表 Map
ConcurrentHashMap
、ConcurrentSkipListMap
List
CopyOnWriteArrayList
Set
CopyOnWriteArraySet
、ConcurrentSkipListSet
Queue
BlockingQueue
(含多种实现)、ConcurrentLinkedQueue
、BlockingDeque
等 -
以下是几种典型并发容器的设计逻辑:
-
CopyOnWriteArrayList
(对应非并发容器ArrayList
)-
目标:替代
Vector
、SynchronizedList
,解决读多写少场景下的并发性能问题; -
原理:利用写时复制策略——读操作不加锁,写操作时先复制一份新的数组,在新数组上修改,再将新数组赋值给原引用;同时通过
volatile
保证数组的可见性,写操作本身加锁保证原子性;
-
-
CopyOnWriteArraySet
(对应非并发容器HashSet
)-
目标:替代
SynchronizedSet
,实现线程安全的集合(元素不重复); -
原理:基于
CopyOnWriteArrayList
实现,核心是在add
操作时调用CopyOnWriteArrayList
的addIfAbsent
方法——遍历数组,若元素已存在则直接返回,否则添加到数组尾部,以此保证元素不重复;
-
-
ConcurrentHashMap
(对应非并发容器HashMap
)-
目标:替代
Hashtable
、SynchronizedMap
,支持高效的复合操作(如:先检查后操作); -
原理:
- JDK6 采用分段锁(Segment):将哈希表分成多个 Segment,每个 Segment 是一个小的哈希表,锁只针对单个 Segment,大幅降低锁竞争;
- JDK8 采用CAS 无锁算法 + synchronized 轻量级锁:摒弃分段锁,通过 CAS 保证节点操作的原子性,结合 synchronized 对链表/红黑树的节点加锁,进一步提升并发效率;
-
-
ConcurrentSkipListMap
(对应非并发容器TreeMap
)-
目标:替代
SynchronizedSortedMap
(即加锁的TreeMap
),实现“线程安全的有序 Map”。 -
原理:基于 跳表(Skip List) 实现——跳表是一种可替代平衡树的数据结构,通过多层索引加速查找,默认按 Key 升序排列,并发时通过原子操作保证线程安全;
-
-
并发队列(
Queue
类)。JUC 提供了多种并发队列,典型的如BlockingQueue
系列:-
ArrayBlockingQueue
:基于数组的有界阻塞队列,创建时需指定容量; -
LinkedBlockingQueue
:基于链表的阻塞队列,可指定容量(默认无界); -
SynchronousQueue
:同步队列,不存储元素,生产者和消费者直接交互; -
PriorityBlockingQueue
:支持优先级的无界阻塞队列; -
DelayQueue
:支持“延迟获取”的队列,元素需实现延迟接口; -
还有
ConcurrentLinkedQueue
(非阻塞、基于链表的并发队列)、BlockingDeque
(阻塞双端队列,如LinkedBlockingDeque
)等,满足不同场景的队列操作需求。
-
-
2 CopyOnWriteArrayList
2.1 概述
-
CopyOnWriteArrayList
是 Java 中线程安全的 List 实现,属于 JUC 并发容器。它是可变数组结构,支持多线程下的并发读、写操作,核心是通过写时复制策略保证线程安全和并发一致性; -
它的核心逻辑是:读操作不加锁,写操作时复制一份新数组,在新数组上完成修改后,再将原数组引用指向新数组。同时通过
volatile
关键字保证数组引用的可见性,写操作过程中加锁保证原子性; -
工作流程:
- 写入未完成时的读取:原有数组(元素
[1,2,3,4]
)是当前有效数据,读操作直接访问原有数组;此时若有写操作(如添加元素5
),会复制一份新数组,在新数组上完成写入(新数组变为[1,2,3,4,5]
); - 写入完成后的读取:写操作完成后,原数组引用被切换为新数组(原有数组被丢弃回收),后续读操作都访问写入完成的新数组(元素
[1,2,3,4,5]
);
- 写入未完成时的读取:原有数组(元素
2.2 应用场景
-
场景 1:读多写少的场景
-
特点:系统中对集合的读取操作频率远高于写入操作;
-
如:高频读取的缓存列表(如系统配置项列表、字典表数据等);
-
解释:
CopyOnWriteArrayList
读操作不加锁,能充分利用 CPU 资源并行处理读请求;写操作虽有复制数组的开销,但因写入频率低,整体性能优势明显,同时避免了锁竞争带来的性能损耗;
-
-
场景 2:不需要实时更新的数据
-
特点:业务对数据的实时一致性要求不高,允许读取到“稍旧”的数据;
-
如:日志系统(日志先暂存于
CopyOnWriteArrayList
,批量写入文件而非实时写入)、统计类数据(如非实时的访问量统计列表); -
解释:
CopyOnWriteArrayList
写操作是复制新数组后切换引用,读操作可能在写切换完成前访问旧数组,因此不保证实时最新数据。但这种最终一致性恰好适配了无需实时更新的业务场景,同时还能保证读操作的高并发性能。
-
2.3 使用
2.3.1 基本使用
-
CopyOnWriteArrayList
的创建和操作流程与ArrayList
高度一致,我们可以像使用ArrayList
一样快速上手:-
首先通过
new CopyOnWriteArrayList()
创建实例; -
然后调用
add
、set
、get
、remove
等方法完成增、改、查、删操作; -
还可以通过
clear
、isEmpty
、contains
、size
等方法进行集合的状态判断和管理;
// 创建一个 CopyOnWriteArrayList 对象 CopyOnWriteArrayList phaser = new CopyOnWriteArrayList(); // 新增 copyOnWriteArrayList.add(1); // 修改指定下标的元素值 copyOnWriteArrayList.set(0, 2); // 查询 copyOnWriteArrayList.get(0); // 删除 copyOnWriteArrayList.remove(0); // 清空 copyOnWriteArrayList.clear(); // 是否为空 copyOnWriteArrayList.isEmpty(); // 是否包含 copyOnWriteArrayList.contains(1); // 获取元素个数 copyOnWriteArrayList.size();
-
方法 | 功能描述 |
---|---|
add(元素) |
向集合中新增一个元素 |
set(下标, 新元素) |
修改指定下标的元素值 |
get(下标) |
获取指定下标的元素值 |
remove(下标) |
删除指定下标的元素 |
clear() |
清空集合中所有元素 |
isEmpty() |
判断集合是否为空(返回 boolean ) |
contains(元素) |
判断集合是否包含某个元素(返回 boolean ) |
size() |
获取集合中元素的个数 |
2.3.2 IP 黑名单判定
-
IP 黑名单的业务特点是:读多写少(大量请求需要读黑名单来判断是否需要拦截,但黑名单更新(写操作)频率低),且对黑名单更新的实时性要求不高(新 IP 加入黑名单后,后续请求拦截即可,无需要求所有请求立即感知)。这完全契合
CopyOnWriteArrayList
“读多写少、最终一致性”的适用场景; -
代码示例:
public class CopyOnWriteArrayListDemo { private static CopyOnWriteArrayList<String> copyOnWriteArrayList = new CopyOnWriteArrayList<>(); // 模拟初始化的黑名单数据 static { // 向 CopyOnWriteArrayList 中添加初始的黑名单 IP copyOnWriteArrayList.add("ipAddr0"); copyOnWriteArrayList.add("ipAddr1"); copyOnWriteArrayList.add("ipAddr2"); } public static void main(String[] args) throws InterruptedException { // 启动多个线程模拟外部请求 Runnable task = new Runnable() { public void run() { // 模拟接入用时 try { Thread.sleep(new Random().nextInt(5000)); } catch (Exception e) {} // 每个线程随机生成一个 IP String currentIP = "ipAddr" + new Random().nextInt(6); // 判断是否命中黑名单 if (copyOnWriteArrayList.contains(currentIP)) { // 若命中,打印 “拒绝接入” System.out.println(Thread.currentThread().getName() + " IP " + currentIP + "命中黑名单,拒绝接入处理"); return; } // 若未命中,打印 “接入处理” System.out.println(Thread.currentThread().getName() + " IP " + currentIP + "接入处理..."); } }; new Thread(task, "请求1").start(); new Thread(task, "请求2").start(); new Thread(task, "请求3").start(); // 启动 “IP 黑名单更新” 线程,休眠一段时间后,向 CopyOnWriteArrayList 中添加新的黑名单 IP new Thread(new Runnable() { public void run() { // 模拟用时 try { Thread.sleep(new Random().nextInt(2000)); } catch (Exception e) {} String newBlackIP = "ipAddr3"; // 写操作时会复制新数组,完成添加后切换引用 // 写操作过程中加锁保证原子性,但不阻塞读操作(读操作仍可访问旧数组),保证了读并发不被写阻塞 copyOnWriteArrayList.add(newBlackIP); System.out.println(Thread.currentThread().getName() + " 添加了新的非法IP " + newBlackIP); } }, "IP黑名单更新").start(); Thread.sleep(1000000); } }
2.4 原理
-
CopyOnWriteArrayList
针对读多写少的并发场景设计,核心逻辑是:读操作无锁并发执行,写操作时复制一份新数组,在新数组上完成修改后,再将原数组引用指向新数组; -
执行流程:
-
读操作则全程无锁,直接访问当前数组(写操作切换引用前,读的是旧数组;切换后,读的是新数组);
-
写操作的执行分为四步,同时结合
volatile
和锁保证线程安全:- 加锁:写操作前加锁,保证同一时间只有一个线程执行写操作;
- 复制新数组:从原数组(如
[1,2,3,4]
)复制出一份新数组; - 新数组上执行写操作:在新数组上完成修改(如添加元素
5
,新数组变为[1,2,3,4,5]
); - 解锁 + 切换引用:解锁后,将原数组的引用(
volatile
修饰)指向新数组;volatile
保证其他读线程能立即感知到数组引用的变化;
-
-
整个过程的线程安全保障由锁 + 数组拷贝 + volatile来保障:
-
锁:写操作加锁,保证写操作的原子性(同一时间只有一个线程能写);
-
数组拷贝:写操作在新拷贝的数组上执行,避免了多线程对原数组的直接修改冲突;
-
volatile:底层数组
array
被volatile
修饰(private transient volatile Object[] array;
),保证数组引用的可见性——写操作切换数组引用后,所有读线程能立即看到新数组;
-
-
优点:
-
读性能极高:读操作无锁,多线程可并发执行,尤其适合读多写少的场景;
-
遍历安全:因读写分离(读旧数组、写新数组),遍历过程中不会抛出
ConcurrentModificationException
(与ArrayList
遍历修改抛异常的行为不同);
-
-
缺点:
-
内存开销大:每次写操作都要复制数组,若数组元素多,会消耗大量内存,甚至引发频繁 GC;
-
实时性不足:读操作可能访问旧数组,无法保证读取到最新数据,仅满足最终一致性。
-
2.5 扩展知识:迭代器的fail-fast
与fail-safe
机制
-
在 Java 中,迭代器(Iterator)在迭代的过程中,如果底层的集合被修改(添加或删除元素),不同的迭代器对此的表现行为是不一样的,可分为两类:Fail-Fast(快速失败)和 Fail-Safe(安全失败);
-
fail-fast
(快速失败)机制-
当多个线程(或单线程在遍历中)修改集合结构时,迭代器会立即抛出
ConcurrentModificationException
异常,以此快速失败来提示并发修改问题; -
典型案例:
ArrayList
、HashMap
等java.util
包下的集合,其迭代器默认采用该机制。例如,线程 A 遍历(迭代)ArrayList
时,若其他线程(或自身后续代码)修改了集合的元素数量,就会抛出ConcurrentModificationException
; -
解决方案:
-
方案一:加锁,即在遍历过程中所有涉及到改变
modCount
的地方全部加上synchronized
或者直接使用Collection#synchronizedList
,但会阻塞遍历,性能差,不推荐;modCount
记录集合结构被修改的次数(如添加、删除元素等操作会触发modCount
递增)。当迭代器遍历集合时,会比对自身记录的expectedModCount
和集合的modCount
:- 若两者一致,说明遍历过程中集合结构未被修改;
- 若不一致,说明有其他线程(或当前线程后续代码)修改了集合结构,此时迭代器会立即抛出
ConcurrentModificationException
,即fail-fast
异常;
-
方案二:用
CopyOnWriteArrayList
替换ArrayList
(即采用fail-safe
机制),推荐;
-
-
-
fail-safe
(安全失败)机制-
集合结构修改时,会在复制的新集合上进行,因此迭代器遍历原集合时不会抛出
ConcurrentModificationException
; -
典型案例:
CopyOnWriteArrayList
、ConcurrentHashMap
等java.util.concurrent
包下的并发集合; -
缺点:
- 数据实时性不足:遍历中若集合被修改,可能读取到旧数据(仅保证最终一致性);
- 内存开销大:修改时复制集合,会创建额外对象,可能引发频繁 GC;
-
-
两者核心区别
维度 fail-fast(快速失败) fail-safe(安全失败) 异常抛出 遍历中修改集合会抛 ConcurrentModificationException
不会抛该异常 集合实现 基于 java.util
包(如ArrayList
)基于 java.util.concurrent
包(如CopyOnWriteArrayList
)数据一致性 保证实时一致性(但并发修改会抛异常) 仅保证最终一致性(可能读取旧数据) 内存开销 低(无额外复制) 高(修改时复制集合)
3 ConcurrentHashMap
3.1 概述
-
ConcurrentHashMap
是 Java 中线程安全的哈希表实现,支持高并发下的读写操作,是HashMap
(非线程安全)和Hashtable
(全局锁、并发性能差)的替代方案,专为多线程场景优化; -
ConcurrentHashMap
的实现机制在 JDK1.8 前后有显著变化:- JDK1.8 之前:采用分段锁(Segment) 机制。将哈希表分割为多个Segment 段,每个 Segment 是一个小的哈希表,锁仅作用于单个 Segment。这样大幅降低了锁竞争,提升了并发度;
- JDK1.8 及之后:摒弃分段锁,采用自旋 + CAS + synchronized 关键字 实现同步。具体来说,对哈希表的链表/红黑树节点加
synchronized
锁,结合 CAS 操作保证节点修改的原子性,进一步提升并发效率;
-
JDK1.8 为何弃用分段锁?官方给出了三点核心原因:
- 节省内存空间:分段锁需要为每个 Segment 维护额外的锁结构,JDK1.8 的新实现减少了这部分内存开销;
- 避免锁竞争导致的性能瓶颈:分段锁的并发度由 Segment 数量决定,若实际并发度达不到设置的粒度,会导致锁定一段后整个段无法更新,反而引发长时间等待。新实现的节点级锁更精细,竞争概率更低;
- 提升 GC 效率:分段锁的结构复杂,GC 回收时开销更大;新实现的结构更简洁,有助于 GC 高效回收内存。
3.2 应用场景
-
场景 1:共享数据的线程安全。在多线程编程中,若多个线程需要对同一份哈希表数据进行并发读写操作,
ConcurrentHashMap
可保证线程安全。它避免了HashMap
非线程安全导致的脏数据问题,也解决了Hashtable
全局锁带来的并发性能瓶颈,让多线程共享数据时既能保证一致性,又能维持高并发效率; -
场景 2:缓存实现。由于
ConcurrentHashMap
兼具高并发性能和线程安全,非常适合作为缓存的数据结构。在多线程环境下,大量请求可以并发读写缓存(比如查询用户信息缓存、配置项缓存等),既能保证缓存数据的一致性,又能通过高并发特性提升程序整体的响应速度。
3.3 使用
3.3.1 基本用法
-
ConcurrentHashMap
的基础 API 设计和HashMap
非常相似:// 创建一个 ConcurrentHashMap 对象 ConcurrentHashMap<Object, Object> concurrentHashMap = new ConcurrentHashMap<>(); // 添加键值对 concurrentHashMap.put("key", "value"); // 批量添加键值对 concurrentHashMap.putAll(new HashMap()); // 根据键取值 concurrentHashMap.get("key"); // 判定是否为空 concurrentHashMap.isEmpty(); // 获取键值对数量 concurrentHashMap.size(); // 获取所有键的集合 concurrentHashMap.keys(); // 获取所有值的集合 concurrentHashMap.values(); // 清空所有键值对 concurrentHashMap.clear();
-
ConcurrentHashMap
还提供了一些针对并发场景优化的扩展方法,满足线程安全的复合操作需求:方法 功能描述 putIfAbsent(K key, V value)
仅当 key 对应的 value 不存在时,才放入键值对;若已存在,返回原有 value,不做修改 remove(Object key, Object value)
仅当 key 对应的值是 value
时,才移除该键值对;保证“删除操作”的条件原子性replace(K key, V oldValue, V newValue)
仅当 key 当前值是 oldValue
时,才替换为newValue
;保证“替换操作”的条件原子性computeIfAbsent(key, Function)
若 key 不存在,用 Function
计算的值作为 value 放入;若存在,直接返回原有 valuemerge(key, value, BiFunction)
若 key 不存在,直接放入 value
;若存在,用BiFunction
处理原有 value 和新 value,将结果作为新 value
3.3.2 *统计文件中英文字母出现的总次数
-
需求:
-
生成测试数据:26个英文字母每个循环200次(共5200个单词),乱序后存入26个文件,每个文件200个单词;
-
统计需求:启动26个线程分别读取26个文件,统计每个字母的出现次数,最终每个字母应恰好出现200次;
-
-
生成测试文件:
/** * 生成测试文件 * @throws IOException */ public void produceData() throws IOException { // 定义26个字母的字符串 String data="abcdefghijklmnopqrstuvwxyz"; List<String> list=new ArrayList<>(); // 循环遍历26个字母,每个字母循环200次,最后将5200个字母放入集合 for (int i = 0; i < data.length(); i++) { for (int j = 0; j < 200; j++) { list.add(String.valueOf(data.charAt(i))); } } // 打乱集合顺序,保证数据随机 Collections.shuffle(list); // 遍历 26 次,每次取 200 个元素,以换行符 \n 拼接后写入一个文件(共 26 个文件,如 1.txt、2.txt…26.txt) for (int i = 0; i < 26; i++) { try(FileWriter fw=new FileWriter((i+1)+".txt")){ fw.write(list.subList(i*200,(i+1)*200).stream().collect(Collectors.joining("\n"))); } } }
-
读取文件:
/** * 定义读文件的方法 */ private static void read(List list, int i) { try ( // 创建输入缓冲字符流 BufferedReader bf = new BufferedReader(new FileReader((i + 1) + ".txt"))) { String data; // 按行读取单个文件的内容,判断是否为空,不为空则存入线程本地的 List while ((data = bf.readLine()) != null) { list.add(data); } } catch (FileNotFoundException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); } }
-
生成线程:操作每个文件对应的
list
,存放到线程共享的map
:/** * 定义26个线程读26个文件并将结果放入map。map由函数式接口作为参数提供,放入map由Consumer函数式接口处理 * @param supplier 提供者:提供map集合存放单词计数 * @param consumer 消费者:对list(第二个参数)进行计数并存入map(第一个参数)中 */ private static <T> void deal(Supplier<Map<String, T>> supplier, BiConsumer<Map<String, T>, List<String>> consumer) { //获得map集合,用于存放单词计数 Map<String, T> map = supplier.get(); // 利用闭锁保证26个线程都执行完任务,即等待所有线程执行完毕后输出结果 CountDownLatch count = new CountDownLatch(26); // 循环创建26个线程,每个线程读取一个文件的内容,再对内容进行计数 for (int i = 0; i < 26; i++) { int j = i; new Thread(() -> { List<String> list = new ArrayList(); //读取文件 read(list, j); consumer.accept(map, list); count.countDown(); }).start(); } try { count.await(); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(map); }
Consumer
是 Java 8 引入的函数式接口(位于java.util.function
包),代表“消费一个输入参数但不返回结果”的操作;consumer.accept(map, list);
表示:- 调用
BiConsumer
实例consumer
中定义的accept
方法; - 传入两个参数:
map
(ConcurrentHashMap
集合)和list
(文件读取的内容列表); - 执行
consumer
中预先定义的操作(将list
中的数据统计到map
中)
通过这种方式,实现了“数据读取”与“统计逻辑”的解耦,让代码更灵活、可复用。
简言之,
consumer.accept(map, list);
是通过函数式接口,将list
中的数据按预设逻辑“消费”到map
中的操作,是 Java 中“行为参数化”的典型应用; - 调用
-
我们要做的是给
deal()
方法传入两个参数:- 一是提供一个
map
集合,用来存放每个单词的计数结果,key
为单词,value
为计数; - 二是提供一组操作,保证计数的安全性,会传递
map
集合以及单词的List
;
- 一是提供一个
-
正确输出结果应该如下:
{a=200, b=200, c=200, d=200, e=200, f=200, g=200, h=200, i=200, j=200, k=200, l=200, m=200, n=200, o=200, p=200, q=200, r=200, s=200, t=200, u=200, v=200, w=200, x=200, y=200, z=200}
-
测试代码:
// 错误实现(用HashMap):HashMap非线程安全,多线程并发put会导致计数错误 deal(() -> new HashMap<String, Integer>(), (map, words) -> { for (String word : words) { Integer counter = map.get(word); int newValue = counter == null ? 1 : counter + 1; map.put(word, newValue); } }); // 正确实现 1(ConcurrentHashMap + LongAdder) // 用 computeIfAbsent 保证“若 key 不存在则创建 LongAdder 实例”的原子性 // 调用 LongAdder.increment() 进行线程安全的计数(LongAdder 是专门为高并发计数优化的类) deal(() -> new ConcurrentHashMap<String, LongAdder>(), (map, list) -> { // 遍历集合内容 list.forEach(str -> { map.computeIfAbsent(str, (key) -> new LongAdder()).increment(); }); }); // 正确实现 2(ConcurrentHashMap + merge 方法): // merge 方法接收“键、初始值、合并函数”,若键不存在则存入初始值,若存在则用合并函数处理旧值和新值 // 这里 merge(str, 1, Integer::sum) 表示:若 str 不存在则存 1,若存在则将旧值 + 1,保证了计数的原子性 deal(() -> new ConcurrentHashMap<String, Integer>(), (map, list) -> { // 遍历集合内容 list.forEach(str -> { map.merge(str, 1, Integer::sum); }); });
3.4 数据结构
3.4.1 HashTable
的数据结构
-
HashTable 底层采用数组 + 链表的组合结构:
-
数组:作为哈希表的“桶”,每个桶对应一个数组下标(如上图中的
0
、1
、2
…64
); -
链表:当多个键的哈希值映射到同一数组下标时,会以链表的形式存储在该桶下(如下标
1
的桶中,链表长度达到 8 个节点);
-
-
为了保证线程安全,HashTable 对所有公共方法(如
put
、get
、remove
、clear
等)都添加了synchronized
关键字,实现全局锁:-
任何线程执行这些方法时,都会锁定整个 HashTable 实例;
-
同一时间只有一个线程能操作 HashTable,从而避免了多线程下的竞态问题;
-
-
HashTable 的全局锁设计导致了并发性能瓶颈:
-
即使多个线程操作 HashTable 中不同的“桶”(数组下标),也会因为全局锁的存在而串行执行;
-
这使得 HashTable 在高并发场景下的吞吐量极低,无法充分利用多线程的性能优势。
-
-
这也是后来
ConcurrentHashMap
被设计出来的核心原因——ConcurrentHashMap
通过分段锁(JDK1.8 前)或节点级锁 + CAS(JDK1.8 后)实现更细粒度的锁,大幅提升了并发性能。
3.4.2 JDK1.7 中的ConcurrentHashMap
-
JDK 1.7 的
ConcurrentHashMap
采用分段式的三层结构:-
Segments 数组:是最外层的分段锁载体,每个 Segment 本质上是一个独立的小哈希表,且继承自
ReentrantLock
(可重入锁)。默认 Segment 数量为 16,也可通过构造函数修改; -
HashEntry 数组 + 链表:每个 Segment 内部维护一个数组 + 链表的结构(与 HashMap 类似),用于存储具体的键值对;
-
-
与 HashTable 的全局锁不同,ConcurrentHashMap 通过分段锁实现更细粒度的并发控制:
-
当线程执行
put
、get
等操作时,会先通过哈希计算定位到具体的 Segment; -
然后只对该 Segment 加锁(
ReentrantLock
),其他 Segment 仍可被其他线程并发访问;
-
-
这种分段锁设计的核心优势是提升并发度:
-
不同 Segment 之间的操作完全隔离,比如线程 A 操作 Segment 0,线程 B 操作 Segment 1,两者互不干扰,可并行执行;
-
相比 HashTable 的全局锁,分段锁大幅减少了锁竞争,从而在高并发场景下显著提升吞吐量。
-
3.4.3 JDK1.8 中的ConcurrentHashMap
-
JDK 1.8 的 ConcurrentHashMap 摒弃了 JDK 1.7 的Segments 分段设计,采用与 HashMap 类似的三层结构:
-
数组:作为哈希表的“桶”,每个桶对应一个数组下标;
-
链表:当多个键的哈希值映射到同一数组下标时,初始以链表形式存储;
-
红黑树:当链表的节点数量≥树化阈值 8,且数组长度≥最小树化容量 64时,链表会转化为红黑树,以提升查询效率(红黑树的查询时间复杂度为 O(log n),远优于链表的 O(n));
-
-
为保证线程安全,JDK 1.8 采用无锁 + 轻量级锁的组合:
-
CAS(Compare-And-Swap):用于数组节点的初始化、元素插入等操作,通过原子性的“比较-交换”保证操作的原子性,避免了锁的开销;
-
synchronized 关键字:对链表或红黑树的节点加锁(而非全局锁或分段锁),只有操作同一节点的线程会竞争锁,进一步减小了锁粒度,提升并发度;
-
-
相比 JDK 1.7,JDK 1.8 的设计在性能上有两大升级:
- 结构更高效:红黑树的引入解决了链表过长时的查询性能瓶颈;同时去掉 Segments 数组,减少了内存开销;
- 锁粒度更细:由分段锁进化为节点级 synchronized 锁,结合 CAS 无锁操作,在高并发场景下的吞吐量更高,资源利用率更优。
4 ConcurrentSkipListMap
4.1 概述
-
ConcurrentSkipListMap
是 Java 中线程安全的有序映射(Map),底层基于跳表(Skip List) 实现。跳表是一种可替代平衡树(如红黑树)的数据结构,能在保证有序性的同时,支持高效的并发读写; -
它是 TreeMap 的并发版本。TreeMap 是基于红黑树的有序 Map,但非线程安全;
ConcurrentSkipListMap
则在保证有序性的基础上,实现了线程安全,支持高并发读写操作; -
适用于需要高并发性能、有序性、区间查询的场景:
-
高并发性能:跳表的并发控制机制使其在多线程下的读写效率优于加锁的 TreeMap;
-
有序性:能像 TreeMap 一样按 Key 排序;
-
区间查询:支持类似“获取 Key 介于 A 和 B 之间的所有元素”的区间操作,且并发下仍能高效执行。
-
4.2 跳表
4.2.1 简介
-
跳表是一种基于有序链表的高级数据结构,支持快速的插入、删除、查找操作,时间复杂度为O(log n),远高于普通链表的 O(n),是平衡树(如红黑树)的高效替代方案之一;
-
跳表的结构是通过多层索引链表实现的:
-
普通有序链表,元素按顺序
1→2→3→…→12
串联,查找元素时需逐个遍历,时间复杂度 O(n); -
初步的跳表雏形,在普通链表上增加一层稀疏索引链表(如
1→3→5→9→12
)。查找时先通过索引层快速跳过无效元素,再在底层链表精确查找,减少了遍历次数; -
完整的跳表结构,包含多层索引(Level3、Level2、Level1)。最底层(Level1)包含所有元素,上层索引层逐渐稀疏;每个元素可能出现在多个层级的链表中,层级越高,元素越稀疏;
-
-
跳表的设计遵循以下规则:
- 多层结构:由多个层级的有序链表组成;
- 有序性:每一层的链表都是有序的(默认升序,也可自定义排序);
- 底层全量:最底层(Level1)包含所有元素;
- 层级包含性:若一个元素出现在 LevelN(N>1)的链表中,它必定也出现在下一层(LevelN-1)的链表中;
- 节点双指针:每个节点包含两个指针——一个指向同层下一个元素,一个指向下层的相同元素;
-
跳表通过多层索引实现了类似二分查找的效果,使得插入、删除、查找的时间复杂度降至 O(log n):
-
查找时,从最上层索引开始“跳跃式”缩小范围,最终在底层链表找到目标元素;
-
插入/删除时,只需调整对应层级的链表指针,无需像平衡树那样进行复杂的旋转操作,实现更简单且并发友好。
-
4.2.2 跳表的查找
-
步骤1:从最上层(Level3)开始比较
-
初始位置在 Level3 的头节点
1
,要查找元素11
; -
比较
11
和 Level3 下一个节点5
:11 > 5
,继续向右移动; -
比较
11
和 Level3 后续节点12
:11 < 12
,因此向下层(Level2)寻找;
-
-
步骤2:在 Level2 中继续查找
-
来到 Level2 的
5
节点,比较11
和下一个节点9
:11 > 9
,继续向右移动; -
比较
11
和 Level2 后续节点12
:11 < 12
,因此向下层(Level1)寻找;
-
-
步骤3:在 Level1(底层全量链表)中精确查找
- 来到 Level1 的
9
节点,向右依次比较:10
(11 > 10
)→11
(找到目标元素)。
- 来到 Level1 的
4.2.3 跳表的插入
-
跳表插入数据分为三步:
- 确定插入层级 K:通过随机方式确定元素要插入的层级。若 K 大于跳表现有总层级,则开辟新的层级;否则在对应层级插入;
- 申请新节点:为要插入的元素(如案例中的
13
)创建节点; - 调整指针:在对应层级的链表中,调整前后节点的指针,将新节点接入跳表;
-
跳表的层级是随机生成的,这一设计保证了跳表的“平衡性”,避免出现某一层级过密或过疏的情况;
-
案例1:插入元素13,原有的层级是3级,假设K=4,即插入到第 4 级
-
原有跳表层级为 3 级(Level1~Level3),因 K=4,需开辟新的 Level4;
-
新元素
13
会在 Level4、Level3、Level2、Level1 这 4 个层级中插入,保持各层级的有序性;
-
-
-
K=2(小于原有3级),即插入到第 2 级
- 原有跳表层级为 3 级,因 K=2,仅在 Level2、Level1 这 2 个层级中插入
13
,Level3 保持不变;
- 原有跳表层级为 3 级,因 K=2,仅在 Level2、Level1 这 2 个层级中插入
-
插入新节点时,需要调整对应层级中前驱节点和后继节点的指针:
-
前驱节点(如
12
)的【同层下一个元素指针】指向新节点(指向13
); -
新节点(如
13
)的【同层下一个元素指针】指向后继节点(此处指向null
); -
同时,新节点(如
13
)的【下层相同元素指针】指向下一层的自身节点(若存在多层插入)。
-
4.3 使用
-
ConcurrentSkipListMap
的创建和操作流程与普通Map
高度一致,可以快速上手:-
首先通过
new ConcurrentSkipListMap<>()
创建实例; -
然后调用
put
、get
、remove
等方法完成增、查、删操作; -
还可以通过
keySet()
遍历所有键,进而获取对应的值;
-
方法 | 功能描述 |
---|---|
put(键, 值) |
向 ConcurrentSkipListMap 中添加键值对,且会自动按键的自然顺序(或自定义比较器)排序 |
get(键) |
根据键获取对应的值,若键不存在则返回 null |
keySet() |
获取所有键的集合,集合中的键是有序的(默认升序) |
remove(键) |
根据键删除对应的键值对,返回被删除的值(若键不存在则返回 null ) |
-
例:
public class ConcurrentSkipListMapDemo { public static void main(String[] args) { ConcurrentSkipListMap<Integer, String> map = new ConcurrentSkipListMap<>(); // 添加元素 map.put(1, "a"); map.put(3, "c"); map.put(2, "b"); map.put(4, "d"); // 获取元素 String value1 = map.get(2); System.out.println(value1); // 输出:b // 遍历元素 for (Integer key : map.keySet()) { String value = map.get(key); System.out.println(key + " : " + value); } // 删除元素 String value2 = map.remove(3); System.out.println(value2); // 输出:c } }
5 电商场景中并发容器的选择
-
案例一:电商活动商品售卖数量统计
-
分析:需频繁对商品 ID(key)进行“get 读取次数 + set 写入新次数”的操作,商品 ID 数量稳定(不频繁新增);
-
初级方案:
- 用
HashMap
会因非线程安全导致数据丢失或不准确;JDK1.7 前甚至会出现扩容死循环,导致 CPU 飙升; - 用
Hashtable
会因全局锁导致并发性能极差,锁粒度太粗;
- 用
-
选型:
ConcurrentHashMap
。它通过细粒度锁(JDK1.8 后为节点级synchronized + CAS
)保证线程安全,同时维持高并发性能,完美适配“频繁读写、key 数量稳定”的场景;
-
-
案例二:用户浏览商品历史与次数统计
-
分析:用户数量极多(key 数量大),且需频繁读写(更新浏览次数);
-
初级方案:
ConcurrentHashMap
在数据量大时会将链表转为红黑树,但红黑树的平衡操作在高并发下会涉及大量节点,锁竞争成本高,性能下降; -
选型:
ConcurrentSkipListMap
。它基于跳表实现,增删改查的时间复杂度为 O(log n),且跳表的分层索引设计在高并发下的锁竞争远低于红黑树,更适合“数据量极大、频繁增删改”的场景;
-
-
案例三:活动冻结用户列表,冻结后不允许再下单采购,但是可以浏览(低频写、高频读)
-
分析:冻结用户数量少(写操作低频),但非冻结用户每次抢购都要读取列表判断(读操作高频);
-
初级方案:
ArrayList
线程不安全,高并发下会出现数据错乱。Vector
虽线程安全,但因全局锁导致读操作也被阻塞,并发性能差。
-
选型:
CopyOnWriteArrayList
。它的写时复制机制让读操作无锁、并发性能极高;写操作虽有内存开销,但因写频率低,整体成本可接受,完美适配“低频写、高频读”的场景;
-
-
总结:并发容器选型策略
业务需求 推荐容器 核心原因 高并发下的哈希表(key 数量稳定、频繁读写) ConcurrentHashMap
细粒度锁保证线程安全,高并发性能优 高并发下的有序映射(数据量大、频繁增删改) ConcurrentSkipListMap
跳表结构在高并发下的锁竞争更低,增删改查效率稳定 低频写、高频读的列表场景 CopyOnWriteArrayList
读操作无锁并发,写操作复制数组保证安全,适配“读多写少”场景 强一致性要求(极少场景) Hashtable
全局锁保证强一致性,但并发性能差,仅在极端场景下使用
更多推荐
所有评论(0)