Java 线程安全 List (ArrayList )

7 阅读8分钟

前言

在java并发开发中,List作为最常用的集合容器之一,其线程安全问题是我们常常考虑的问题,大部分在日常编程的时候,习惯使用ArrayList完成数据存储,但忽略了在多线程环境下的问题,看似一切没毛病,但是高并发场景下又出现数据错乱,异常抛出和系统卡顿等问题。

ArrayList原理

ArrayList 是 Java 集合框架中 List 接口的动态数组实现,其底层基于Object[] elementData 数组存储元素,而它的底层设计围绕“性能优先(单线程情况下)展开”,主要包含以下的三方面。

  • 它的核心成员变量有elementData(存储元素的数组)和 size(实际元素个数),还有 DEFAULT_CAPACITY = 10(默认初始容量)、EMPTY_ELEMENTDATA(空数组常量)、DEFAULTCAPACITY_EMPTY_ELEMENTDATA(默认容量空数组),三者共同控制数组的初始化和扩容逻辑。
  • 而当我们使用无参构造器 new ArrayList<>() 时,底层并不会立即创建容量为10的数组,而是先指向 DEFAULTCAPACITY_EMPTY_ELEMENTDATA 空数组;直到第一次调用 add() 方法时,才会初始化数组为容量10的 Object[]
  • 接着无论是 add()remove() 还是 set(),本质都是对elementData 数组的直接操作,且所有操作都未做任何线程同步处理,甚至连 size 变量的修改都不是原子操作(如 size++

以下是其核心代码

public ArrayList() { 
this.elementData = EMPTY_ELEMENTDATA; // 初始时指向空数组,不立即创建容量为10的数组 
}

public boolean add(E e) { 
modCount++; // 记录集合修改的次数
add(e, elementData, size); 
return true; 
}
// 私有add重载方法:抽取添加元素的核心逻辑提供对外方法调用 
private void add(E e, Object[] elementData, int s) { 
if (s == elementData.length)
elementData = grow(); 
elementData[s] = e;  
size = s + 1; 
} 
// 扩容核心方法:无参重载且默认按当前元素数+1的需求扩容 
private Object[] grow() {
return grow(size + 1); 
} 
// 扩容核心方法:根据最小需求容量,计算并执行扩容,返回扩容后的新数组 
private Object[] grow(int minCapacity) { 
int oldCapacity = elementData.length; 
// 判断当前数组是否为初始化后的空数组
if (oldCapacity > 0 || elementData != EMPTY_ELEMENTDATA) { 
// 计算新容量:调用工具类默认按原容量1.5倍扩容
int newCapacity = ArraysSupport.newLength(oldCapacity, 
minCapacity - oldCapacity,// 扩容的最小增量
oldCapacity >> 1); // 扩容偏好增量(原容量右移1位,等价于原容量*0.5) 
return elementData = Arrays.copyOf(elementData, newCapacity); // 将原数组元素拷贝到新数组
} else {
// 空数组首次扩容:取默认容量和最小需求容量的最大值以确保容量不小于10 
return elementData = new Object[Math.max(DEFAULT_CAPACITY, minCapacity)]; 
  } 
}

ArrayList缺陷

而从代码上可以看出,ArrayList的底层设计是围绕着单线程场景优化,而没有考虑多线程并发的处理,这就会导致一些问题的发生。

一.无锁的动态数组操作

ArrayList 的底层是基于 Object 数组实现的动态扩容容器,核心操作(add、remove、set 等)均未做任何线程同步处理。在多线程环境下,多个线程可同时操作底层数组,导致数组结构被破坏。elementData[size++]看似一行代码,但是它包括了“获取 size 值→给数组赋值→size 自增”三个步骤,且这三个步骤都不是原子操作,也没有对其有任何锁保护,这就会导致在多线程并发的情况出现两个典型的问题

1.数据覆盖

线程 A 和线程 B 同时获取到同一个 size 值(比如 size=5),线程A先向elementData[5] 赋值,随后size自增到6;但是线程B已经获取size=5,依然对elementData[5]进行赋值,导致线程A的赋值被线程B覆盖导致数据丢失。

2.size 计数异常

线程A和线程B同时获取size=5,线程A赋值后执行++(size=6),线程B赋值后也执行++(size=7),但是若线程A赋值后,size在自增前,线程B已经完成赋值和对size自增,就会导致size多增1,从而size计数大于实际元素个素,后续可能会出现数值下标越界的问题。

二.并发扩容导致的数组错乱

ArrayList 当元素个数达到数组容量上限时,会触发扩容操作(默认扩容为原容量的 1.5 倍),而扩容的核心是“创建新数组→拷贝原数组元素→替换原数组引用”。这一过程在多线程并发下,会出现严重的数组错乱问题。如线程 A 和线程 B 同时调用 add() 方法,此时 ArrayList 为延迟初始化状态(elementData 指向统一的 EMPTY_ELEMENTDATA 空数组);线程A会先执行add判断size==0,触发扩容,准备初始化数组为容量10;线程B也执行相同的逻辑,最终两个线程同时初始化数组,导致集合数据错乱和size计数异常等问题。

解决方案

一.Vector

Vector 是 Java 最早提供的线程安全 List,其设计思路非常简单,在 ArrayList 的基础上,给所有核心操作方法添加 synchronized 修饰即(public synchronized E remove(int index)public synchronized boolean add(E e)),通过全局锁来保证线程安全。可以理解为“给 ArrayList 的每一个方法都加了一把锁”,确保同一时刻只有一个线程能执行集合操作。而它虽然能保证线程是安全的,但是无论是读,还是写都要对Vector操作来获取同一把锁,就会导致无法实现并行处理,所有的操作都要排队执行,会导致在高并发的情况下系统的响应速度变慢,出现卡顿的问题

二.Collections.synchronizedList

Collections.synchronizedList 是 Java 提供的一个工具类方法,其核心作用是“装饰”普通 List(如 ArrayList),通过装饰器模式为普通 List 添加线程安全支持。它的设计思路比 Vector 灵活,但本质上依然是全局锁方案。

static class SynchronizedList<E> extends SynchronizedCollection<E> implements List<E> {
    private final List<E> list; // 被包装的原始List(如ArrayList),所有操作委托给该List执行
      // 接收原始List,初始化父类同步所需的锁对象
    SynchronizedList(List<E> list) {
        super(list);
        this.list = list;
    }
    // 向指定索引添加元素,通过synchronized代码块加锁,保证线程安全
    public void add(int index, E element) {
        synchronized (mutex) { 
            list.add(index, element); // 委托原始List执行添加操作
        }
    }
      // 获取指定索引元素,通过synchronized代码块加锁,保证线程安全
    public E get(int index) {
        synchronized (mutex) { 
            return list.get(index); // 委托原始List执行获取操作
        }
    }
}

上面代码可以看出与Vector不同的是这方法修饰的是代码块,而Vector而是实例本身,这就可以通过构造方法自定义锁的对象,实现多个SynchronizedList 共用一把锁的情况,提升并发的效率,但依旧无法解决与Vector一样的问题。

三.CopyOnWriteArrayList

CopyOnWriteArrayList 是 Java 并发包(java.util.concurrent)提供的线程安全 List,它使用写实复制(Copy-On-Write,COW),将集合的读操作和写操作分离,读操作直接读底层数组,无需加锁,写操作(add、remove、set 等)时,不直接修改原来数据,而是创建一个新的数组,将原数组的元素拷贝到新数组中,在新数组上执行修改操作,修改完成后,再将底层数组的引用指向新数组。

而它的核心成员变量包含一把ReentrantLock(用于控制写操作的并发)和一个 volatile 修饰的 Object 数组(用于存储元素),以及volatile,来保证数组引用的可见性,保写操作完成后,读线程能立即看到新数组

// CopyOnWriteArrayList核心成员变量:控制写操作并发,存储元素
private final ReentrantLock lock = new ReentrantLock(); // 可重入锁用于控制写操作的并发
private transient volatile Object[] array; // 存储元素的核心数组,volatile保证数组引用的可见性

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) {
    // 直接读取当前核心数组的元素,无锁操作
    // volatile修饰array,保证能读取到最新的数组引用
    return get(getArray(), index);
}

总结

ArrayList线程不安全的根源,并非单纯未加锁,而是底层无锁设计扩容缺陷等一系列问题,导致在多线程的环境中数据错乱,异常抛出,而也提到了三种解决方案,Vector与SynchronizedList二者性能几乎一致,只是锁的灵活度不同,但基本都会使用后者优先,在高并发的场景下都不适用。而CopyOnWriteArrayList虽然使用于高并发的场景,但是如果遇到写操作频繁操作的场景,那写操作的数组拷贝开销会导致性能急剧下降,适得其反,且如果业务有要求读必须是最新数据就不能使用CopyOnWriteArrayList。

方案名称核心锁机制读性能写性能核心适用场景
Vectorsynchronized 全局锁(方法级)极差(读需排队)极差(写需排队)老项目维护,新项目禁止使用
Collections.synchronizedListsynchronized 全局锁(代码块级)较差(读需排队)较差(写需排队)临时测试、读少写少的简单并发场景
CopyOnWriteArrayListReentrantLock 细粒度锁(仅写操作)极高(无锁读)中等(有数组拷贝开销)读多写少、高并发场景(推荐)