java

关注公众号 jb51net

关闭
首页 > 软件编程 > java > Java Arraylist线程不安全性

Java Arraylist在多线程环境下的问题与解决方案

作者:加洛斯

这篇文章主要介绍了关于arraylist的多线程问题以及多种场景下的解决方案,同时详细简述了其核心内容和原理,希望对大家有一定的帮助

一、ArrayList 的线程不安全性

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

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 字段,用于记录实际元素个数:

所有修改操作如 addremove都会直接操作这个数组和 size。

2.2 并发添加时的竞态条件

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

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

1. 扩容检查

2. 数组越界异常

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

3. size 的非原子操作

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

三、实际测试

我们看一段如下的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("❌ 检测到线程安全问题:数据丢失!");
    }
}

其运行结果如下:

四、解决方案

关于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的方法
        }  // 释放锁
    }
    
    // ... 其他所有方法都是同样的模式
}

优点

缺点

同时它也有着大量的其他问题,例如复合操作不具备原子性。即使使用了 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 保证线程安全!");
    }
}

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;
    }
}

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

优点

缺点

综上,如果你的业务要求严格的实时一致性(比如支付扣款后的余额查询),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("❌ 检测到线程安全问题:数据不完整!");
    }
}

通过上述代码可以看到,同样是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;
}

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

以上就是Java Arraylist在多线程环境下的问题与解决方案的详细内容,更多关于Java Arraylist线程不安全性的资料请关注脚本之家其它相关文章!

您可能感兴趣的文章:
阅读全文