前言
在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。
| 方案名称 | 核心锁机制 | 读性能 | 写性能 | 核心适用场景 |
|---|---|---|---|---|
| Vector | synchronized 全局锁(方法级) | 极差(读需排队) | 极差(写需排队) | 老项目维护,新项目禁止使用 |
| Collections.synchronizedList | synchronized 全局锁(代码块级) | 较差(读需排队) | 较差(写需排队) | 临时测试、读少写少的简单并发场景 |
| CopyOnWriteArrayList | ReentrantLock 细粒度锁(仅写操作) | 极高(无锁读) | 中等(有数组拷贝开销) | 读多写少、高并发场景(推荐) |