文章目录

为什么说ArrayList线程不安全?并发修改异常复现与分析

发布于 2026-05-05 00:15:34 · 浏览 15 次 · 评论 0 条

为什么说ArrayList线程不安全?并发修改异常复现与分析


1. 复现并发修改异常

ArrayList 是 Java 开发中最常用的集合之一,但它并不是线程安全的。当多个线程同时对同一个 ArrayList 实例进行结构性修改(如添加、删除元素)时,很容易引发 java.util.ConcurrentModificationException 异常,或者导致数据丢失、数组越界等问题。

创建一个名为 ArrayListUnsafeDemo 的 Java 类,输入以下代码来模拟多线程并发场景。

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

public class ArrayListUnsafeDemo {
    public static void main(String[] args) {
        // 1. 初始化一个 ArrayList
        List<String> list = new ArrayList<>();

        // 2. 开启一个线程向列表中添加数据
        new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                list.add("Data-" + i);
                try { Thread.sleep(10); } catch (InterruptedException e) {}
            }
        }, "Thread-Add").start();

        // 3. 开启另一个线程遍历列表
        new Thread(() -> {
            Iterator<String> iterator = list.iterator();
            while (iterator.hasNext()) {
                System.out.println("遍历元素: " + iterator.next());
                try { Thread.sleep(10); } catch (InterruptedException e) {}
            }
        }, "Thread-Iterator").start();
    }
}

运行上述代码,控制台大概率会抛出如下异常信息:

Exception in thread "Thread-Iterator" java.util.ConcurrentModificationException
    at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:909)
    at java.util.ArrayList$Itr.next(ArrayList.java:859)
    at ArrayListUnsafeDemo.lambda$main$1(ArrayListUnsafeDemo.java:20)
    ...

2. 分析异常产生的原因

异常的核心在于 ArrayList 的迭代器机制。ArrayList 内部维护了一个名为 modCount 的变量,用于记录列表被结构性修改(如 add、remove)的次数。

获取迭代器时,迭代器会将当前列表的 modCount 值赋给自身的 expectedModCount 变量。在调用 iterator.next() 方法时,系统会检查这两个值是否一致。

以下是迭代器内部检查逻辑的源码解析:

final void checkForComodification() {
    if (modCount != expectedModCount)
        throw new ConcurrentModificationException();
}

解析发生过程:

  1. 线程 A 获取迭代器,此时 modCount 为 0,迭代器内的 expectedModCount 也为 0。
  2. 线程 B 执行 add 操作,导致 ArrayList 的 modCount 自增为 1。
  3. 线程 A 继续执行 next() 方法。
  4. 检测modCount (1) 不等于 expectedModCount (0)。
  5. 抛出 ConcurrentModificationException

我们可以通过以下流程图直观理解这一竞态条件:

sequenceDiagram autonumber participant T_Add as 线程 B (添加) participant List as ArrayList participant T_Itr as 线程 A (遍历) T_Itr->>List: 获取 Iterator
记录 expectedModCount = modCount Note over List: 此时 modCount = N T_Add->>List: 执行 add() 操作 List->>List: modCount++ (变为 N+1) T_Itr->>List: 调用 next() List->>List: 检查 modCount == expectedModCount? alt 检查失败 List-->>T_Itr: 抛出 ConcurrentModificationException end

3. 深入源码探究

打开 ArrayList 的源码,查看 add 方法的实现,可以发现没有任何同步锁(如 synchronized)。

public boolean add(E e) {
    ensureCapacityInternal(size + 1);  // 确保容量足够
    elementData[size++] = e;           // 直接赋值
    return true;
}

这里的 size++ 操作并非原子性。在多线程环境下,可能会发生以下两种常见的数据错误情况(即使没有抛出异常):

  1. 数据覆盖

    • 线程 A 读取 size 为 10。
    • 线程 B 读取 size 为 10。
    • 线程 A 在位置 10 放入元素 A。
    • 线程 B 在位置 10 放入元素 B。
    • 结果:元素 A 被覆盖,size 变为 11,但实际上丢失了一个数据。
  2. 数组越界

    • ensureCapacityInternal 判断容量足够。
    • 线程 A 和线程 B 同时通过了容量检查,但都需要扩容。
    • 可能导致在复制数组时出现索引越界异常。

4. 线程安全的替代方案

为了保证多线程环境下的数据安全,有以下三种常用的解决方案:

方案 实现方式 性能特点 适用场景
Vector 继承自 AbstractList,所有方法都加 synchronized 性能较差,锁粒度大 几乎不使用,遗留代码
Collections.synchronizedList 包装类,使用 synchronized 代码块锁 性能一般,锁粒度依然是大锁 需要兼容旧接口,读写都较频繁时
CopyOnWriteArrayList 写操作时复制一份新数组,读操作无锁 写性能低,读性能极高 读多写少的并发场景

推荐在并发场景下优先使用 CopyOnWriteArrayList

修改之前的代码,使用 CopyOnWriteArrayList

import java.util.concurrent.CopyOnWriteArrayList;
import java.util.List;

public class SafeListDemo {
    public static void main(String[] args) {
        // 替换实现类
        List<String> list = new CopyOnWriteArrayList<>();

        // 添加数据线程
        new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                list.add("Data-" + i);
                try { Thread.sleep(10); } catch (InterruptedException e) {}
            }
        }).start();

        // 遍历数据线程
        new Thread(() -> {
            for (String s : list) {
                System.out.println("遍历元素: " + s);
                try { Thread.sleep(10); } catch (InterruptedException e) {}
            }
        }).start();
    }
}

分析 CopyOnWriteArrayList 的原理:

  • 写入:当调用 add 时,它会加锁复制出一个新的数组(长度+1),修改新数组,最后将引用指向新数组。这保证了写操作的原子性和可见性。
  • 读取:直接读取当前数组引用,无需加锁,速度非常快。

由于写操作需要复制整个数组,如果数据量非常大且写操作非常频繁,会造成内存占用过高和 GC 压力。因此,务必在“读多写少”的场景下使用。

评论 (0)

暂无评论,快来抢沙发吧!

扫一扫,手机查看

扫描上方二维码,在手机上查看本文