1. 引言
顺序表(Sequential List)是一种最基本、最常用的线性数据结构,它的核心思想是用一段物理地址连续的存储单元依次存储线性表的数据元素。在 Java 中,顺序表的典型实现就是ArrayList,它是对数组的封装与增强,实现了变长存储、动态扩容等特性。
本文将从基础概念入手,深入ArrayList的源码实现,涵盖核心操作方法、性能优化、高级话题以及常见面试陷阱,带你完成从“会用”到“精通”的进阶。无论你是 Java 初学者,还是准备面试的开发者,都能在这篇教学里收获系统化的知识体系。
2. 顺序表基础概念
顺序表是线性表的顺序存储结构,具有以下特点:
- 逻辑相邻,物理也相邻:所有数据元素在内存中连续存放,支持通过下标进行随机访问。
- 存储密度高:只需存储数据本身,不像链表需要额外的指针字段,空间利用率更高。
- 插入/删除效率较低:在非尾部位置插入或删除元素时,需要移动大量元素,平均时间复杂度为 O(n)。
顺序表的抽象操作包括:初始化、增(add)、删(remove)、改(set)、查(get)、获取大小(size)等。在 Java 标准库中,java.util.ArrayList正是基于动态数组实现的顺序表,它允许存储任意类型的对象,并且能够根据元素的增删自动调整内部数组的大小。
3. Java中的顺序表:ArrayList
ArrayList位于java.util包下,实现了List<E>、RandomAccess、Cloneable、Serializable等接口。它具备以下能力:
- 有序存储:元素按插入顺序存放。
- 可重复:允许存储相同的元素。
- 容量可动态增长:无需手动指定大小,内部数组会根据需要自动扩容。
- 支持泛型:可以指定元素类型,避免类型转换和
ClassCastException。
基本使用示例如下:
import java.util.ArrayList; import java.util.List; public class ArrayListDemo { public static void main(String[] args) { // 创建一个空的顺序表,默认容量为10 List<String> list = new ArrayList<>(); list.add("Java"); list.add("顺序表"); list.add("实战"); System.out.println(list); // [Java, 顺序表, 实战] } }由于实现了RandomAccess接口,ArrayList支持高效的随机访问,这一点在遍历或查找时可以明显体现出来。
4. ArrayList内部实现原理
4.1 底层数组结构
ArrayList的核心是一个Object[]类型的数组,名为elementData。源码声明如下:
transient Object[] elementData; // non-private to simplify nested class access使用transient修饰是为了在序列化时通过自定义的writeObject来优化,避免序列化未使用的数组空间。
另外还有一个size字段,表示当前列表中实际存储的元素个数,它总是 ≤elementData.length。
4.2 构造方法
ArrayList提供了三个构造器:
- 无参构造器:创建一个空列表,并将
elementData初始化为一个共享的空数组实例(DEFAULTCAPACITY_EMPTY_ELEMENTDATA)。第一次添加元素时才会扩容到默认容量 10。 - 指定初始容量:
ArrayList(int initialCapacity),如果initialCapacity > 0则直接创建指定大小的数组;为 0 则使用空数组;负数则抛出异常。 - 通过集合构造:
ArrayList(Collection<? extends E> c),将传入集合转为数组并赋值给elementData,同时保证数组的实际类型为Object[]。
4.3 扩容机制
扩容是顺序表实现变长存储的核心。当调用add(E e)时,如果size == elementData.length,就会触发扩容。流程如下:
private void grow(int minCapacity) { int oldCapacity = elementData.length; // 新容量 = 旧容量 + 旧容量 >> 1,即扩容为原来的 1.5 倍 int newCapacity = oldCapacity + (oldCapacity >> 1); if (newCapacity - minCapacity < 0) { newCapacity = minCapacity; } if (newCapacity - MAX_ARRAY_SIZE > 0) { newCapacity = hugeCapacity(minCapacity); } // 复制数组到新数组 elementData = Arrays.copyOf(elementData, newCapacity); }需要注意的是,扩容操作会涉及数组拷贝,因此如果能够预估数据量,最好在初始化时就指定一个合适的容量,避免频繁扩容带来的性能开销。
5. 核心操作方法详解
本节深入分析ArrayList中最常用的增删改查操作,并结合源码理解其时间复杂度和潜在陷阱。
5.1 添加元素
尾部追加:add(E e)
- 先确保容量足够(
ensureCapacityInternal),然后将元素放入elementData[size++]。 - 时间复杂度:均摊 O(1),仅在扩容时产生 O(n) 的拷贝。
指定位置插入:add(int index, E element)
- 检查索引范围,然后调用
System.arraycopy将从 index 开始的元素整体后移一位,再赋值。 - 时间复杂度:O(n),因为需要移动后续元素。
5.2 获取元素
get(int index)直接返回elementData[index],时间复杂度 O(1)。得益于有序存储和数组下标访问,随机读取速度极快。
5.3 修改元素
set(int index, E element)先检查索引,然后将新值放入数组并返回旧值。同样为 O(1)。
5.4 删除元素
按索引删除:remove(int index)会使用System.arraycopy将 index 之后的所有元素向前移动一位,然后置空最后一个元素(方便 GC),并size--。时间复杂度 O(n)。
按对象删除:remove(Object o)内部分为null和非null两种比较,遍历找到第一个相等元素后按索引删除,时间复杂度 O(n)。
5.5 查找元素
indexOf(Object o)和contains(Object o)都是通过正向顺序遍历数组来检查相等性,时间复杂度 O(n)。
5.6 遍历方式
推荐三种遍历方式:
// 1. 普通 for 循环(随机访问快) for (int i = 0; i < list.size(); i++) { System.out.println(list.get(i)); } // 2. 增强 for 循环 for (String s : list) { System.out.println(s); } // 3. 迭代器 Iterator<String> it = list.iterator(); while (it.hasNext()) { System.out.println(it.next()); }其中,普通 for 循环最适合ArrayList,因为它可以利用get(i)的 O(1) 随机访问;增强 for 和迭代器底层也是基于Iterator模式,但内部会检查modCount防止并发修改异常,适合只读遍历。不要在增强 for 循环中直接调用list.remove(),否则会抛出ConcurrentModificationException。
6. 性能分析与最佳实践
6.1 时间复杂度总结
| 操作 | 平均时间复杂度 |
|---|---|
| add(E e) | O(1)(均摊) |
| 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) |
| indexOf / contains | O(n) |
| iterator.remove() | O(n) |
6.2 扩容性能影响
频繁扩容会导致大量的数组拷贝,尤其是在向空列表逐个添加大量元素时。建议通过new ArrayList<>(expectedSize)指定初始容量,可以有效减少扩容次数。例如:
int expectedSize = 10000; List<String> list = new ArrayList<>(expectedSize);6.3 线程安全问题
ArrayList不是线程安全的。多线程并发修改同一个ArrayList时,可能发生数据错乱、数组下标越界,甚至抛出ConcurrentModificationException。如果需要在并发环境下使用,有三种常见方案:
- 使用
Collections.synchronizedList(new ArrayList<>())包装一个同步列表。 - 使用
CopyOnWriteArrayList,适用于读多写少的场景。 - 使用显式锁(如
ReentrantLock)或synchronized块手动控制并发。
6.4 最佳实践建议
- 预估容量:已知元素数量时,务必指定初始容量。
- 尾部添加为主:尽量在末尾追加元素,减少移动开销。
- 删除时从后往前:如果需要在遍历中删除,建议从后向前遍历,避免索引错乱。
- 使用迭代器删除:在避免索引混乱的场景下,使用
iterator.remove()代替list.remove(index)。 - 减少扩容预留:可以使用
list.ensureCapacity(int minCapacity)在批量添加前一次性扩容。
7. ArrayList与LinkedList对比
ArrayList和LinkedList都是List接口的实现,但底层实现完全不同,适用场景也有显著差异。
| 特性 | ArrayList | LinkedList |
|---|---|---|
| 底层结构 | 动态数组 | 双向链表 |
| 随机访问(get) | O(1) | O(n) |
| 头部插入/删除 | O(n)(需要移动元素) | O(1) |
| 尾部插入/删除 | 均摊 O(1) | O(1)(维护尾指针) |
| 中间插入/删除 | O(n)(移动元素) | O(n)(定位节点) |
| 内存占用 | 连续空间,空间浪费在预留的容量上 | 需要额外存储前后节点指针 |
| 缓存友好性 | 高(数组连续,CPU缓存命中率高) | 低(节点随机分布在堆中) |
一般情况下,如果业务以随机读取和尾部追加为主,优先选择ArrayList;如果需要频繁在头部或中部插入/删除,可以考虑LinkedList,但也要注意链表在遍历定位时仍然是 O(n),并非所有情况下插入删除都比数组快。
8. 高级话题:自定义泛型顺序表
为了更好地理解顺序表的原理,我们可以手动实现一个泛型动态数组,模拟ArrayList的核心行为。
import java.util.Arrays; public class MyArrayList<E> { private static final int DEFAULT_CAPACITY = 10; private Object[] elementData; private int size; public MyArrayList() { elementData = new Object[DEFAULT_CAPACITY]; } public MyArrayList(int initialCapacity) { if (initialCapacity > 0) { elementData = new Object[initialCapacity]; } else if (initialCapacity == 0) { elementData = new Object[]{}; } else { throw new IllegalArgumentException("Illegal Capacity: " + initialCapacity); } } public int size() { return size; } public boolean isEmpty() { return size == 0; } private void ensureCapacity(int minCapacity) { if (minCapacity > elementData.length) { int newCapacity = elementData.length + (elementData.length >> 1); if (newCapacity < minCapacity) { newCapacity = minCapacity; } elementData = Arrays.copyOf(elementData, newCapacity); } } public boolean add(E e) { ensureCapacity(size + 1); elementData[size++] = e; return true; } public void add(int index, E e) { if (index < 0 || index > size) { throw new IndexOutOfBoundsException("Index: " + index + ", Size: " + size); } ensureCapacity(size + 1); System.arraycopy(elementData, index, elementData, index + 1, size - index); elementData[index] = e; size++; } @SuppressWarnings("unchecked") public E get(int index) { if (index < 0 || index >= size) { throw new IndexOutOfBoundsException(); } return (E) elementData[index]; } @SuppressWarnings("unchecked") public E set(int index, E e) { E oldValue = (E) elementData[index]; elementData[index] = e; return oldValue; } public E remove(int index) { if (index < 0 || index >= size) { throw new IndexOutOfBoundsException(); } @SuppressWarnings("unchecked") E oldValue = (E) elementData[index]; int numMoved = size - index - 1; if (numMoved > 0) { System.arraycopy(elementData, index + 1, elementData, index, numMoved); } elementData[--size] = null; // clear to let GC do its work return oldValue; } @Override public String toString() { StringBuilder sb = new StringBuilder("["); for (int i = 0; i < size; i++) { sb.append(elementData[i]); if (i < size - 1) sb.append(", "); } sb.append("]"); return sb.toString(); } }通过这个精简版实现,你可以更直观地理解ArrayList的容量管理、数组拷贝和泛型擦除原理。源码中的很多细节(如modCount、fast-fail 迭代器、子列表视图等)都可以在此基础上进一步展开。
9. 常见面试题与陷阱
9.1 为什么说 ArrayList 是线程不安全的?举例说明。
当多个线程同时对同一个ArrayList进行增删操作时,elementData和size的变化可能交叉,导致数据覆盖、null元素出现,甚至数组下标越界。例如,一个线程正在执行add扩容,另一个线程正在读取,可能读到旧数组的部分数据。
9.2 Array 和 ArrayList 的区别?
- 数组是固定长度的,
ArrayList可以动态扩容。 - 数组可以存储基本数据类型,
ArrayList只能存储对象(但可以借助封装类)。 - 数组没有提供丰富的 API,
ArrayList提供了大量便捷方法。
9.3 ArrayList 的扩容为何是 1.5 倍?
这是一种经验值:1.5 倍既避免了频繁扩容,又不会造成过多的空间浪费。如果倍率太大(如2倍),可能造成大量内存闲置;如果倍率太小,扩容次数会显著增加。
9.4 如何在遍历中安全删除元素?
必须使用iterator.remove()或在普通 for 循环中从后向前删除。绝对不能使用for-each直接调用list.remove(),它会触发ConcurrentModificationException。
9.5 subList 的陷阱
list.subList(fromIndex, toIndex)返回的是视图,对子列表的修改会反映到原列表,反之亦然。另外,对原列表进行结构性修改后,子列表将会失效并抛出异常。
10. 总结与学习资源
本文系统梳理了 Java 顺序表的核心知识,从基础概念、ArrayList源码实现到性能优化和面试高频考点,构建了一条完整的进阶路径。掌握这些内容后,你将能够在日常开发中合理选用和优化顺序表,也能从容应对相关面试问题。
推荐继续深入学习:
- 阅读 JDK 源码中的
ArrayList、AbstractList和List接口注释。 - 理解
modCount与ConcurrentModificationException的实现机制。 - 研究
CopyOnWriteArrayList在并发场景下的写入时复制策略。 - 动手实现一个支持迭代器的动态泛型数组,并添加单元测试。
顺序表是数据结构和 Java 集合框架的基石,掌握它,你就离“精通 Java”更近了一步。