ArrayList全面详解
ArrayList是基于动态数组的集合。其核心特性是:随机访问快(O(1)),中间位置增删慢(O(n)),并且非线程安全。
我们来对 Java 中的 ArrayList 进行一个全面而详细的解析。
这篇详解将涵盖以下内容:
ArrayList是什么? (概念与定位)- 内部实现原理 (核心:动态数组与扩容)
- 主要特性 (优点与缺点)
- 常用方法详解 (增删改查遍历)
ArrayListvsLinkedList(经典对比)- 多线程环境下的使用 (线程安全问题)
- 常见陷阱与最佳实践 (使用注意事项)
- 总结
1. ArrayList 是什么?
ArrayList 是 Java 集合框架(Java Collections Framework)中的一个核心类,它位于 java.util 包下。简单来说,ArrayList 是一个可以动态调整大小的数组。
它实现了 List 接口,这意味着:
- 有序集合:元素存储的顺序和添加的顺序是一致的。
- 允许重复元素:可以存储多个相同的元素。
- 允许
null值:可以存储null。
ArrayList 在功能上与普通的 Java 数组 (String[], int[]) 相似,但它解决了数组长度不可变的痛点。你可以在创建 ArrayList 后,随意地向其中添加或删除元素,而不用担心容量问题。
2. 内部实现原理
要真正理解 ArrayList,就必须了解它的内部是如何工作的。
核心数据结构
ArrayList 的底层实际上就是一个 Object 类型的数组。在 ArrayList 类的源码中,你会看到一个关键的成员变量:
transient Object[] elementData; // ArrayList存储元素的数组
transient 关键字表示这个字段不会被序列化。
动态扩容(The Magic)
ArrayList 之所以能“动态”调整大小,关键就在于它的扩容(grow)机制。
初始容量:当你创建一个空的
ArrayList(new ArrayList<>()) 时,它内部的elementData数组在 JDK 1.7 之后版本中是空的 ({}),初始容量为 0。只有在第一次添加元素时,才会分配一个默认的初始容量,通常是 10。- 你也可以在创建时指定初始容量:
new ArrayList<>(20);这可以避免初期的几次扩容,提升性能。
- 你也可以在创建时指定初始容量:
扩容时机:当你调用
add()方法添加元素时,ArrayList会检查当前数组容量是否足够。如果数组已满(size == elementData.length),就会触发扩容。扩容逻辑:扩容的过程大致如下:
- 计算新容量:新的容量通常是旧容量的 1.5 倍(
newCapacity = oldCapacity + (oldCapacity >> 1),位右移一位相当于除以2)。 - 创建新数组:根据新容量创建一个更大的新数组。
- 复制元素:使用
Arrays.copyOf()方法将旧数组中的所有元素复制到新数组中。 - 引用切换:将
elementData的引用指向这个新创建的数组。
- 计算新容量:新的容量通常是旧容量的 1.5 倍(
扩容是一个成本较高的操作,因为它涉及到内存分配和数组的整体复制。因此,如果你能预估到要存储的元素数量,最好在创建 ArrayList 时就指定一个合适的初始容量,以减少不必要的扩容次数。
3. 主要特性
| 特性 | 描述 |
|---|---|
| 查询/访问效率高 | 由于底层是数组,它支持随机访问(Random Access)。通过索引 get(index) 直接访问元素的时间复杂度是 O(1),非常快。 |
| 增删效率(末尾)高 | 在列表末尾添加或删除元素(add(element), remove(size-1))时,通常不涉及元素移动,时间复杂度接近 O(1)(不考虑扩容)。 |
| 增删效率(中间)低 | 在列表的中间或开头插入或删除元素时,需要移动该位置之后的所有元素。例如,在索引 i 处插入一个元素,需要将 i 以及之后的所有元素向后移动一位。这个操作的时间复杂度是 O(n),其中 n 是需要移动的元素数量。 |
| 非线程安全 | ArrayList 不是线程安全的。如果在多线程环境下对同一个 ArrayList 实例进行修改操作,可能会导致数据不一致或抛出 ConcurrentModificationException 异常。 |
| 内存连续 | 底层的数组需要一块连续的内存空间。 |
4. 常用方法详解
以下是 ArrayList 最核心和常用的方法,配有代码示例。
// 1. 创建 ArrayList
List<String> fruits = new ArrayList<>(); // 推荐使用接口类型 List 声明
// 2. 添加元素 (Add)
fruits.add("Apple"); // 在末尾添加
fruits.add("Banana");
fruits.add("Cherry");
fruits.add(1, "Orange"); // 在索引 1 的位置插入 "Orange"
// 当前列表: ["Apple", "Orange", "Banana", "Cherry"]
// 3. 获取元素 (Get/Read)
String firstFruit = fruits.get(0); // "Apple"
System.out.println("第一个水果是: " + firstFruit);
// 4. 修改元素 (Set/Update)
fruits.set(2, "Grape"); // 将索引 2 的元素从 "Banana" 修改为 "Grape"
// 当前列表: ["Apple", "Orange", "Grape", "Cherry"]
// 5. 删除元素 (Remove)
fruits.remove("Apple"); // 按内容删除第一个匹配的元素
fruits.remove(2); // 按索引删除元素 ("Cherry")
// 当前列表: ["Orange", "Grape"]
// 6. 获取列表信息
int size = fruits.size(); // 获取元素数量,结果是 2
boolean isEmpty = fruits.isEmpty(); // 判断是否为空,结果是 false
boolean hasGrape = fruits.contains("Grape"); // 判断是否包含某个元素,结果是 true
// 7. 遍历 (Iterate) - 推荐方式
System.out.println("--- 使用 for-each 遍历 ---");
for (String fruit : fruits) {
System.out.println(fruit);
}
System.out.println("--- 使用 Iterator 遍历 ---");
Iterator<String> iterator = fruits.iterator();
while (iterator.hasNext()) {
String fruit = iterator.next();
System.out.println(fruit);
}
System.out.println("--- 使用 forEach 方法 (Java 8+) ---");
fruits.forEach(fruit -> System.out.println(fruit));
// 或者使用方法引用
// fruits.forEach(System.out::println);
// 8. 清空列表
fruits.clear();
System.out.println("清空后的大小: " + fruits.size()); // 0
5. ArrayList vs LinkedList
这是 Java 面试中的高频问题,也是选择正确数据结构的关键。
| 特性 | ArrayList |
LinkedList |
|---|---|---|
| 底层结构 | 动态数组 | 双向链表 (每个节点包含数据、前驱指针、后继指针) |
| 随机访问 | 快 (O(1))。通过索引直接定位。 | 慢 (O(n))。需要从头或尾开始遍历查找。 |
| 插入/删除 (中间) | 慢 (O(n))。需要移动后续元素。 | 快 (O(1))。只需修改前后节点的指针。(前提是已经定位到该节点) |
| 内存开销 | 较小。只需存储元素本身。 | 较大。每个元素都需要额外的空间存储前后节点的引用。 |
| 内存要求 | 需要连续的内存空间。 | 不需要连续的内存空间。 |
选择指南:
- 优先选择
ArrayList:在绝大多数情况下,ArrayList的性能都更好,特别是当你的主要操作是遍历和随机访问时。它是默认和最常用的List实现。 - 选择
LinkedList:当你的应用场景需要频繁地在列表的开头、中间进行插入和删除操作时,LinkedList才可能更具优势。例如,实现一个队列 (Queue)。
6. 多线程环境下的使用
如前所述,ArrayList 是非线程安全的。如果多个线程同时修改它,程序可能会崩溃。
解决方案:
Collections.synchronizedList()javaList<String> syncList = Collections.synchronizedList(new ArrayList<>());这个工厂方法会返回一个线程安全的
List包装器。它通过在每个公共方法(如add,get,remove)上添加synchronized关键字来实现同步。- 缺点:性能较差,因为它对所有操作都加了锁,在高并发下会导致激烈的锁竞争。同时,对于复合操作(如先检查后添加)和迭代,仍需要手动加锁。
CopyOnWriteArrayListjavaList<String> cowList = new CopyOnWriteArrayList<>();这是
java.util.concurrent包下的一个线程安全类。- 工作原理:写时复制。当进行修改操作(
add,set,remove)时,它不会直接修改现有数组,而是复制一份新数组,在新数组上进行修改,最后再将引用指向新数组。 - 优点:读操作完全无锁,性能极高。适合读多写少的并发场景。
- 缺点:写操作成本很高(因为要复制整个数组),并且无法保证数据的实时一致性(迭代器看到的是修改前的快照)。
- 工作原理:写时复制。当进行修改操作(
7. 常见陷阱与最佳实践
在
for-each循环中删除元素
错误的示例:javafor (String fruit : fruits) { if ("Apple".equals(fruit)) { fruits.remove(fruit); // 会抛出 ConcurrentModificationException } }原因:
for-each循环内部是使用Iterator实现的。当你在循环外部通过list.remove()修改集合结构时,Iterator内部维护的一个状态(modCount)会与ArrayList的状态不一致,导致异常。正确的删除方式是使用
Iterator自己的remove()方法:javaIterator<String> iterator = fruits.iterator(); while (iterator.hasNext()) { String fruit = iterator.next(); if ("Apple".equals(fruit)) { iterator.remove(); // 正确! } }或者使用 Java 8 的
removeIf方法,更简洁:javafruits.removeIf(fruit -> "Apple".equals(fruit));subList()返回的是视图而非副本list.subList(from, to)返回的是原始列表的一个视图。对这个子列表的任何修改都会反映到原始列表中,反之亦然。对原始列表的结构性修改(增删元素)会导致子列表的后续操作失败。javaList<String> sub = fruits.subList(0, 2); sub.set(0, "Mango"); // 原 fruits 列表的第一个元素也会变成 "Mango" fruits.add("Kiwi"); // 对原列表结构性修改 // sub.get(0); // 此时再操作 sub 会抛出 ConcurrentModificationException如果需要一个独立的副本,应该这样做:
List<String> independentSubList = new ArrayList<>(fruits.subList(0, 2));预设容量
如前所述,如果能预估元素数量,请使用new ArrayList<>(estimatedSize)来创建,以提高性能。
8. 总结
ArrayList 是 Java 中最基础、最重要的数据结构之一。
- 核心是动态数组,通过扩容机制实现大小可变。
- 查询快 (O(1)),中间增删慢 (O(n))。
- 是非线程安全的。
- 在绝大多数场景下都是
List接口的最佳选择。
掌握了 ArrayList 的原理、特性和使用方法,你就能在日常开发和面试中更加从容自信。