JAVA知识梳理:arraylist在多线程环境下的问题与解决方案

0 阅读11分钟

大家好,我是加洛斯,是一名全栈工程师👨‍💻,这里是我的知识笔记与分享,旨在把复杂的东西讲明白。如果发现有误🔍,万分欢迎你帮我指出来!废话不多说,正文开始 👇

一、ArrayList 的线程不安全性

ArrayList 的所有方法都没有进行同步控制,多个线程同时添加、删除、修改同一个 ArrayList 实例时,会导致:

  • 数据不一致:例如两个线程同时添加元素,可能导致 size 值错误,甚至覆盖彼此的数据。
  • 数组越界异常:内部数组扩容时,多个线程同时操作可能导致 ArrayIndexOutOfBoundsException
  • ConcurrentModificationException:当一个线程正在迭代,另一个线程修改了列表结构(如添加或删除元素)时,会快速失败抛出该异常。
List<Integer> list = new ArrayList<>();
ExecutorService executor = Executors.newFixedThreadPool(10);
for (int i = 0; i < 1000; i++) {
    executor.submit(() -> list.add(1));
}
executor.shutdown();
// 结果:可能抛出异常,或最终 size 不等于 1000

二、为什么它是线程不安全的

ArrayList 是线程不安全的,根本原因在于其内部实现没有对共享数据的并发访问进行任何同步控制,导致多线程同时修改时会出现数据竞争。

2.1 内部数据结构

ArrayList 底层是一个 Object 数组elementData)和一个 int 类型的 size 字段,用于记录实际元素个数: image.png 所有修改操作如 addremove都会直接操作这个数组和 size。

2.2 并发添加时的竞态条件

假设两个线程同时执行 list.add(e),该方法大致步骤如下:

public boolean add(E e) {
    ensureCapacityInternal(size + 1);  // 检查是否需要扩容
    elementData[size++] = e;            // 插入元素并 size 自增
    return true;
}

1. 扩容检查

  • ensureCapacityInternal(size + 1) 会读取当前 size,判断数组是否已满。
  • 如果两个线程同时发现数组还有空间(比如 size=5,容量=10),它们都会认为无需扩容,然后继续向下执行。
  • 但若两个线程同时发现需要扩容,它们可能各自进行扩容操作,最终只有一个数组被保留,另一个线程使用的数组可能被覆盖,导致数据丢失。

2. 数组越界异常

更常见的情况是:两个线程在扩容后都准备插入元素:

  • 线程 A 执行 elementData[size] = e 时,size 还是旧值(比如 5);
  • 线程 B 也执行同样的操作,可能线程 A 还没更新 size,线程 B 仍然使用旧 size(5)写入同一位置,覆盖了线程 A 的数据;
  • 或者线程 B 在写入前,线程 A 已经更新 size 为 6,线程 B 再写入时使用的索引 5 已经合法,但最终 size 可能只增加了一次,导致少计一个元素
  • 极端情况:如果两个线程同时写入同一个数组下标,并且数组已满且都触发了扩容,可能最终写入时使用的数组引用不一致,导致 ArrayIndexOutOfBoundsException

3. size 的非原子操作

size++ 实际上分为三步:读取 size → 加 1 → 写回 size。多线程环境下,这些步骤可能交错执行,导致:

  • 两个线程都读取到 size=5,各自加 1 后写回 6,最终 size 为 6,但实际插入了两个元素,丢失一次更新
  • 最终 size 值小于实际元素个数,或数组中有空位(null),后续操作可能出现问题。

三、实际测试

我们看一段如下的java代码

/**
 * 演示 ArrayList 多线程并发问题
 */
@Test
public void testArrayListConcurrencyIssue() throws InterruptedException {
    List<Integer> list = new ArrayList<>();
    int threadCount = 5;
    CountDownLatch latch = new CountDownLatch(threadCount);

    // 创建 5 个线程,每个线程添加 1000 个元素
    for (int i = 0; i < threadCount; i++) {
        final int threadId = i;
        new Thread(() -> {
            try {
                for (int j = 0; j < 1000; j++) {
                    list.add(threadId * 1000 + j);
                }
            } catch (Exception e) {
                System.err.println("线程异常:" + e);
            } finally {
                latch.countDown();
            }
        }).start();
    }

    latch.await();

    System.out.println("预期大小:" + (threadCount * 1000));
    System.out.println("实际大小:" + list.size());
    System.out.println("丢失元素:" + (threadCount * 1000 - list.size()));
    
    if (list.size() < threadCount * 1000) {
        System.out.println("❌ 检测到线程安全问题:数据丢失!");
    }
}

其运行结果如下:

image.png

四、解决方案

关于ArrayList多线程并发解决方案还是很多的,但是各有优缺点,我们来一个一个介绍。

4.1 Collections.synchronizedList 同步包装器

它是将普通 ArrayList 转换为线程安全版本最直接的手段。synchronizedList 的核心设计思路是 装饰器模式。它并没有重新实现一个 List,而是将原有的 ArrayList 包裹起来,然后在每个方法的实现上都加上 synchronized 代码块,通过同一把 互斥锁 来保证线程安全。

// Collections类中的静态内部类
static class SynchronizedList<E> extends SynchronizedCollection<E> implements List<E> {
    final List<E> list;  // 被包装的原始ArrayList

    SynchronizedList(List<E> list, Object mutex) {
        super(list, mutex);  // 将mutex(锁对象)传给父类
        this.list = list;
    }

    public E get(int index) {
        synchronized (mutex) {  // 获取锁
            return list.get(index); // 调用原ArrayList的方法
        }  // 释放锁
    }

    public void add(int index, E element) {
        synchronized (mutex) {  // 获取锁
            list.add(index, element); // 调用原ArrayList的方法
        }  // 释放锁
    }
    
    // ... 其他所有方法都是同样的模式
}

优点

  • 使用简单:一行代码就能把非线程安全的 ArrayList 包装成线程安全的。
  • 强一致性:由于每次操作都加锁,你能获得 强一致性 的数据视图(只要操作完成,其他线程立即可见)。
  • 兼容性好:返回的 List 实现了 RandomAccess 接口(如果原 List 实现了),所以随机访问的性能和 ArrayList 一样好。

缺点

  • 性能瓶颈:相当于将并发操作强制变成了串行操作。在并发量高的时候,这就是一个巨大的性能瓶颈。
  • 粗粒度锁:锁的范围是整个 List 对象,无法进行并发读取(读写互斥,读读也互斥)。

同时它也有着大量的其他问题,例如复合操作不具备原子性。即使使用了 synchronizedList,下面这段代码在多线程环境下仍然是错误的:

List<String> list = Collections.synchronizedList(new ArrayList<>());
// ... 假设list中已经有了一些元素

// 在多线程环境下执行这段代码
if (!list.contains("a")) {  // 检查操作(已加锁)
    list.add("b");          // 添加操作(已加锁)
}

contains 和 add 虽然是两个原子操作,但组合在一起就不是原子操作了。在 contains 检查通过后、add 执行之前,可能有另一个线程插进来添加了该元素,导致最终重复添加。

此时你需要手动使用同一个锁对象来保证复合操作的原子性:

// 正确做法:使用list对象本身作为锁,锁住整个操作块
synchronized (list) {
    if (!list.contains("特定元素")) {
        list.add("特定元素");
    }
}

因为 synchronizedList 内部使用的是 this 作为锁,所以外部用 synchronized (list) 可以保证与内部方法使用的是同一把锁这个是多线程的知识点,如果有不会的可以翻翻我写的关于多线程的文章

其次的问题就是遍历时需要手动加锁。当使用迭代器遍历 synchronizedList 时,必须在外层加锁。

List<String> list = Collections.synchronizedList(new ArrayList<>());

// 错误示例:会抛出 ConcurrentModificationException
// 因为迭代器遍历期间,另一个线程可能修改了list
for (String item : list) { 
    // 处理item
}

// 正确示例
synchronized (list) {
    for (String item : list) { // 在锁的保护下遍历
        // 处理item
    }
}

这是因为 SynchronizedList 的 iterator() 方法本身并没有加锁,它返回的迭代器在遍历过程中,如果有其他线程修改了 List,依然会触发快速失败机制。

综上,如果在使用场景是读写比例均衡,或需要强一致性的场景,可以考虑使用Collections.synchronizedList,但是需要记住在遍历或者任何复合操作的情况下,都需要手动加锁来保证原子性。

/**
 * 使用 Collections.synchronizedList 解决并发问题
 */
@Test
public void testSynchronizedList() throws InterruptedException {
    List<Integer> list = Collections.synchronizedList(new ArrayList<>());
    int threadCount = 10;
    int opsPerThread = 50000;
    CountDownLatch latch = new CountDownLatch(threadCount);

    long startTime = System.currentTimeMillis();

    for (int i = 0; i < threadCount; i++) {
        final int threadId = i;
        new Thread(() -> {
            try {
                for (int j = 0; j < opsPerThread; j++) {
                    synchronized (list) {
                        list.add(threadId * opsPerThread + j);
                    }
                }
            } finally {
                latch.countDown();
            }
        }).start();
    }

    latch.await();
    long endTime = System.currentTimeMillis();

    System.out.println("预期大小:" + (threadCount * opsPerThread));
    System.out.println("实际大小:" + list.size());
    System.out.println("执行时间:" + (endTime - startTime) + "ms");
    
    if (list.size() == threadCount * opsPerThread) {
        System.out.println("✓ synchronizedList 保证线程安全!");
    }
}

image.png

4.2 CopyOnWriteArrayList 并发集合

这是Java并发包(JUC)中专门为读多写极少场景量身定做的一个方法。它的设计思想非常巧妙,采用了不变性写时复制策略,彻底解决了并发冲突的问题。

CopyOnWriteArrayList 的核心思想非常简单直观:每当需要对列表进行修改(增、删、改)时,不直接修改原始数组,而是先复制一份快照,在快照上修改,修改完成后再将原数组的引用指向这个新的数组。

public class CopyOnWriteArrayList<E> {
    // 关键:使用 volatile 修饰的数组,保证修改后对其他线程的可见性
    private transient volatile Object[] array;

    // 添加元素的方法
    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();
        }
    }

    // 读取元素的方法(没有加锁)
    public E get(int index) {
        return get(getArray(), index); // 直接从当前数组中获取
    }

    // 返回当前数组的快照
    final Object[] getArray() {
        return array;
    }
}

这种设计带来的两个核心特性:

  1. 读操作无锁:读线程永远不需要加锁,因为它们访问的是当前时刻的数组快照。即使此时有写线程正在复制新数组,也完全不影响读线程访问旧数组。
  2. 数据弱一致性:迭代器一旦被创建,它遍历的就是创建时刻的那个数组快照。遍历过程中,其他线程对列表的修改(即使已经提交)对当前迭代器是不可见的。这被称为  "弱一致性"

优点

  • 极高的读并发:读操作完全不阻塞,也不互斥。这在读多写少的场景下,性能远超 Collections.synchronizedList
  • 迭代安全:永远不会抛出 ConcurrentModificationException。因为迭代器操作的是独立的数组快照。

缺点

  • 内存开销:每次修改都要复制整个数组。如果列表很大(比如上万元素),频繁复制会造成巨大的内存压力(老数组和正在构建的新数组同时存在于内存中),甚至引发频繁的GC。
  • 数据延迟:写线程修改数据后,并不能保证读线程立即看到最新数据。因为读线程访问的可能还是旧的数组快照。不过由于volatile变量的语义,这个"不可见"的时间窗口非常短(写完成后,后续的读操作一定能看到)。
  • 不适合写频繁场景:如果写操作较多,复制数组的开销会急剧上升,性能可能反而不如 synchronizedList

综上,如果你的业务要求严格的实时一致性(比如支付扣款后的余额查询),CopyOnWriteArrayList 就不适合了。

特性CopyOnWriteArrayListCollections.synchronizedList
实现原理空间换时间:写时复制,读写分离时间换安全:所有操作串行化
读锁无锁有锁(读读互斥)
写锁有锁(用ReentrantLock控制)有锁
内存占用高(每次写创建新数组)
数据一致性弱一致性(迭代器快照)强一致性(锁保护)
迭代异常永不抛出 ConcurrentModificationException遍历期间如有修改会抛出异常
最佳场景读多写极少(配置、白名单、监听器列表)读写均衡,或需要强一致性的场景
/**
 * 演示使用 CopyOnWriteArrayList 解决并发问题
 */
@Test
public void testThreadSafeList() throws InterruptedException {
    List<Integer> list = new CopyOnWriteArrayList<>();
    int threadCount = 10;
    int opsPerThread = 50000;
    CountDownLatch latch = new CountDownLatch(threadCount);

    long startTime = System.currentTimeMillis();

    for (int i = 0; i < threadCount; i++) {
        final int threadId = i;
        new Thread(() -> {
            try {
                for (int j = 0; j < opsPerThread; j++) {
                    list.add(threadId * opsPerThread + j);
                }
            } finally {
                latch.countDown();
            }
        }).start();
    }

    latch.await();
    long endTime = System.currentTimeMillis();

    System.out.println("预期大小:" + (threadCount * opsPerThread));
    System.out.println("实际大小:" + list.size());
    System.out.println("执行时间:" + (endTime - startTime) + "ms");
    
    if (list.size() == threadCount * opsPerThread) {
        System.out.println("✓ 线程安全,数据完整!");
    }else {
        System.out.println("❌ 检测到线程安全问题:数据不完整!");
    }
}

image.png

通过上述代码可以看到,同样是50万量级的数据,使用CopyOnWriteArrayList的运行插入的时间是Collections.synchronizedList好几百倍,所以我们一定要注意使用场景的问题。

我们再来用一个读多写少的场景对比一下

/**
 * 读多写少场景:synchronizedList vs CopyOnWriteArrayList
 */
@Test
public void testReadHeavyScenario() throws InterruptedException {
    int threadCount = 10;
    int writeCount = 1000;
    int readCount = 1000000;

    System.out.println("=== synchronizedList 读多写少 ===");
    long syncTime = testSynchronizedList(threadCount, writeCount, readCount);

    System.out.println("\n=== CopyOnWriteArrayList 读多写少 ===");
    long cowTime = testCopyOnWriteArrayList(threadCount, writeCount, readCount);

    System.out.println("\n=== 性能对比 ===");
    System.out.println("synchronizedList: " + syncTime + "ms");
    System.out.println("CopyOnWriteArrayList: " + cowTime + "ms");
    System.out.println("CopyOnWriteArrayList " + (syncTime > cowTime ? "更快" : "更慢") + 
                      ",提升了 " + String.format("%.2f", (double)(syncTime - cowTime) / syncTime * 100) + "%");
}

private long testSynchronizedList(int threadCount, int writeCount, int readCount) throws InterruptedException {
    List<Integer> list = Collections.synchronizedList(new ArrayList<>());
    CountDownLatch latch = new CountDownLatch(threadCount);
    long startTime = System.currentTimeMillis();

    for (int i = 0; i < threadCount; i++) {
        final int threadId = i;
        new Thread(() -> {
            try {
                for (int j = 0; j < writeCount; j++) {
                    synchronized (list) {
                        list.add(threadId * writeCount + j);
                    }
                }
                for (int j = 0; j < readCount; j++) {
                    synchronized (list) {
                        if (!list.isEmpty()) {
                            list.get(list.size() - 1);
                        }
                    }
                }
            } finally {
                latch.countDown();
            }
        }).start();
    }

    latch.await();
    long endTime = System.currentTimeMillis();
    System.out.println("写+读执行时间:" + (endTime - startTime) + "ms,写大小:" + list.size());
    return endTime - startTime;
}

private long testCopyOnWriteArrayList(int threadCount, int writeCount, int readCount) throws InterruptedException {
    List<Integer> list = new CopyOnWriteArrayList<>();
    CountDownLatch latch = new CountDownLatch(threadCount);
    long startTime = System.currentTimeMillis();

    for (int i = 0; i < threadCount; i++) {
        final int threadId = i;
        new Thread(() -> {
            try {
                for (int j = 0; j < writeCount; j++) {
                    list.add(threadId * writeCount + j);
                }
                for (int j = 0; j < readCount; j++) {
                    if (!list.isEmpty()) {
                        list.get(list.size() - 1);
                    }
                }
            } finally {
                latch.countDown();
            }
        }).start();
    }

    latch.await();
    long endTime = System.currentTimeMillis();
    System.out.println("写+读执行时间:" + (endTime - startTime) + "ms,写大小:" + list.size());
    return endTime - startTime;
}

image.png

上面的程序是十个线程,写入1万,读100万,可以看到在读百万量级数据的时候,用CopyOnWriteArrayList的时间几乎是提升了40倍。