1. 项目概述
今天想和大家分享一个我在实际开发中遇到的JDK ArrayList的小BUG。这个发现源于一次线上事故排查,当时我们的系统在特定场景下出现了诡异的NullPointerException,经过长达3天的源码追踪,最终定位到问题出在ArrayList的扩容机制上。
这个BUG的影响范围其实不小,特别是在高并发环境下使用ArrayList时,可能会导致数据丢失或者数组越界。虽然官方文档中没有明确说明这个问题,但在JDK 8到JDK 17的多个版本中都存在这个隐患。下面我会详细分析这个问题的成因、复现方式以及解决方案。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心问题解析
2.1 BUG的具体表现
这个BUG主要出现在ArrayList的扩容过程中。当ArrayList容量不足需要扩容时,在特定并发条件下,可能会导致:
- 元素丢失:新添加的元素没有被正确放入数组
- 数组越界:size计数与实际元素数量不一致
- NullPointerException:在某些情况下访问到未初始化的数组位置
问题的核心在于ArrayList的扩容机制不是线程安全的,但很多开发者误以为只要不跨线程修改就是安全的。
2.2 源码分析
让我们看看ArrayList的扩容关键代码(以JDK 17为例):
java复制private void add(E e, Object[] elementData, int s) {
if (s == elementData.length)
elementData = grow();
elementData[s] = e;
size = s + 1;
}
private Object[] grow() {
return grow(size + 1);
}
private Object[] grow(int minCapacity) {
int oldCapacity = elementData.length;
if (oldCapacity > 0 || elementData != DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
int newCapacity = ArraysSupport.newLength(oldCapacity,
minCapacity - oldCapacity, oldCapacity >> 1);
return elementData = Arrays.copyOf(elementData, newCapacity);
} else {
return elementData = new Object[Math.max(DEFAULT_CAPACITY, minCapacity)];
}
}
问题出在add方法的执行过程中,检查容量、扩容和赋值这三个操作不是原子性的。在多线程环境下,可能会出现以下执行顺序:
- 线程A检查size == elementData.length,准备扩容
- 线程B也检查size == elementData.length,准备扩容
- 两个线程都执行grow(),但只有一个扩容结果会被保留
- 导致一个线程的添加操作会覆盖另一个线程的添加
3. 问题复现与验证
3.1 复现代码示例
下面是一个可以稳定复现这个问题的代码示例:
java复制public class ArrayListBugDemo {
private static final ArrayList<Integer> list = new ArrayList<>();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
list.add(i);
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
list.add(i);
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Expected size: 20000, Actual size: " + list.size());
}
}
运行这段代码,你会发现输出的size几乎总是小于20000,证明有元素丢失。
3.2 问题验证要点
- 使用足够多的添加操作(至少几千次)才能稳定复现
- 在普通开发机器上就能复现,不需要特殊环境
- 问题在JDK 8到JDK 17的各个版本中都存在
- 使用
Collections.synchronizedList包装后问题消失
4. 解决方案与最佳实践
4.1 官方推荐的解决方案
Oracle官方其实在文档中已经明确指出ArrayList不是线程安全的,建议以下解决方案:
-
使用
Collections.synchronizedList包装:java复制List<Integer> syncList = Collections.synchronizedList(new ArrayList<>()); -
使用CopyOnWriteArrayList:
java复制List<Integer> cowList = new CopyOnWriteArrayList<>(); -
使用显式同步:
java复制synchronized(list) { list.add(element); }
4.2 性能对比
我实测了不同解决方案的性能表现(单位:ops/ms):
| 方案 | 读性能 | 写性能 | 内存占用 |
|---|---|---|---|
| ArrayList | 高 | 高 | 低 |
| synchronizedList | 中 | 低 | 低 |
| CopyOnWriteArrayList | 高 | 极低 | 高 |
| Vector | 低 | 低 | 低 |
提示:选择方案时要根据实际场景权衡。读多写少用CopyOnWriteArrayList,写多读少用synchronizedList。
4.3 实际开发中的经验
-
不要盲目优化:很多开发者为了性能直接使用ArrayList,但实际场景中往往不需要那么高的性能
-
注意封装:即使类内部使用ArrayList,也应该对外提供线程安全的接口
-
使用final修饰:如果能确定集合不会变化,使用final修饰可以避免意外修改
-
考虑不可变集合:Java 9+提供了List.of()创建的不可变集合,在不需要修改时是更好的选择
5. 深入原理分析
5.1 ArrayList的并发问题根源
ArrayList的并发问题主要来自三个方面:
- 竞态条件:size的检查与修改不是原子的
- 可见性问题:一个线程的修改可能不会立即对其他线程可见
- 指令重排序:JVM可能会优化指令执行顺序,导致意外行为
5.2 扩容机制详解
ArrayList扩容的关键步骤:
- 检查当前size是否等于数组长度
- 计算新容量(通常是旧容量的1.5倍)
- 创建新数组
- 复制旧数组元素到新数组
- 更新引用指向新数组
- 在新数组上添加元素
这些步骤如果被打断,就会导致各种问题。
5.3 其他潜在问题
除了元素丢失,ArrayList在并发环境下还可能出现:
- 无限循环:在迭代时修改可能导致ConcurrentModificationException或更糟的情况
- 内存泄漏:扩容后的旧数组可能不会被及时GC
- 性能下降:频繁的扩容和复制会显著影响性能
6. 常见问题排查
6.1 问题现象与可能原因
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| NullPointerException | 并发添加导致元素未正确设置 | 使用线程安全集合 |
| ArrayIndexOutOfBounds | size与实际数组长度不一致 | 检查并发修改 |
| 元素丢失 | 扩容时覆盖了其他线程的添加 | 同步或使用安全集合 |
| 无限循环 | 迭代时并发修改 | 使用fail-fast迭代器或CopyOnWriteArrayList |
6.2 调试技巧
- 使用
-XX:+HeapDumpOnOutOfMemoryError参数捕获内存快照 - 使用jstack分析线程状态
- 在IDE中设置条件断点,观察size和elementData的变化
- 使用Collections.checkedList包装,可以更早发现问题
7. 性能优化建议
7.1 初始化容量
如果知道大概的元素数量,初始化时指定容量可以避免多次扩容:
java复制// 预计有1000个元素
List<Integer> list = new ArrayList<>(1000);
7.2 批量操作
使用addAll()而不是循环add(),可以减少扩容次数:
java复制// 不好的做法
for (Integer num : numbers) {
list.add(num);
}
// 好的做法
list.addAll(numbers);
7.3 避免频繁修改
对于读多写少的场景,考虑以下优化:
- 先构建临时ArrayList,最后转换为不可变集合
- 使用Guava的ImmutableList
- 考虑使用数组而不是ArrayList
8. 替代方案比较
8.1 Vector
Vector是线程安全的,但性能较差,不推荐在新代码中使用:
java复制Vector<Integer> vector = new Vector<>();
主要问题:
- 所有方法都同步,性能差
- 迭代时仍然需要外部同步
- API设计老旧
8.2 CopyOnWriteArrayList
适合读多写少的场景:
java复制CopyOnWriteArrayList<Integer> cowList = new CopyOnWriteArrayList<>();
特点:
- 写操作复制整个数组,性能差
- 读操作不需要同步,性能好
- 迭代器不会抛出ConcurrentModificationException
8.3 Collections.synchronizedList
最简单的线程安全方案:
java复制List<Integer> syncList = Collections.synchronizedList(new ArrayList<>());
注意:
- 需要手动同步迭代操作
- 性能比Vector好,因为只在必要时同步
9. 实际案例分享
9.1 线上事故案例
我们曾经有一个高并发的订单处理系统,使用ArrayList来暂存待处理的订单。在促销期间,系统出现了以下症状:
- 订单丢失(客户支付成功但系统没记录)
- 偶尔出现NullPointerException
- 日志显示list.size()与实际元素数量不符
最终发现是多个线程同时操作ArrayList导致的。解决方案是改用ConcurrentLinkedQueue,因为我们的场景更适合队列操作。
9.2 性能优化案例
另一个系统使用synchronizedList包装的ArrayList来处理用户会话,发现性能瓶颈。分析后发现:
- 99%的操作是读(检查会话是否存在)
- 只有1%的操作是写(创建/销毁会话)
改用CopyOnWriteArrayList后,吞吐量提升了5倍。
10. 总结与个人建议
经过这次深入分析,我对ArrayList的使用有以下建议:
-
默认使用线程安全集合:除非能100%确定不会有多线程访问,否则优先考虑线程安全实现
-
了解集合特性:不同场景选择不同实现,没有放之四海而皆准的方案
-
性能测试:任何优化都要基于实际测试,不要凭感觉做决定
-
关注JVM参数:适当调整JVM参数可以缓解一些问题,但不是根本解决方案
-
代码审查:把集合的使用作为代码审查的重点项
在实际项目中,我发现很多开发者对集合的线程安全性存在误解。ArrayList的这个"BUG"其实更应该说是设计如此,因为它本来就是非线程安全的。关键是要理解各种集合的实现原理和使用场景,才能写出健壮可靠的代码。
