Java实战排错:ArrayList并发add引发数组越界、数据丢失问题深度分析与解决方案
Java实战排错:ArrayList并发add引发数组越界、数据丢失问题深度分析与解决方案
前言
ArrayList是日常开发使用频率最高的集合容器,底层基于数组实现,查询高效。很多开发者在多线程场景下直接使用ArrayList执行add新增操作,本地测试偶尔正常,线上并发场景出现诡异问题:存入的数据莫名丢失、抛出ArrayIndexOutOfBoundsException数组越界异常,且故障难以稳定复现。
ArrayList非线程安全是基础知识点,但很多人只记住结论,不清楚底层为什么会出现数据丢失、数组越界。本文结合真实线上多线程导入业务故障,还原错误代码、剖析底层源码、复现异常现象,提供多种生产可用解决方案,附带完整测试代码与落地规范。

一、真实线上故障场景还原
1.1 业务背景
后台开启多线程异步解析Excel批量用户数据,每个线程解析完成后调用add方法,将有效用户实体存入公共ArrayList集合,解析完成后统一入库。
开发直接选用ArrayList作为公共容器,上线并发导入大批量数据后出现两类问题:
- 部分解析成功的数据消失,最终集合内元素数量小于预期;
- 偶发抛出
ArrayIndexOutOfBoundsException程序中断,导入任务失败。
1.2 错误复现代码
java
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
/
错误示例:多线程并发操作ArrayList
/
public class ArrayListConcurrentErrorDemo {
// 非线程安全集合
private static final List
// 线程数量
private static final int THREAD_COUNT = 10;
// 每个线程添加元素数量
private static final int ADD_NUM = 1000;
public static void main(String[] args) throws InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(THREAD_COUNT);
for (int i = 0; i < THREAD_COUNT; i++) {
int threadNo = i;
new Thread(() -> {
try {
for (int j = 0; j < ADD_NUM; j++) {
dataList.add(threadNo 10000 + j);
}
} finally {
countDownLatch.countDown();
}
}).start();
}
countDownLatch.await();
// 预期总数:THREAD_COUNT ADD_NUM = 10000
System.out.println("最终集合元素数量:" + dataList.size());
}
}
多次运行程序会出现两种现象:
- 集合size小于10000,数据丢失;
- 随机抛出异常:
java.lang.ArrayIndexOutOfBoundsException
开发常见误区
- 认为只是简单add操作,不会产生并发冲突;
- 测试环境并发量低,无法复现问题,上线才暴露;
- 出现异常后盲目加sleep,试图规避并发问题,治标不治本。
二、底层源码分析,故障产生两大核心原因
ArrayList核心add源码:
java
public boolean add(E e) {
ensureCapacityInternal(size + 1); // 扩容校验
elementData[size++] = e;
return true;
}
原因1:size++ 非原子操作,造成数据丢失
elementData[size++] = e 分为三步执行:
- 读取当前size值
- 将元素赋值给 elementData[size]
- size自增 +1
多线程并发下,两个线程同时读取到同一个size,先后赋值到同一个数组下标,后写入的数据直接覆盖先写入的数据,最终size只增加一次,数据永久丢失。
原因2:并发扩容触发数组越界异常
ensureCapacityInternal(size + 1) 负责判断是否需要扩容。
当多个线程同时判定需要扩容,同时进入扩容逻辑;部分线程扩容完成,部分线程仍然使用旧数组长度执行赋值,下标超出数组最大长度,抛出ArrayIndexOutOfBoundsException。
三、生产环境多种解决方案(按场景选择)
方案1:Collections.synchronizedList(简单改造,低并发场景)
JDK提供同步包装集合,对所有方法增加synchronized同步锁,快速改造原有代码。
java
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CountDownLatch;
public class SyncListDemo {
// 使用同步包装器
private static final List
private static final int THREAD_COUNT = 10;
private static final int ADD_NUM = 1000;
public static void main(String[] args) throws InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(THREAD_COUNT);
for (int i = 0; i < THREAD_COUNT; i++) {
int threadNo = i;
new Thread(() -> {
try {
for (int j = 0; j < ADD_NUM; j++) {
dataList.add(threadNo 10000 + j);
}
} finally {
countDownLatch.countDown();
}
}).start();
}
countDownLatch.await();
System.out.println("最终集合元素数量:" + dataList.size());
}
}
注意:遍历集合时,需要手动加锁,否则依然存在并发异常风险。
java
synchronized (dataList) {
for (Integer num : dataList) {
// 遍历逻辑
}
}
优点:改动极小;缺点:全局锁,高并发场景竞争激烈,性能较差。
方案2:CopyOnWriteArrayList(读多写少场景首选)
写时复制容器,写入时复制一份新数组完成修改,修改完成替换原数组;读取不加锁。
java
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.CountDownLatch;
public class CopyOnWriteDemo {
private static final List
private static final int THREAD_COUNT = 10;
private static final int ADD_NUM = 1000;
public static void main(String[] args) throws InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(THREAD_COUNT);
for (int i = 0; i < THREAD_COUNT; i++) {
int threadNo = i;
new Thread(() -> {
try {
for (int j = 0; j < ADD_NUM; j++) {
dataList.add(threadNo 10000 + j);
}
} finally {
countDownLatch.countDown();
}
}).start();
}
countDownLatch.await();
System.out.println("最终集合元素数量:" + dataList.size());
}
}
⚠️重要限制:
每次add都会复制完整数组,写入性能差、内存占用高;禁止大量循环写入场景,适合查询频繁、写入很少的业务。
方案3:线程本地集合 + 合并结果(高并发大批量写入最优方案)
每个线程创建独立ArrayList,线程内部无并发冲突,任务结束后合并所有集合,规避全局锁竞争,性能最优。
适合多线程批量解析、数据采集场景,也是本次故障最终采用的优化方案。
java
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
public class ThreadLocalListDemo {
private static final int THREAD_COUNT = 10;
private static final int ADD_NUM = 1000;
// 最终汇总容器
private static final List
public static void main(String[] args) throws InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(THREAD_COUNT);
for (int i = 0; i < THREAD_COUNT; i++) {
int threadNo = i;
new Thread(() -> {
// 线程内部私有集合,不存在竞争
List
for (int j = 0; j < ADD_NUM; j++) {
threadData.add(threadNo 10000 + j);
}
// 合并结果,合并阶段加锁
synchronized (resultList) {
resultList.addAll(threadData);
}
countDownLatch.countDown();
}).start();
}
countDownLatch.await();
System.out.println("最终集合元素数量:" + resultList.size());
}
}
方案4:手动加锁(自定义锁控制)
保留ArrayList,操作集合时增加ReentrantLock或者synchronized,适合已有大量业务代码不方便替换集合类型场景。
java
Lock lock = new ReentrantLock();
lock.lock();
try {
dataList.add(item);
}finally {
lock.unlock();
}
四、企业开发编码规范
- ArrayList、LinkedHashMap、HashMap均非线程安全,禁止多线程并发修改;
- 多线程批量写入场景优先使用「线程私有集合,最后合并」方案,性能最优;
- 读多写少场景选用CopyOnWriteArrayList;大量写入场景避免使用;
- 简单并发场景快速修复可使用Collections.synchronizedList,遍历集合务必手动加锁;
- 代码审查重点排查:线程池任务共享ArrayList、HashMap等容器;
- 并发集合不要凭经验选择,需要结合读写比例评估内存与性能开销。
五、故障排查小技巧
- 并发场景出现数据丢失、偶发数组越界,优先检查是否多线程共用ArrayList;
- 并发问题难以本地复现,可提高线程数量、加大循环次数构造压力测试;
- 压力测试不要只观察程序是否报错,必须校验最终数据总量是否符合预期。
凡尘版权
友情链接:凡尘博客 fanchenblog.com、wz.fanchenblog.com、雨落凡尘博客 b.fanchenblog.com、t.fanchenblog.com、凡尘乡音 y.fanchenblog.com、凡尘影院 a.fanchenblog.com

浙公网安备 33010602011771号