并发编程——11 并发容器(Map、List、Set)实战及其原理分析

1 JUC包下的并发容器

  • Java 基础集合(如 ArrayListLinkedListHashMap非线程安全 。为了解决线程安全问题,Java 最初提供了同步容器 (如 VectorHashtableSynchronizedList),但它们通过 synchronized 实现全局锁,多线程竞争时会"串行化"执行,并发性能差 。为了兼顾线程安全和并发性能 ,JUC(java.util.concurrent)包提供了并发容器,通过更精细的锁机制或无锁算法,大幅提升多线程场景下的吞吐量;

  • 根据集合的类别(ListSetMapQueue),并发容器可分为以下几类:

    集合类别 并发容器代表
    Map ConcurrentHashMapConcurrentSkipListMap
    List CopyOnWriteArrayList
    Set CopyOnWriteArraySetConcurrentSkipListSet
    Queue BlockingQueue(含多种实现)、ConcurrentLinkedQueueBlockingDeque
  • 以下是几种典型并发容器的设计逻辑:

    • CopyOnWriteArrayList (对应非并发容器 ArrayList

      • 目标:替代 VectorSynchronizedList,解决读多写少场景下的并发性能问题;

      • 原理:利用写时复制 策略------读操作不加锁,写操作时先复制一份新的数组,在新数组上修改,再将新数组赋值给原引用;同时通过 volatile 保证数组的可见性,写操作本身加锁保证原子性;

    • CopyOnWriteArraySet (对应非并发容器 HashSet

      • 目标:替代 SynchronizedSet,实现线程安全的集合(元素不重复);

      • 原理:基于 CopyOnWriteArrayList 实现,核心是在 add 操作时调用 CopyOnWriteArrayListaddIfAbsent 方法------遍历数组,若元素已存在则直接返回,否则添加到数组尾部,以此保证元素不重复;

    • ConcurrentHashMap (对应非并发容器 HashMap

      • 目标:替代 HashtableSynchronizedMap,支持高效的复合操作(如:先检查后操作);

      • 原理:

        • 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() 创建实例;

    • 然后调用 addsetgetremove 等方法完成增、改、查、删操作;

    • 还可以通过 clearisEmptycontainssize 等方法进行集合的状态判断和管理;

    java 复制代码
    // 创建一个 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 "读多写少、最终一致性"的适用场景;

  • 代码示例:

    java 复制代码
    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. 复制新数组 :从原数组(如 [1,2,3,4])复制出一份新数组;
      3. 新数组上执行写操作 :在新数组上完成修改(如添加元素 5,新数组变为 [1,2,3,4,5]);
      4. 解锁 + 切换引用 :解锁后,将原数组的引用(volatile 修饰)指向新数组;volatile 保证其他读线程能立即感知到数组引用的变化
  • 整个过程的线程安全保障由锁 + 数组拷贝 + volatile来保障:

    • :写操作加锁,保证写操作的原子性(同一时间只有一个线程能写);

    • 数组拷贝:写操作在新拷贝的数组上执行,避免了多线程对原数组的直接修改冲突;

    • volatile :底层数组 arrayvolatile 修饰(private transient volatile Object[] array;),保证数组引用的可见性------写操作切换数组引用后,所有读线程能立即看到新数组;

  • 优点:

    • 读性能极高:读操作无锁,多线程可并发执行,尤其适合读多写少的场景;

    • 遍历安全 :因读写分离(读旧数组、写新数组),遍历过程中不会抛出ConcurrentModificationException(与ArrayList遍历修改抛异常的行为不同);

  • 缺点:

    • 内存开销大:每次写操作都要复制数组,若数组元素多,会消耗大量内存,甚至引发频繁 GC;

    • 实时性不足:读操作可能访问旧数组,无法保证读取到最新数据,仅满足最终一致性。

2.5 扩展知识:迭代器的fail-fastfail-safe机制

  • 在 Java 中,迭代器(Iterator)在迭代的过程中,如果底层的集合被修改(添加或删除元素),不同的迭代器对此的表现行为是不一样的,可分为两类:Fail-Fast(快速失败)和 Fail-Safe(安全失败);

  • fail-fast(快速失败)机制

    • 当多个线程(或单线程在遍历中)修改集合结构时,迭代器会立即抛出 ConcurrentModificationException 异常,以此快速失败来提示并发修改问题;

    • 典型案例:ArrayListHashMapjava.util 包下的集合,其迭代器默认采用该机制。例如,线程 A 遍历(迭代) ArrayList 时,若其他线程(或自身后续代码)修改了集合的元素数量,就会抛出 ConcurrentModificationException

    • 解决方案:

      • 方案一:加锁,即在遍历过程中所有涉及到改变modCount的地方全部加上synchronized或者直接使用Collection#synchronizedList,但会阻塞遍历,性能差,不推荐;

        modCount 记录集合结构被修改的次数 (如添加、删除元素等操作会触发 modCount 递增)。当迭代器遍历集合时,会比对自身记录的 expectedModCount 和集合的 modCount

        • 若两者一致,说明遍历过程中集合结构未被修改;
        • 若不一致,说明有其他线程(或当前线程后续代码)修改了集合结构,此时迭代器会立即抛出 ConcurrentModificationException,即 fail-fast异常
      • 方案二:用 CopyOnWriteArrayList 替换 ArrayList(即采用fail-safe机制),推荐;

  • fail-safe(安全失败)机制

    • 集合结构修改时,会在复制的新集合 上进行,因此迭代器遍历原集合时不会抛出 ConcurrentModificationException

    • 典型案例:CopyOnWriteArrayListConcurrentHashMapjava.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 为何弃用分段锁?官方给出了三点核心原因:

    1. 节省内存空间:分段锁需要为每个 Segment 维护额外的锁结构,JDK1.8 的新实现减少了这部分内存开销;
    2. 避免锁竞争导致的性能瓶颈 :分段锁的并发度由 Segment 数量决定,若实际并发度达不到设置的粒度,会导致锁定一段后整个段无法更新,反而引发长时间等待。新实现的节点级锁更精细,竞争概率更低;
    3. 提升 GC 效率:分段锁的结构复杂,GC 回收时开销更大;新实现的结构更简洁,有助于 GC 高效回收内存。

3.2 应用场景

  • 场景 1:共享数据的线程安全 。在多线程编程中,若多个线程需要对同一份哈希表数据进行并发读写操作ConcurrentHashMap 可保证线程安全。它避免了 HashMap 非线程安全导致的脏数据问题,也解决了 Hashtable 全局锁带来的并发性能瓶颈,让多线程共享数据时既能保证一致性,又能维持高并发效率;

  • 场景 2:缓存实现 。由于 ConcurrentHashMap 兼具高并发性能和线程安全,非常适合作为缓存的数据结构。在多线程环境下,大量请求可以并发读写缓存(比如查询用户信息缓存、配置项缓存等),既能保证缓存数据的一致性,又能通过高并发特性提升程序整体的响应速度。

3.3 使用

3.3.1 基本用法

  • ConcurrentHashMap 的基础 API 设计和 HashMap 非常相似:

    java 复制代码
    // 创建一个 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 放入;若存在,直接返回原有 value
    merge(key, value, BiFunction) 若 key 不存在,直接放入 value;若存在,用 BiFunction 处理原有 value 和新 value,将结果作为新 value

3.3.2 *统计文件中英文字母出现的总次数

  • 需求:

    • 生成测试数据:26个英文字母每个循环200次(共5200个单词),乱序后存入26个文件,每个文件200个单词;

    • 统计需求:启动26个线程分别读取26个文件,统计每个字母的出现次数,最终每个字母应恰好出现200次;

  • 生成测试文件

    java 复制代码
    /**
     * 生成测试文件
     * @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")));
            }
        }
    }
  • 读取文件

    java 复制代码
    /**
     * 定义读文件的方法
     */
    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

    java 复制代码
    /**
     * 定义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 方法;
    • 传入两个参数:mapConcurrentHashMap集合)和 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}
  • 测试代码:

    java 复制代码
    // 错误实现(用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 底层采用数组 + 链表的组合结构:

    • 数组 :作为哈希表的"桶",每个桶对应一个数组下标(如上图中的 012...64);

    • 链表 :当多个键的哈希值映射到同一数组下标时,会以链表的形式存储在该桶下(如下标 1 的桶中,链表长度达到 8 个节点);

  • 为了保证线程安全,HashTable 对所有公共方法(如 putgetremoveclear 等)都添加了 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 通过分段锁实现更细粒度的并发控制:

    • 当线程执行 putget 等操作时,会先通过哈希计算定位到具体的 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 下一个节点 511 > 5,继续向右移动;

    • 比较 11 和 Level3 后续节点 1211 < 12,因此向下层(Level2)寻找

  • 步骤2:在 Level2 中继续查找

    • 来到 Level2 的 5 节点,比较 11 和下一个节点 911 > 9,继续向右移动;

    • 比较 11 和 Level2 后续节点 1211 < 12,因此向下层(Level1)寻找

  • 步骤3:在 Level1(底层全量链表)中精确查找

    • 来到 Level1 的 9 节点,向右依次比较:1011 > 10)→ 11(找到目标元素)。

4.2.3 跳表的插入

  • 跳表插入数据分为三步:

    1. 确定插入层级 K :通过随机方式确定元素要插入的层级。若 K 大于跳表现有总层级,则开辟新的层级;否则在对应层级插入;
    2. 申请新节点 :为要插入的元素(如案例中的 13)创建节点;
    3. 调整指针:在对应层级的链表中,调整前后节点的指针,将新节点接入跳表;
  • 跳表的层级是随机生成的,这一设计保证了跳表的"平衡性",避免出现某一层级过密或过疏的情况;

    • 案例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 保持不变;
  • 插入新节点时,需要调整对应层级中前驱节点和后继节点的指针:

    • 前驱节点(如12)的【同层下一个元素指针】指向新节点(指向13);

    • 新节点(如13)的【同层下一个元素指针】指向后继节点(此处指向null);

    • 同时,新节点(如13)的【下层相同元素指针】指向下一层的自身节点(若存在多层插入)。

4.3 使用

  • ConcurrentSkipListMap 的创建和操作流程与普通 Map 高度一致,可以快速上手:

    • 首先通过 new ConcurrentSkipListMap<>() 创建实例;

    • 然后调用 putgetremove 等方法完成增、查、删操作;

    • 还可以通过 keySet() 遍历所有键,进而获取对应的值;

方法 功能描述
put(键, 值) ConcurrentSkipListMap 中添加键值对,且会自动按键的自然顺序(或自定义比较器)排序
get(键) 根据键获取对应的值,若键不存在则返回 null
keySet() 获取所有键的集合,集合中的键是有序的(默认升序)
remove(键) 根据键删除对应的键值对,返回被删除的值(若键不存在则返回 null
  • 例:

    java 复制代码
    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 全局锁保证强一致性,但并发性能差,仅在极端场景下使用
相关推荐
灵魂猎手6 分钟前
Antrl4 入门 —— 使用Antrl4实现一个表达式计算器
java·后端
zhonghua88101612 分钟前
spring ai alibab agent之ReactAgent深度解读
java·人工智能·spring
水坚石青16 分钟前
Java+Swing+Mysql实现物业管理系统
java·开发语言·数据库·mysql·swing
尼古拉斯·纯情暖男·天真·阿玮16 分钟前
[JavaEE初阶] Thread类的基本用法
java·开发语言
Wpa.wk32 分钟前
自动化测试(java) - PO模式了解
java·开发语言·python·测试工具·自动化·po模式
IT 行者44 分钟前
Spring Security 7.0 新特性详解
java·后端·spring
华仔啊1 小时前
Java 的金额计算用 long 还是 BigDecimal?资深程序员这样选
java·后端
Coder_Boy_1 小时前
业务导向型技术日志记录(2)
java·人工智能·驱动开发·微服务
凤凰战士芭比Q1 小时前
Jenkins(环境变量、构建参数、流水线触发、通知报告)
java·servlet·jenkins
运维@小兵1 小时前
Spring AI系列——开发MCP Server和MCP Client(SSE方式)
java·人工智能·spring