ArrayList 基础概述
ArrayList 是Java集合框架中最为常用的数据结构之一,它实现了 List 接口,底层基于动态数组实现。与传统的固定长度数组不同,ArrayList 的大小可以根据元素的添加和删除操作动态增长或缩小。在面试中,面试官通常会首先询问 ArrayList 的基本特性,例如它允许存储 null 值、保持插入顺序(非排序)、以及非线程安全等关键属性。
理解 ArrayList 的底层原理,对于编写高效、健壮的Java代码至关重要。它不仅是日常开发中最常用的集合类,也是考察候选人对内存管理、时间复杂度和空间复杂度理解程度的绝佳切入点。
⚡ 核心特性
基于动态数组,支持随机访问(get方法时间复杂度O(1))。允许重复元素和null值。保持插入顺序。
⚙️ 继承体系
继承自 AbstractList,实现 List, RandomAccess, Cloneable, Serializable 接口。
📝 初始化
默认容量为10。可以通过构造函数指定初始容量,避免频繁扩容带来的性能损耗。
ArrayList 底层数据结构
ArrayList 的底层核心是一个 Object[] 数组,在源码中被称为 elementData。这个数组充当了 ArrayList 的缓冲区,用于存储添加的元素。然而,这个数组的大小并不是固定的,而是随着元素的添加自动增长的。
1. 数组与指针
在Java中,数组在内存中是连续分配的。这种连续分配的特性使得 ArrayList 能够通过索引快速定位元素,从而实现 O(1) 的时间复杂度进行随机访问。然而,这也意味着在数组中间插入或删除元素时,需要移动后续所有元素,导致 O(n) 的时间复杂度。
2. 源码解析
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable
{
// 默认初始容量大小
private static final int DEFAULT_CAPACITY = 10;
// 共享的空数组实例,用于默认构造函数
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
// 实际的存储数组,transient表示序列化时不序列化此字段
transient Object[] elementData;
// 数组的大小
private int size;
// ... 其他代码
}
注意 elementData 被标记为 transient,这是因为 ArrayList 自定义了 writeObject 和 readObject 方法来进行序列化,只序列化实际存储的元素,而不是整个数组容量,从而节省内存空间。
ArrayList 扩容机制详解
扩容是 ArrayList 面试中的高频考点。理解扩容机制有助于优化程序性能,避免频繁的内存分配和数据拷贝。
默认扩容策略
当使用无参构造函数创建 ArrayList 时,elementData 被初始化为空数组 {}。第一次添加元素时,才会分配默认容量 10。此后,每次扩容时,新容量为旧容量的 1.5倍(具体计算为 oldCapacity + (oldCapacity >> 1))。
扩容过程涉及 Arrays.copyOf() 方法,这会创建一个新的数组,并将旧数组的元素复制到新数组中。这是一个相对昂贵的操作,因此在预期数据量较大时,建议指定初始容量。
指定初始容量
如果在使用 ArrayList 前能预估元素数量,应通过构造函数 new ArrayList<>(initialCapacity) 指定初始容量。这样可以避免默认的多次扩容操作,显著提升性能。
// 推荐写法
ArrayList<String> list = new ArrayList<>(100);
JDK 8 的优化
在 JDK 8 中,对 ArrayList 的初始化进行了延迟加载优化。无参构造函数不再立即分配 10 个容量的数组,而是分配一个空数组。只有当第一次添加元素时,才会分配默认大小 10 的数组。这一优化减少了内存浪费,特别是对于只创建但不添加元素的 ArrayList 实例。
ArrayList 的线程安全性
ArrayList 是非线程安全的。这意味着如果多个线程同时修改一个 ArrayList 实例,可能会导致数据不一致、ConcurrentModificationException 或其他未定义行为。
为什么非线程安全?
在 add 方法中,首先检查是否需要扩容,然后增加 size,最后将元素放入数组。这些步骤不是原子的。如果两个线程同时执行 add,可能会发生以下情况:
- 线程A检查容量,发现足够。
- 线程B检查容量,发现足够。
- 线程A添加元素,
size加 1。 - 线程B添加元素,
size加 1,但可能覆盖了线程A刚写入的数据,或者导致数组越界(如果恰好触发扩容)。
解决方案
🔒 Collections.synchronizedList
使用 Collections.synchronizedList(new ArrayList<>()) 包装 ArrayList。该方法返回一个线程安全的包装类,所有方法调用都加锁。缺点是性能较低,因为每次操作都加锁。
🚀 CopyOnWriteArrayList
JUC 包下的 CopyOnWriteArrayList 是线程安全的。它采用“写入时复制”策略,每次修改都创建新数组。适用于读多写少的场景,写性能较差。
⚙️ Vector
古老的 Vector 类也是线程安全的,所有方法都使用 synchronized 关键字。但由于性能问题,现代Java开发中已很少使用。
ArrayList vs LinkedList
在面试中,经常需要对比 ArrayList 和 LinkedList。两者都实现了 List 接口,但底层结构和性能特征截然不同。
| 特性 | ArrayList | LinkedList |
|---|---|---|
| 底层结构 | 动态数组 | 双向链表 |
| 随机访问 (get) | O(1),通过索引直接定位 | O(n),需要遍历链表 |
| 插入/删除 (非末尾) | O(n),需要移动后续元素 | O(1),仅修改指针(前提是已找到节点) |
| 内存占用 | 较低,仅存储数据 | 较高,每个节点需存储前后指针 |
| 适用场景 | 频繁查询,少量增删 | 频繁增删,少量查询 |
总结:ArrayList 更适合用于需要频繁随机访问的场景,而 LinkedList 更适合用于频繁在列表头部或中间插入/删除的场景。但在实际开发中,由于 ArrayList 的缓存友好性(局部性原理),其性能往往优于 LinkedList,除非有特定的增删需求。
ArrayList 面试真题高频考点
以下是网友们最关心的 ArrayList 相关面试题及其深度解答,涵盖了从基础到源码级别的各个层面。
A: ArrayList 的默认容量是 10。选择10作为默认值是一个折中的考量:既不会占用过多内存(如100),又能满足大多数小规模集合的需求,减少初始扩容次数。在JDK 8中,为了延迟分配,实际在第一次添加元素时才分配10个容量的数组。
A: 当元素数量超过当前容量时,ArrayList 会创建一个新数组,大小为原容量的 1.5倍。然后使用 System.arraycopy() 或 Arrays.copyOf() 将旧数组元素复制到新数组。最后,elementData 引用指向新数组。这个过程涉及内存分配和数据拷贝,因此应尽量避免频繁扩容。
A: 使用 contains(Object o) 方法。该方法底层调用 equals() 方法遍历数组。时间复杂度为 O(n)。如果需要频繁查找,建议将 ArrayList 转换为 HashSet,查找时间复杂度降为 O(1)。
A: ArrayList 不支持并发修改,直接遍历删除会抛出 ConcurrentModificationException。正确的方法有:
- 使用
Iterator.remove()方法。 - 使用 Java 8 的
removeIf()方法。 - 倒序遍历(从后往前),避免索引偏移问题。
A: 数组是固定长度的,一旦创建大小不可变,且只能存储同类型元素(或Object)。ArrayList 是动态长度的,可以自动扩容,存储泛型类型对象。此外,ArrayList 提供了丰富的API(如 add, remove, contains 等),而数组需要手动管理索引。