哈喽,刷题小伙伴们!Day16咱们吃透了线程池的核心原理、七大参数与实战选型,掌握了并发编程中的“效率神器”。今天咱们聚焦并发编程的另一核心考点——并发容器!在多线程场景下,ArrayList、HashMap这些普通容器是线程不安全的,直接使用会导致数据错乱、程序崩溃等问题。Java提供了专门的并发容器(如ConcurrentHashMap、CopyOnWriteArrayList等),它们通过特殊的设计保证线程安全,同时兼顾性能。今天咱们从“普通容器的线程安全问题”入手,逐一拆解核心并发容器的底层实现、核心优势与适用场景,彻底搞懂不同场景下该选哪种容器!

今日核心目标

  1. 理解普通容器(ArrayList、HashMap)的线程安全问题;

  2. 掌握并发容器的核心设计思想(对比同步容器的弊端);

  3. 吃透ConcurrentHashMap的底层原理(JDK1.8版本重点);

  4. 搞懂CopyOnWriteArrayList/CopyOnWriteArraySet的实现逻辑与适用场景;

  5. 明确各并发容器的选型原则(面试高频)。

一、前置认知:普通容器的线程安全问题(避坑第一步)

在单线程场景下,ArrayList、HashMap是常用的高效容器,但在多线程场景下,它们存在严重的线程安全问题,核心原因是“操作非原子性+无锁保护”。咱们通过两个典型案例拆解:

1. 案例1:ArrayList的线程安全问题(并发添加元素)
import java.util.ArrayList;
import java.util.List;

public class ArrayListThreadSafeDemo {
    public static void main(String[] args) {
        List&lt;Integer&gt; 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及之后引入)的核心优势是“锁粒度细化+无锁设计”,对比同步容器的“全局锁”,并发容器通过以下设计提升性能:

  1. 锁粒度细化:不使用全局锁,而是将容器分割成多个“小片段”,每个片段单独加锁(如ConcurrentHashMap的分段锁/Node锁),多线程访问不同片段时无需竞争锁,可并行执行;

  2. 无锁设计:部分操作通过CAS(compare-and-swap)实现原子性,无需加锁(如CopyOnWrite容器的读操作、ConcurrentHashMap的部分写操作),避免线程阻塞;

  3. 读写分离:区分读操作和写操作,读操作无需加锁(或加轻量级锁),写操作加锁,兼顾读多写少场景的性能(如CopyOnWrite容器);

  4. 故障安全(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)缺点
  • 写操作性能差:每次写操作都要复制整个数组,数组越大,复制开销越大;

  • 数据一致性问题:读操作可能读取到旧数据(写操作执行时,读操作访问的是原数组,新数据写入副本后才会切换引用);

  • 内存占用高:写操作时会同时存在原数组和副本数组,内存消耗翻倍。

五、其他常用并发容器(简单了解)

  1. ConcurrentLinkedQueue

    1. 基于链表的无界并发队列,底层通过CAS实现无锁操作;

    2. 优点:并发性能极高,适合高并发场景下的任务队列;

    3. 适用场景:线程池的任务队列、生产者-消费者模型。

  2. LinkedBlockingQueue

    1. 基于链表的有界/无界并发队列,底层通过ReentrantLock实现线程安全;

    2. 优点:支持阻塞的put()和take()方法(队列满时put阻塞,队列空时take阻塞);

    3. 适用场景:线程池的任务队列(如FixedThreadPool默认使用)、需要阻塞等待的场景。

  3. ConcurrentSkipListMap

    1. 基于跳表的有序并发Map,支持按键排序;

    2. 优点:并发性能优于TreeMap(TreeMap是同步容器,全局锁);

    3. 适用场景:需要有序性的并发场景(如按时间排序的日志存储)。

六、核心选型原则(面试必背)

不同并发容器的设计理念不同,适用场景也不同,记住以下选型原则,面试和实战都能用到:

  1. 并发Map场景

    1. 优先选ConcurrentHashMap:大部分并发场景(读多写少、读写均衡)都适用,性能最优;

    2. 需要有序性选ConcurrentSkipListMap:如按键排序、需要获取子集的场景。

  2. 并发List场景

    1. 读多写少选CopyOnWriteArrayList:如配置缓存、日志列表(读操作极多,写操作极少);

    2. 读写均衡/写多读少选ConcurrentLinkedQueue(若允许队列结构):或手动通过Collections.synchronizedList()包装ArrayList(性能较差,万不得已才用)。

  3. 并发Set场景

    1. 读多写少、元素少选CopyOnWriteArraySet;

    2. 其他场景:可通过ConcurrentHashMap实现(将值设为固定对象,利用键的唯一性去重)。

  4. 并发队列场景

    1. 高并发无界队列选ConcurrentLinkedQueue;

    2. 需要阻塞等待选LinkedBlockingQueue(有界);

    3. 需要按优先级排序选PriorityBlockingQueue。

七、今日打卡

评论区留下你的答案:以下场景该选哪种并发容器?为什么?✅

场景:电商系统的商品配置缓存(商品配置一旦发布,极少修改,但会被大量用户查询);

可选容器:ConcurrentHashMap、CopyOnWriteArrayList、HashMap(Collections.synchronizedMap包装)、Hashtable。

提示:结合场景的“读多写少”特性和各容器的核心优势分析~

文末预告

Day18预告:学完了并发容器,咱们进入并发编程的“通信神器”——线程间通信!线程间如何协作?wait()/notify()和Condition的区别是什么?CountDownLatch、CyclicBarrier、Semaphore这些工具类的核心作用是什么?明天咱们拆解线程间通信的核心原理与实战用法!

更多推荐