ArrayList 源码与扩容机制,几乎是 Android 面试里被反复问到、且容易被答浅的一题。很多候选人张口就是“底层是数组,查询快插入慢”,听起来没问题,面试官接着问一句“无参构造 new 出来的那一刻,底层数组多大”,台面上的人一半会答错。这题考的从来不是背诵,而是能不能把一个被用滥的数据结构拆开讲清楚:容量怎么来、怎么长、长到什么时候会出问题。
先把结论放在前面:无参构造在 JDK 8 里并不会立刻分配数组,首次 add 之前底层是空数组,第一次 add 才真正扩容到默认容量 10;早期 JDK 版本则是构造时直接分配长度 10 的数组。后续每次容量不足,新容量取旧容量的 1.5 倍(old + old >> 1),通过 Arrays.copyOf 整体拷贝到新数组。这题要答好,得把扩容机制拆开看:它解决什么问题、不解决什么问题、和“手动指定容量”的分界在哪里。
机制拆解
讲清楚扩容发生在哪。add(E e) 内部先调用 ensureCapacityInternal(size + 1),算出最小所需容量,若超过当前 elementData.length,就走 grow 方法。grow 里 newCapacity = oldCapacity + (oldCapacity >> 1),也就是 1.5 倍;若 1.5 倍仍不够(比如一次 addAll 塞进一大段),则直接取所需容量;若超过 MAX_ARRAY_SIZE(Integer.MAX_VALUE - 8),再按上限兜底。这个细节很少有人主动提到,却是“一次 addAll 大集合会不会频繁扩容”这类追问的答案。理解 1.5 倍与“按需取所需容量”两条分支,才能在“批量写入”场景讲出层次。
这些坑的正确绕法
最常见的坑是预知数据量却仍用无参构造。例如从接口拉回一万条数据循环 add,会经历多次 1.5 倍扩容与整段拷贝,既吃 CPU 又产生大量短命大数组加重 GC。正确做法是 new ArrayList<>(expectedSize) 一次到位,或至少调用 ensureCapacity 提前撑开底层数组。
其次是以为 ArrayList 线程安全。多个线程并发 add,size 自增与数组写入不是原子的,会出现元素覆盖、size 超前、甚至数组越界。ArrayList 从设计上就不保证并发安全,多线程写要用 CopyOnWriteArrayList 或外部加锁。
还有一个更隐蔽的坑:用 subList 拿到的是原集合的视图而非副本,对子列表的增删会直接反映到原集合,且原集合结构一旦被修改(哪怕是通过子列表之外的方式),再操作子列表会抛 ConcurrentModificationException。把 subList 结果当独立列表传给别的方法,是生产事故里很常见的一种误用。
代码里见真章
看一段能直接跑的代码,把上面的机制落到具体写法上:
// ArrayList 扩容机制(JDK 8)
ArrayList<Integer> list = new ArrayList<>(); // 首次 add 前底层为空数组
list.add(1); // 首次 add 扩容到 10
list.ensureCapacity(1000); // 预分配,避免反复拷贝
// 扩容:newCapacity = old + (old >> 1),即 1.5 倍
// grow() 内通过 Arrays.copyOf 整体拷贝到新数组
// 容量上限 Integer.MAX_VALUE - 8(MAX_ARRAY_SIZE)
这段代码值得盯三处:第一处,无参构造延迟分配,首次 add 才到 10,省了空集合的内存;第二处,ensureCapacity 预分配能省掉中途多次 1.5 倍扩容的拷贝开销;第三处,扩容本质是 Arrays.copyOf 的整段数组迁移,数据量大时这笔开销不可忽视。面试讲到这一层,基本就稳了。
这题在面试里怎么问、怎么答
“请简单介绍一下 ArrayList 源码与扩容机制,它在 Android 开发中起什么作用?”按“是什么 → 怎么扩 → 项目里怎么用”递进,别超三分钟。ArrayList 用动态数组承载元素,随机访问 O(1),尾部增删均摊 O(1),中间插入 O(n)。在 Android 里,列表渲染的数据源、接口返回的对象集合、ViewHolder 里的临时容器都大量用到它。如果要把使用约定沉淀成团队规范,建议加一条:已知批量数据规模时,构造即指定容量,别让扩容在循环里偷偷发生。
“ArrayList 的扩容底层原理是什么?能不能详细说一下?”从 add 的 ensureCapacityInternal 讲起:计算最小容量 → 超过当前长度则 grow → 新容量 1.5 倍 → Arrays.copyOf 迁移。补充两个常被忽略的点:一是 addAll 大集合时 1.5 倍不够会直接取所需容量,避免二次扩容;二是 MAX_ARRAY_SIZE 上限兜底。机制别空讲,配上核心代码最稳。
“在使用 ArrayList 时遇到过什么问题?”讲真实案例:某次首页瀑布流从接口分页拉数据,用无参构造逐页 addAll,页面越翻越卡,profiler 抓到频繁的 Arrays.copyOf 大数组分配。修复为按首屏预估总量一次性构造容量后,卡顿消失、GC 次数明显下降。量化的修复效果最加分。
“ArrayList 和相关的替代方案相比,有什么优劣?”与 LinkedList 比,读多写少、随机访问场景 ArrayList 更合适,只有频繁两端增删才轮到 LinkedList;与数组比,ArrayList 自动扩容、有丰富 API,代价是轻微装箱与扩容拷贝;与线程安全容器比,ArrayList 非并发安全,写竞争场景要换 CopyOnWriteArrayList 或加锁。选型就按读写特征来,能讲清代价才叫真懂。选型时要把“多线程并发 add 导致元素覆盖或越界”这类代价摆到台面上,再决定是否引入。
再补一个工程上的细节:trimToSize 与容量浪费的取舍。ArrayList 经历过多次扩容后,底层数组往往比实际元素多出一截(例如放了 11 个元素,底层数组却是 16)。长期持有这种“虚胖”的集合,会无谓占用内存;在集合生命周期明确结束于一次性构建的场景(比如把查库结果封进一个 DTO 后不再变更),调用 trimToSize 能把底层数组收缩到刚好容纳元素,降低内存峰值。但 trimToSize 本身是一次整体拷贝,不能在对性能敏感的循环里滥用。另一个值得提的点是 modCount 与 fail-fast:ArrayList 在迭代中若被结构性修改会抛 ConcurrentModificationException,边遍历边删除要走迭代器的 remove(),否则要么抛异常要么跳元素。这些细节单看都不复杂,串起来却是 ArrayList 在真实项目里“用得对不对”的分水岭。
给正在准备面试的你
把 ArrayList 源码与扩容机制画成一张小图:无参构造(延迟空数组)→ 首次 add 到 10 → 不足则 1.5 倍 grow → Arrays.copyOf 迁移。面试中关于 ArrayList 的问题,关键在于能够从原理、应用、踩坑三个层面给出有深度的回答。
再补工程案例与踩坑——应用落点是给一个已知十万条的列表场景显式指定初始容量,对比有无预分配下的构造耗时与 GC 次数。
复习时别孤立刷题:LinkedList 与 ArrayList 选型——两者常一起考,边界提前划清楚。
划两句重点:无参构造延迟到首次 add 才分配默认容量 10,省了空列表内存;扩容走 1.5 倍加 Arrays.copyOf,已知规模时建议显式指定容量。
下一篇聊 LinkedList 与 ArrayList 选型:随机访问与插删的权衡——沿着今天这条主线继续往前走。
如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。你的支持就是这个系列持续更新的动力。
「Android软件开发面试·从入门到精通」连载系列
上一篇:集合框架总览:Collection-与-Map-两大家族
下一篇预告:LinkedList-与-ArrayList-选型:随机访问与插删的权衡
有任何问题欢迎在评论区留言交流。