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 等方法进行集合的状态判断和管理;

    // 创建一个 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. 复制新数组:从原数组(如 [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 非常相似:

    // 创建一个 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次;

  • 生成测试文件

    /**
     * 生成测试文件
     * @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 方法;
    • 传入两个参数: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}
    
  • 测试代码:

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

    • 数组:作为哈希表的“桶”,每个桶对应一个数组下标(如上图中的 01264);

    • 链表:当多个键的哈希值映射到同一数组下标时,会以链表的形式存储在该桶下(如下标 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
  • 例:

    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 全局锁保证强一致性,但并发性能差,仅在极端场景下使用
Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐