ArrayList
ArrayList 用连续的引用数组保存元素,支持按下标读取和动态增删。本文先区分元素数量与数组容量,再解释随机访问、扩容和中间搬移的成本。最后说明子列表共享数据带来的修改与内存保留问题。实现细节以 OpenJDK 21 为准。
1. 面试核心
先记住这几条,再读正文- 按下标访问。
ArrayList的get、set按下标访问,成本为 O(1)。 - 扩容策略。 OpenJDK 21 的
ArrayList扩容时首选增加旧容量的一半,并满足本次操作所需的最小容量。卡 · size 与 capacity - 摊销与单次成本。
ArrayList尾部追加的摊销成本为 O(1),触发扩容时的单次复制成本为 O(n)。卡 · size 与 capacity - 中间增删。
ArrayList在中间插入和删除元素时需要搬移后续引用,最坏成本为 O(n)。卡 · 随机访问 - 子列表视图。
subList与原列表共享数据,复制子列表可以隔离后续的结构变化。卡 · 子列表的内存保留
2. size 与 capacity
创建列表时,要区分元素数量和数组容量。size 是已经放入列表的元素数量,capacity 是内部数组的长度。下面的代码只预留存储空间:
List<String> values = new ArrayList<>(100);
System.out.println(values.size()); // 0
构造参数 100 预留 100 个引用槽,size 仍是 0。此时调用 get(0) 会抛出 IndexOutOfBoundsException。
数组中连续排列的是对象引用,也就是用于找到对象的值。元素对象位于各自的堆位置,所以引用连续并不表示对象本身也连续。
3. 随机访问
读取第 i 个元素时,ArrayList 先检查 0 <= i < size,再读取内部数组对应位置:
User user = users.get(i);
下标可以直接定位数组槽,所以 get 和 set 的成本都是 O(1)。顺序遍历会连续读取相邻引用,有利于 CPU 缓存和预取。缓存保存近期访问的数据,预取则提前加载即将使用的数据。相比沿链表访问分散的节点,这种布局减少了跳转带来的开销。
复杂度描述的是操作成本随数据量增长的趋势。即使同为 O(n),连续复制数组与逐个访问链表节点的实际耗时也会不同。因此,比较性能时还要固定数据规模和访问方式。
4. 尾部追加与扩容
只要内部数组还有空位,尾部 add 就把引用写到 elementData[size],再增加 size:
users.add(user);
容量不足时,OpenJDK 21 会通过分配与复制扩大存储空间:
- 根据最小需要容量计算新容量。
- 首选增加旧容量的一半,并满足最小所需容量。
- 申请新的引用数组。
- 复制现有元素引用。
- 把内部字段指向新数组。
首选增加一半,也就是按旧容量约 1.5 倍增长。一次需要加入更多元素时,新容量还要满足最小所需容量。这个比例属于 OpenJDK 的实现细节,API 保证的是尾部追加具有摊销常数时间。
4.1 摊销复杂度
摊销复杂度把一段操作的总成本分摊到每次操作上。连续追加时,多数操作只写入一个数组槽,少数操作才需要分配数组并复制引用。容量按比例增长后,前几次复制的数组都比最后一次小。累计复制量随追加数量线性增长,所以每次追加的摊销成本是 O(1)。
摊销成本描述整段追加的平均开销,单次扩容仍需 O(n) 的复制。大数组恰好在请求处理中扩容时,这次调用就会变慢。因此,延迟敏感的代码可以提前预留容量。
5. 初始容量
已知大致元素数量时,可以预留容量:
List<OrderLine> lines = new ArrayList<>(expectedLineCount);
预留容量可以减少扩容带来的三类开销:
- 分配新数组的次数减少。
- 已有元素引用被复制的次数减少。
- 新旧数组同时占用内存的峰值降低,因为旧数组还要等待垃圾回收(GC)。
预留容量应参考实际的元素数量分布。大量小列表都预留大数组时,空槽会长期占用内存,甚至超过减少扩容带来的收益。
已有列表需要追加大量元素时,可以先调用 ensureCapacity。元素减少后,trimToSize 可以将数组缩小到当前元素数量。缩容也要分配和复制,所以适合长期保留且大小已稳定的列表,频繁调用反而增加开销。
6. 中间插入与删除
在索引位置插入元素时,后面的引用需要整体向右移动:
users.add(0, user);
删除后,后续引用向左移动。最后一个旧位置置为 null,释放多余引用。对象的其他引用也消失后,GC 才能回收它。
根据是否需要查找和搬移,可以区分以下三类操作成本:
- 尾部追加直接利用空槽,摊销成本为 O(1)。
- 中间插入和删除需要搬移后续引用,最坏成本为 O(n)。
- 按值删除需要先线性查找目标,再搬移后续引用。
批量删除时,倒序按索引删除可以避免索引错位,但仍会反复搬移引用。OpenJDK 21 的 removeIf 会集中压缩保留元素,减少重复搬移。也可以一次筛选出需要保留的元素,再组成结果列表。
后续阅读:subList 视图
7. subList 视图
subList 可以查看原列表的一段范围,返回的是共享数据的视图。例如,下面的 page 对应从 from 到 to 之前的元素:
List<User> page = users.subList(from, to);
通过视图替换或增删元素,会影响原列表。原列表如果绕过视图增删元素,就发生了视图未跟踪的结构修改。此时继续使用视图的语义未定义。OpenJDK 会通过修改计数尽力检测这类错误,并抛出 ConcurrentModificationException。
需要保存独立的列表结构时,可以显式复制:
List<User> page = new ArrayList<>(users.subList(from, to));
子列表还会保留根列表,内存影响见 子列表的内存保留。
8. 线程安全
ArrayList 不提供并发修改保证。下面的复合操作不是原子的:
if (!users.contains(user)) {
users.add(user);
}
两个线程都通过检查后,会分别追加同一个元素。要避免这类竞争,需要根据数据的共享方式选择方案:
- 列表只由一个线程访问时,使用线程封闭,让其他线程通过任务或消息提交修改。
- 多个线程共享可变列表时,用同一把锁保护完整的检查与追加。
- 读者只需要固定结果时,发布安全构造的不可变快照。
- 需要特定并发操作时,选择提供相应原子方法的并发容器。
Collections.synchronizedList 为单个方法提供同步,遍历和复合操作仍需在包装列表的同一把锁上完成。具体规则见 集合迭代器。
检查能否解释
解释随机访问、追加、扩容和中间插入的成本,区分单次操作与摊销复杂度。
ArrayList 怎样扩容,指定初始容量有什么作用? 查看题目与参考回答 →9. 子列表的内存保留
只保留一页视图,也会让原列表的其他元素继续占用内存。原因是 OpenJDK 21 的 SubList 保存了根列表 root、父视图 parent 和偏移量。根列表仍可通过这些引用访问,所以它的底层数组及数组引用的其他元素也会继续存活。
需要长期保存一页结果时,可复制这一页:
List<String> page = new ArrayList<>(all.subList(0, 10));
新列表仅保存这 10 个元素引用。复制后的元素仍是原对象;可变元素需要单独管理。
10. 常见问题
10.1 默认容量与延迟分配
OpenJDK 21 的无参构造先使用共享空数组,等到首次单元素 add 时再分配容量 10。显式指定容量 0 时,首次单元素 add 分配的容量是 1。这些数值属于实现策略,业务逻辑应通过 size 判断已有元素。
10.2 Arrays.asList 的列表类型
Arrays.asList 返回数组支持的固定大小列表,类型与 java.util.ArrayList 不同。替换元素会写回原数组,add、remove 等改变长度的操作会抛出 UnsupportedOperationException。示例见 集合迭代器。
10.3 Integer 列表的删除重载
remove(1) 选择的是接收 int 索引的重载,删除下标 1。删除值为 1 的元素应写 remove(Integer.valueOf(1))。重载选择发生在编译阶段。
10.4 ArrayList 与数组的开销
ArrayList 需要管理容量,保存基本类型时还需装箱,也就是把数值包装成对象。Java 数组访问本身也有边界检查,所以额外成本应结合具体代码分析。即时编译器(JIT)能优化部分开销,实际效果需要测量。固定长度的基本类型密集计算适合数组,需要动态增删的业务序列适合 List。
11 · 面试题卡片
11.1 ArrayList 怎样扩容,指定初始容量有什么作用
出现公司:一嗨租车、美团
考察重点
- 说明
size与 capacity 分别表示什么,以及它们如何对应内部引用数组。 - 解释扩容为什么需要复制,以及连续追加为什么具有摊销常数成本。
- 说明预留过小和过大分别产生什么成本。
相关内容:size 与 capacity、尾部追加与扩容、初始容量。
参考回答
ArrayList 容量不足时会分配更大的数组并复制引用,预留初始容量可以减少这个过程。
容量还有空位时,追加只需写入 size 对应的槽位,再增加元素数量。OpenJDK 21 扩容时首选增加旧容量的一半,最小所需容量更大时则满足该容量。
容量按比例增长,所以连续追加的摊销成本是 O(1),单次扩容仍为 O(n)。已知大致数量时预留容量,可以减少分配、复制和新旧数组共存的内存峰值。预留过大则让空槽长期占用内存。
11.2 ArrayList 与 LinkedList 应该怎样选择
出现公司:字节跳动、阿里巴巴、美团
考察重点
- 说明随机访问、定位和节点修改的不同成本。
- 解释连续引用数组为什么有利于缓存,以及链表节点会增加哪些内存开销。
- 说明“插入多就用 LinkedList”为什么缺少前提。
参考回答
按索引读取或尾部追加时,优先考虑 ArrayList。
它的随机访问为 O(1),尾部追加摊销 O(1)。连续引用数组也减少了节点跳转。
LinkedList 按索引定位为 O(n),每个元素还需要单独的节点和前后引用。已有迭代位置并频繁在该处插入删除时,局部修改为 O(1),可以省去查找和数组搬移。因此,要结合定位方式与节点分配成本选择;单线程队列或栈可优先考虑 ArrayDeque。
11.3 只保留一个很小的 subList,为什么仍会占用大量内存?
出现公司:暂无已核验的公开来源(本站自测)
考察重点:解释子列表如何保留根列表,以及复制结构后为何仍共享元素对象。
相关内容:子列表的内存保留。
参考回答
子列表持有根列表,所以即使视图很小,也会间接保留整个底层数组。
数组还引用着其他元素,这些元素也会继续存活。OpenJDK 21 仍采用这种实现。
用 new ArrayList<>(subList) 复制需要的范围后,新列表只保存这一段的元素引用。释放原视图和原列表的其他引用后,其余元素才有机会被回收。副本仍共享原来的元素对象,所以可变字段需要单独管理。