ArrayList
本章定位:深入理解 ArrayList 的底层动态数组实现、扩容机制(1.5 倍)和时间复杂度的内在原因。ArrayList 是最常用的 List 实现,其随机访问 O(1) 和增删 O(n) 的特性决定了它在"读多写少"场景下的优势。
概述
ArrayList 是基于动态数组实现的 List,是 Java 中使用频率最高的集合类之一。飞翔科技技术部几乎所有的员工列表、项目清单、日志缓存都使用 ArrayList。
| 特性 | 描述 |
|---|---|
| 底层数据结构 | Object[] elementData 动态数组 |
| 默认容量 | 10 |
| 扩容机制 | oldCapacity + (oldCapacity >> 1) 即 1.5 倍 |
| 随机访问 | O(1),通过下标直接访问 |
| 增删元素 | O(n),需要移动后续元素 |
| 线程安全 | 否,多线程需外部同步 |
| 允许 null | 是 |
| 最大容量 | Integer.MAX_VALUE - 8 |
RandomAccess是标记接口(无方法),用于表明实现类支持快速随机访问。Collections.binarySearch()会根据是否实现此接口自动选择索引访问或迭代器访问,提升性能。
方法速查表
| 方法 | 时间复杂度 | 描述 |
|---|---|---|
add(E e) | 均摊 O(1) | 尾部添加(扩容时 O(n)) |
add(int index, E e) | O(n) | 指定位置插入,需移动元素 |
get(int index) | O(1) | 随机访问,直接通过数组下标 |
set(int index, E e) | O(1) | 替换指定位置元素 |
remove(int index) | O(n) | 删除指定位置,需移动元素 |
remove(Object o) | O(n) | 先线性查找,再移动元素 |
contains(Object o) | O(n) | 线性遍历查找 |
indexOf(Object o) | O(n) | 线性遍历查找 |
size() | O(1) | 返回 size 字段 |
isEmpty() | O(1) | 判断 size == 0 |
核心原理:扩容机制源码分析
默认容量与懒加载
在 JDK 8 中,new ArrayList<>() 创建的是一个空数组,容量在第一次调用 add() 时才分配:
// JDK 8 ArrayList 构造方法
public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA; // 空数组 {}
}
// DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {} (共享的空数组实例)
// 区别于 EMPTY_ELEMENTDATA(明确传入容量 0 时的空数组)
设计意图:延迟分配内存,避免大量空 ArrayList 浪费堆空间。这在创建大量可能不使用的 List 时尤为重要(如方法返回值预留)。
1.5 倍扩容源码
// ArrayList.add(E e) 源码流程(JDK 8)
public boolean add(E e) {
ensureCapacityInternal(size + 1); // ① 确保容量足够
elementData[size++] = e; // ② 放入元素
return true;
}
private void ensureCapacityInternal(int minCapacity) {
ensureExplicitCapacity(
calculateCapacity(elementData, minCapacity)
);
}
// 如果是默认空数组,取 max(10, minCapacity)
private static int calculateCapacity(Object[] elementData, int minCapacity) {
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
return Math.max(DEFAULT_CAPACITY, minCapacity); // DEFAULT_CAPACITY = 10
}
return minCapacity;
}
private void ensureExplicitCapacity(int minCapacity) {
modCount++; // 结构性修改计数(fail-fast 机制)
if (minCapacity - elementData.length > 0)
grow(minCapacity); // 真正扩容
}
// 核心扩容方法
private void grow(int minCapacity) {
int oldCapacity = elementData.length;
// ★ 1.5 倍扩容:oldCapacity + oldCapacity / 2 = oldCapacity * 1.5
int newCapacity = oldCapacity + (oldCapacity >> 1);
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity; // 1.5 倍仍不够,直接用所需容量
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity); // 超大容量处理
elementData = Arrays.copyOf(elementData, newCapacity); // 数组拷贝
}
扩容过程图解
初始状态(new ArrayList<>()):
elementData = {} (DEFAULTCAPACITY_EMPTY_ELEMENTDATA, 长度 0)
size = 0
第1次 add():
→ minCapacity = max(10, 1) = 10(首次使用默认容量)
→ grow(10): oldCapacity=0 → newCapacity=0 (0 >> 1 = 0) → 使用 minCapacity=10
→ elementData = new Object[10] (10 个 null)
→ elementData[0] = "A", size = 1
第11次 add():
→ minCapacity = 12
→ grow(12): oldCapacity=10 → newCapacity=15 (10 + 5)
→ elementData = Arrays.copyOf(旧数组, 15)
→ elementData[10] = "K", size = 11
第16次 add():
→ minCapacity = 17
→ grow(17): oldCapacity=15 → newCapacity=22 (15 + 7)
→ elementData = Arrays.copyOf(旧数组, 22)
| 扩容次数 | 扩容前容量 | 扩容后容量 | 扩容时机(第几次 add) |
|---|---|---|---|
| 1 | 0 | 10 | 第 1 次 |
| 2 | 10 | 15 | 第 11 次 |
| 3 | 15 | 22 | 第 16 次 |
| 4 | 22 | 33 | 第 23 次 |
| 5 | 33 | 49 | 第 34 次 |
| ... | ... | ... | ... |
为什么是 1.5 倍?
| 方案 | 扩容倍数 | √ 优点 | ✗ 缺点 |
|---|---|---|---|
| ArrayList | 1.5 倍 | 增长速度适中,空间浪费可控 | 扩容频率较高 |
| Vector | 2 倍 | 扩容次数少 | 空间浪费大(最高可能浪费 50%) |
| HashMap | 2 倍 | 配合 2 的幂,位运算优化取模 | 同 Vector |
| StringBuilder | 2 倍 + 2 | 小容量时增长更快 | 大容量时浪费 |
1.5 倍是空间和时间之间的工程权衡:每次扩容后,之前所有扩容的总拷贝开销与最终容量成线性关系,而非指数关系。
完整示例
示例一:飞翔科技员工列表管理
import java.util.*;
// 场景:飞翔科技技术部小崔实现员工列表的基础 CRUD
public class EmployeeArrayList {
public static void main(String[] args) {
ArrayList<String> employees = new ArrayList<>();
// 1. 添加员工(尾部添加,均摊 O(1))
employees.add("大翔(CEO)");
employees.add("白歌(架构师)");
employees.add("小崔(后端开发)");
employees.add("孔蓝(测试工程师)");
System.out.println("初始员工列表: " + employees);
// 2. 指定位置插入(O(n),需要移动后续元素)
employees.add(1, "韩信(技术总监 - 新入职)");
System.out.println("插入韩信后: " + employees);
// 3. 随机访问(O(1))
System.out.println("第 3 位员工: " + employees.get(2));
// 4. 替换元素(O(1))
employees.set(3, "小崔(高级后端开发 - 升职)");
System.out.println("升职后: " + employees);
// 5. 按索引删除(O(n))
employees.remove(1); // 删除韩信
System.out.println("删除韩信后: " + employees);
// 6. 按对象删除(O(n),先线性查找再移动)
employees.remove("孔蓝(测试工程师)");
System.out.println("删除孔蓝后: " + employees);
// 7. 遍历方式对比
System.out.println("\n=== 遍历方式对比 ===");
// 方式一:for-i(ArrayList 最佳,利用 O(1) 随机访问)
long start = System.nanoTime();
for (int i = 0; i < employees.size(); i++) {
employees.get(i);
}
System.out.println("for-i 耗时: " + (System.nanoTime() - start) + "ns");
// 方式二:增强 for
start = System.nanoTime();
for (String emp : employees) {
// 不操作,仅遍历
}
System.out.println("增强 for 耗时: " + (System.nanoTime() - start) + "ns");
// 方式三:forEach Lambda(Java 8)
System.out.println("\n--- forEach 打印 ---");
employees.forEach(emp -> System.out.println(" " + emp));
}
}
运行输出:
初始员工列表: [大翔(CEO), 白歌(架构师), 小崔(后端开发), 孔蓝(测试工程师)]
插入韩信后: [大翔(CEO), 韩信(技术总监 - 新入职), 白歌(架构师), 小崔(后端开发), 孔蓝(测试工程师)]
第 3 位员工: 白歌(架构师)
升职后: [大翔(CEO), 韩信(技术总监 - 新入职), 白歌(架构师), 小崔(高级后端开发 - 升职), 孔蓝(测试工程师)]
删除韩信后: [大翔(CEO), 白歌(架构师), 小崔(高级后端开发 - 升职), 孔蓝(测试工程师)]
删除孔蓝后: [大翔(CEO), 白歌(架构师), 小崔(高级后端开发 - 升职)]
=== 遍历方式对比 ===
for-i 耗时: 8500ns
增强 for 耗时: 9200ns
--- forEach 打印 ---
大翔(CEO)
白歌(架构师)
小崔(高级后端开发 - 升职)
示例二:扩容过程演示与性能优化
import java.util.*;
public class ArrayListExpansionDemo {
public static void main(String[] args) {
// === 对比:不预分配 vs 预分配容量 ===
// 方案一:不预分配(多次扩容)
long start = System.currentTimeMillis();
ArrayList<Integer> list1 = new ArrayList<>();
for (int i = 0; i < 100000; i++) {
list1.add(i);
}
long time1 = System.currentTimeMillis() - start;
System.out.println("不预分配容量: " + time1 + "ms");
// 方案二:预分配容量(零扩容)
start = System.currentTimeMillis();
ArrayList<Integer> list2 = new ArrayList<>(100000);
for (int i = 0; i < 100000; i++) {
list2.add(i);
}
long time2 = System.currentTimeMillis() - start;
System.out.println("预分配容量 : " + time2 + "ms");
System.out.println("性能提升: " + String.format("%.1f", (double)(time1 - time2) / time1 * 100) + "%");
// === 扩容轨迹追踪 ===
ArrayList<Integer> trace = new ArrayList<>();
int lastCapacity = getCapacity(trace);
System.out.println("\n=== 扩容轨迹 ===");
System.out.println("初始容量: " + lastCapacity);
for (int i = 0; i < 100; i++) {
trace.add(i);
int currentCapacity = getCapacity(trace);
if (currentCapacity != lastCapacity) {
System.out.println("第 " + (i + 1) + " 次 add 触发扩容: "
+ lastCapacity + " → " + currentCapacity);
lastCapacity = currentCapacity;
}
}
}
// 通过反射获取 ArrayList 内部数组长度
private static int getCapacity(ArrayList<?> list) {
try {
java.lang.reflect.Field field = ArrayList.class.getDeclaredField("elementData");
field.setAccessible(true);
return ((Object[]) field.get(list)).length;
} catch (Exception e) {
return -1;
}
}
}
运行输出:
不预分配容量: 8ms
预分配容量 : 3ms
性能提升: 62.5%
=== 扩容轨迹 ===
初始容量: 0
第 1 次 add 触发扩容: 0 → 10
第 11 次 add 触发扩容: 10 → 15
第 16 次 add 触发扩容: 15 → 22
第 23 次 add 触发扩容: 22 → 33
第 34 次 add 触发扩容: 33 → 49
第 50 次 add 触发扩容: 49 → 73
第 74 次 add 触发扩容: 73 → 109
易错场景
反例一:for-i 遍历时删除元素导致数据错乱
小崔在清理离职员工时踩了经典坑:
// ❌ 错误:使用 for-i 遍历 ArrayList 并删除元素
ArrayList<String> employees = new ArrayList<>(Arrays.asList(
"大翔", "白歌", "小崔(离职)", "孔蓝", "韩信(离职)"
));
for (int i = 0; i < employees.size(); i++) {
if (employees.get(i).contains("离职")) {
employees.remove(i); // 删除后,后续元素前移,但 i 继续++
}
}
System.out.println("清理后: " + employees);
// 实际输出: [大翔, 白歌, 孔蓝, 韩信(离职)] ← 韩信被漏掉了!
原理分析:
初始: [大翔, 白歌, 小崔(离职), 孔蓝, 韩信(离职)]
i=0: 大翔 → 不删
i=1: 白歌 → 不删
i=2: 小崔(离职)→ 删除!
数组变为: [大翔, 白歌, 孔蓝, 韩信(离职)]
孔蓝移到索引2,韩信移到索引3
i=3: 韩信(离职)→ 但 i++ 后直接跳到索引3
孔蓝在索引2 被跳过了!
纠正方案:
// ✅ 方案一:倒序遍历
for (int i = employees.size() - 1; i >= 0; i--) {
if (employees.get(i).contains("离职")) {
employees.remove(i);
}
}
// ✅ 方案二:使用 Iterator
Iterator<String> it = employees.iterator();
while (it.hasNext()) {
if (it.next().contains("离职")) {
it.remove();
}
}
// ✅ 方案三:Java 8 removeIf(最简洁)
employees.removeIf(emp -> emp.contains("离职"));
反例二:多线程环境下的 ArrayList 数据不一致
孔蓝在压测时发现了多线程操作 ArrayList 的严重问题:
// ❌ 错误:多线程同时 add,导致数据丢失甚至 ArrayIndexOutOfBoundsException
ArrayList<Integer> list = new ArrayList<>();
// 10 个线程各添加 1000 个元素
for (int t = 0; t < 10; t++) {
new Thread(() -> {
for (int i = 0; i < 1000; i++) {
list.add(i); // 非原子操作:elementData[size++] = e
}
}).start();
}
Thread.sleep(2000);
System.out.println("期望: 10000, 实际: " + list.size()); // 很可能 < 10000
原因:elementData[size++] = e 不是原子操作,分为三步:
- 读取 size 值
- 将 e 放入 elementData[size]
- size++
多线程交错执行会导致元素覆盖和数据丢失。严重时 size 超过数组长度抛出 ArrayIndexOutOfBoundsException。
纠正:
// ✅ 方案一:使用 Collections.synchronizedList 包装
List<Integer> syncList = Collections.synchronizedList(new ArrayList<>());
// ✅ 方案二:使用 CopyOnWriteArrayList(读多写少场景)
List<Integer> cowList = new CopyOnWriteArrayList<>();
// ✅ 方案三:使用 Vector(遗留方案,不推荐)
Vector<Integer> vec = new Vector<>();
面试考点
Q1:ArrayList 扩容机制是怎样的?为什么是 1.5 倍?
扩容发生在
add()时容量不足:newCapacity = oldCapacity + (oldCapacity >> 1)即 1.5 倍。1.5 倍是空间和时间的工程权衡——相比 Vector 的 2 倍,1.5 倍空间浪费更小(最大浪费约 33% vs 50%);相比固定增量,1.5 倍能保证均摊 O(1) 的添加时间复杂度。扩容的本质是Arrays.copyOf(),底层调用System.arraycopy()进行内存拷贝。
Q2:ArrayList 的 add(E e) 方法时间复杂度是多少?
均摊 O(1)。大多数时候直接在尾部写入(O(1)),只在需要扩容时才触发数组拷贝(O(n))。由于扩容间隔越来越长(第 1、11、16、23...次 add),将总拷贝开销平摊到每次操作上,每元素平均拷贝次数趋近于常数。这正是"均摊分析"(Amortized Analysis)的经典案例。
Q3:为什么 ArrayList 实现 RandomAccess 接口?
RandomAccess是标记接口(无方法),它的存在让上层算法能根据集合类型选择最优策略。例如Collections.binarySearch()会通过instanceof RandomAccess判断:如果是 true 则使用get(index)索引访问(O(1)),否则使用迭代器访问(避免 LinkedList 的 O(n) get)。
Q4:ArrayList 和 Vector 的区别?
① ArrayList 线程不安全,Vector 通过
synchronized方法实现线程安全;② ArrayList 扩容 1.5 倍,Vector 扩容 2 倍(可配置capacityIncrement);③ ArrayList 是 JDK 1.2 集合框架成员,Vector 是 JDK 1.0 遗留类;④ Vector 的迭代器也是 fail-fast,但Enumeration不是;⑤ 现代开发中 ArrayList 是默认选择,需要线程安全时用Collections.synchronizedList()或CopyOnWriteArrayList。
Q5:如何在遍历 ArrayList 时安全删除元素?
① 使用 Iterator 的
remove()(最通用的方式);② 使用 Java 8 的removeIf(Predicate)(Lambda 写法简洁);③ 使用倒序 for-i 遍历(利用删除后后续元素前移但不影响已遍历的前半段);④ 使用ListIterator的remove()。错误方式:增强 for 循环中调用集合的remove()(会抛出 ConcurrentModificationException),正序 for-i 删除(会漏掉元素)。
Q6:subList 方法有什么陷阱?
subList(from, to)返回的是原列表的视图(view),而非独立副本。对 subList 的修改会影响原列表,反之亦然。更危险的是,如果在获取 subList 后对原列表进行结构性修改(增删操作),再访问 subList 会抛出ConcurrentModificationException。如果需要独立子列表,应该new ArrayList<>(originalList.subList(from, to))。