1. 从一道高频面试题开始
"说说ArrayList的扩容机制?"
如果你去面过Java后端岗位,十有八九会被问到这个问题。很多同学能背出"默认容量10、扩容1.5倍、底层是数组拷贝"这几句话,但再往下追问"为什么是1.5倍不是2倍?""频繁扩容会有什么后果?""初始化时怎么给容量才合适?"就卡住了。
老实说,ArrayList的扩容机制不只是个面试考点,它直接关系到你写的代码在线上跑得稳不稳、快不快。我见过不少线上事故就是栽在集合扩容上——比如某次批量导入接口频繁触发扩容,导致Young GC暴增,接口RT直接翻了好几倍。那一次排查到最后,发现罪魁祸首就是那句不起眼的new ArrayList<>()。
这篇内容我会从源码到实战,把这个机制掰开了讲清楚。不管你是刚入门的新人,还是写了好几年Java的老手,看完应该都能对ArrayList的扩容行为建立完整认知,起码在初始化容量这个选择上,不会再凭感觉写代码。
2. 数组和扩容的本质:为什么ArrayList"会长大"
2.1 ArrayList的数据结构到底是什么
先明确一个基本事实:ArrayList底层就是一个Object数组,用transient Object[] elementData来存数据。所谓"动态数组",本质还是数组,只是它在你往里面塞元素时,能自动判断空间够不够,不够就换一个更大的新数组,把旧数据搬过去。
数组最大的特点是连续内存 + 按下标随机访问,所以ArrayList的get(int index)时间复杂度是O(1),这也是它最核心的优势。但连续内存也意味着"一旦创建,长度固定",跟链表那种"随用随分配节点"完全不同。
那"动态"是怎么实现的?说白了就三步:
- 检查当前数组容量是否够用
- 不够就申请一个更大的新数组(通常1.5倍)
- 把旧数组元素全部拷贝到新数组
这个过程在源码里浓缩在一个叫grow()的方法中,后面我会逐行拆。
2.2 扩容触发的临界点:size和elementData.length的关系
ArrayList内部有两个核心字段,理解清楚它们的区别,后面看源码就不晕了:
size:当前实际存了多少个元素(逻辑长度)elementData.length:底层数组的容量(物理长度)
扩容的触发条件是size + 1 > elementData.length,也就是"再塞一个元素就放不下了"。注意这里比较的是"最小需要的空间"和"当前数组容量",而不是"当前已有元素个数"和"容量"。
比如初始容量10,你已经存了9个元素,size=9,数组长度=10。再add一个,size+1=10,等于容量10,不需要扩容。但如果已经有10个元素,再add一个,size+1=11,大于容量10,触发扩容。
这个判断逻辑在ensureCapacityInternal方法里完成,它先算minCapacity = size + 1,然后跟elementData.length比较,不够就调用grow(minCapacity)。
2.3 一个顺手做的事:add方法是怎么写的
先看入口——add(E e)方法的源码:
public boolean add(E e) { ensureCapacityInternal(size + 1); // 确保容量够用 elementData[size++] = e; // 赋值并让size自增 return true; }逻辑非常简洁:先扩容检查,再直接往数组尾部赋值。这段代码大家可能看过很多遍,但我提醒一个细节:elementData[size++] = e不是原子操作,它分成了"读取size、数组赋值、size+1回写"三步。单线程下没问题,多线程环境下就存在数据覆盖风险。这也是ArrayList被反复强调"线程不安全"的原因之一。
3. 扩容源码逐行拆解:从add到grow的完整链路
3.1 ensureCapacityInternal到ensureExplicitCapacity
add方法调用的ensureCapacityInternal,源码长这样(基于JDK 1.8):
private void ensureCapacityInternal(int minCapacity) { ensureExplicitCapacity(calculateCapacity(elementData, minCapacity)); } private static int calculateCapacity(Object[] elementData, int minCapacity) { if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) { return Math.max(DEFAULT_CAPACITY, minCapacity); } return minCapacity; } private void ensureExplicitCapacity(int minCapacity) { modCount++; if (minCapacity - elementData.length > 0) grow(minCapacity); }这段代码里有三个信息值得注意:
第一,DEFAULTCAPACITY_EMPTY_ELEMENTDATA是new ArrayList<>()时默认的空数组,容量为0。首次add时,calculateCapacity会把这个minCapacity直接提升到DEFAULT_CAPACITY,也就是10。所以你听说的"ArrayList默认初始容量10"准确说应该是首次add时默认扩容到10,而不是new出来就有10个空间。
第二,modCount++每扩容一次都会自增,这个字段是给Fail-Fast机制用的。迭代器遍历时会检查modCount是否变化,一旦在遍历过程中发生结构性修改(add、remove、扩容),就会抛出ConcurrentModificationException。千万别小看这个自增操作,它是ArrayList线程不安全的最直接体现之一。
第三,扩容判断用的是minCapacity - elementData.length > 0,也就是"所需的最小容量"跟"当前容量"做差,只有真正不够才走grow。
3.2 核心方法grow():扩容计算规则
真正干活的grow()方法如下:
private void grow(int minCapacity) { int oldCapacity = elementData.length; 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); }逐行解释一下:
oldCapacity + (oldCapacity >> 1)就是oldCapacity + oldCapacity / 2,也就是1.5倍。这里用右移运算符替代除以2,纯粹是性能考虑——位运算比除法快一个量级。有人问过"为啥不直接写成oldCapacity * 3 / 2",其实oldCapacity >> 1在绝大多数编译器和JVM优化下已经足够高效,而且可读性也不错。
if (newCapacity - minCapacity < 0)这个判断考虑的是极端情况:如果oldCapacity为0(比如你手动初始化为0,或者用无参构造后还没add过),那么oldCapacity >> 1还是0,newCapacity=0。这时候就需要兜底,直接取minCapacity。比如首次add,minCapacity是10,newCapacity是0,就走这个分支变成10。
if (newCapacity - MAX_ARRAY_SIZE > 0)是超过最大数组长度时的兜底逻辑,MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8。为什么减8?因为某些JVM实现里数组头信息需要占用额外空间,如果太大可能导致OOM。Java规范里也没写死这个值,不同JVM实现可能略有差异,但HotSpot VM普遍支持到接近Integer.MAX_VALUE的长度。当超过这个阈值时,hugeCapacity会直接尝试分配minCapacity这么大的空间,如果minCapacity都超过Integer.MAX_VALUE,就直接抛OutOfMemoryError:
private static int hugeCapacity(int minCapacity) { if (minCapacity < 0) // overflow throw new OutOfMemoryError(); return (minCapacity > MAX_ARRAY_SIZE) ? Integer.MAX_VALUE : MAX_ARRAY_SIZE; }3.3 数组拷贝:扩容的搬运工
grow的最后一步是Arrays.copyOf(elementData, newCapacity),这才是真正发生"老数据搬家"的地方。
Arrays.copyOf底层调用System.arraycopy,这是JVM提供的native方法,会用内存级别的拷贝(可能走memmove),效率远高于我们自己写for循环逐元素复制。但即便如此,每扩容一次,就要O(n)的时间把旧数组全部拷到新数组——n是当前元素个数。
这里要画个重点:扩容不是每次add都发生,而是"空间不够才发生"。但一旦发生,代价是当前所有元素都要搬一次家。如果你在循环里不断add,扩容的次数是指数级减少的(10→15→22→33→49→...),但每次搬运的数据量是线性增加的,总拷贝次数约等于2n(等比数列求和),所以均摊下来每次add还是O(1)。这也就是为什么ArrayList整体性能依然优秀——它把扩张的代价摊到了每一次add操作里。
4. 扩容性能影响与容量优化实践
4.1 频繁扩容的代价到底有多大
刚才说了均摊O(1),但均摊O(1)不代表没有性能问题。关键是"均摊"的前提是你没有频繁触发扩容。有些场景下扩容的代价会被人为放大:
场景一:循环批量add,比如导入1万条数据,用无参构造的ArrayList。第一次add触发扩容到10,然后第11次、第16次、第22次……一路扩上去。总计大约要扩容14次左右,每次都是一次全量数组拷贝,虽然总时间可能也就几毫秒,但会产生多次内存分配和垃圾回收压力。
场景二:在ArrayList前部插入数据,比如list.add(0, element)。这个方法内部先ensureCapacity,然后把所有后续元素往后移动一位。如果赶上扩容,那就既要扩容搬家又要错位移动,双重O(n)代价。
场景三(最容易踩的坑):循环内创建ArrayList。比如在for循环里每次new一个ArrayList再add几条数据。每一次循环都创建对象、可能扩容、然后丢掉,Young GC压力直接拉满。这种情况在平时写业务代码时非常常见,监控一看GC频繁,定位半天发现是某个不起眼的循环在疯狂new集合。
4.2 初始化容量怎么给:预估策略与实际操作
最直接的优化手段:如果你能预估元素个数,就显式传入初始容量。
// 已知大概会放1000条记录 List<String> list = new ArrayList<>(1000);这样在add过程中,只要不超过1000个元素,就完全不会触发扩容,省掉了所有数组拷贝和中间态内存。
那"预估"准确度怎么保证?几个经验:
- 数据量稳定且可数(比如从数据库查出的记录数、解析固定格式文件的行数),直接给容量等于"预估上限",别给精确值,留点余量
- 数据量波动大,可以给一个偏保守的上界,比如平时最多500,高峰可能800,那就new ArrayList<>(800)
- 如果你确实不知道多大,但知道会持续add,可以分阶段扩容:
list.ensureCapacity(nextSize)手动触发一次性扩容,避免多次半途扩容
这里还要提一个方法:trimToSize()。它能把底层数组的大小调整到跟size一样,去掉多余空间:
ArrayList<Integer> list = new ArrayList<>(100); for (int i = 0; i < 10; i++) { list.add(i); } list.trimToSize(); // 底层数组长度从100缩到10什么时候用?比如一个ArrayList只是临时用来收集一批数据,之后要长期驻留内存,而你已经确定不再往里面加元素了,用trimToSize()可以省掉那90%的闲置空间。但这方法在多数业务场景下用得不勤,因为它本身也要一次数组拷贝(创建一个更小的新数组),属于"用一次拷贝换长期空间"的权衡,需要根据实际情况决定要不要做。
4.3 ensureCapacity方法:手动扩容的正确姿势
ArrayList提供了一个公共方法ensureCapacity(int minCapacity),专门让调用方在插入大量数据前主动扩容:
ArrayList<User> users = new ArrayList<>(); int estimatedSize = fetchCountFromDb(); users.ensureCapacity(estimatedSize); // 然后开始批量add这方法底层走的就是ensureCapacityInternal,但它会先判断当前容量是否够,够就直接返回,不够才扩容。所以你可以放心调用,不会有多余开销。
可能有人问:那我在循环里每次add前都调用ensureCapacity(size+100)行不行?当然可以,但这属于过度设计。ArrayList本身的设计目标就是"自动扩容足够好用",你只需要在数据量变化跨越量级时(比如从百级到万级)提前打一针"预防针",没必要精确到每次add都管。
4.4 为什么选1.5倍而不是2倍:数学层面的门道
这是面试里最高频的追问。直接说结论:1.5倍是在空间利用率和时间开销之间折中的结果。
如果扩容倍数设为2倍,那么扩容后新数组容量是2^{n+1},而旧数组是2^n。你会发现:当你向ArrayList中存放的元素数量超过前一次容量时,前面所有扩容分配过的空间全部加起来,都不如当前这一个新数组大。这意味着JVM内存中可能同时存在两个大数组:旧数组还在被引用(拷贝结束前),新数组刚分配出来。GC的负担和后端内存峰值都会很高。
如果倍数太接近1,比如1.2倍,那么扩容次数会变多,数组搬运的总代价上升。打个比方:搬家时如果每次只换个比原来大20%的房子,你很快又得搬,搬来搬去累死人;如果每次换个比原来大1倍的房子,一次到位但房价(内存)浪费严重;1.5倍相当于"提前规划一步又不铺张浪费"的中间档。
从编码层面讲,oldCapacity >> 1还可以保证移位后newCapacity是偶数,这对某些JVM的数组对齐优化有一定的积极意义(具体效果依赖各JVM实现)。虽然这个因素不是设计的决定性原因,但确实让1.5倍在工程实现上没有额外负担。
网络上有个经典分析:假设初始容量N,你最终要塞入M个元素(M远大于N),那么扩容总次数约为log_{1.5}(M/N),每次扩容的搬运量分别为N、1.5N、2.25N、...、M,求和约等于2M。也就是说,不管你怎么折腾,从初始到装满,元素被复制的总次数大约就是最终元素个数的两倍。这个"总复制量约2M"的结论不依赖初始容量N,N只影响扩容次数而不影响总搬运量。用2倍的话总搬运量也差不多是2M,但峰值内存大;用1.2倍的话峰值内存小,但扩容次数更多。1.5倍算是平衡点。
5. 常见问题与排查技巧实录
5.1 线上问题:批量导入接口偶发超时
先说一个我实际遇到的案例。某次做数据同步功能,需要把一批外部数据解析后批量写入ArrayList,再统一入库。代码写得很快,直接new ArrayList<>()然后循环add,数据量在高峰期能到几万条。上线后监控发现这个接口的P99延迟在高峰期明显上升,Young GC次数也异常。
排查思路:
- 先看GC日志,确认对象分配速率极高。JFR(Java Flight Recorder)显示大量ArrayList扩容产生的char[]和Object[]分配
- 再看代码,确认是
new ArrayList<>()无参构造导致每次需要一步步扩上去 - 最后用压测验证:20000条数据,无参构造的ArrayList大约扩容14次,而初始容量给20000的ArrayList扩容0次。接口RT从几十毫秒降到十几毫秒
这个案例至今让我记忆深刻的原因在于:问题本身不难,但定位过程绕了不少弯。因为单次扩容也就几十微秒,14次加起来不到1毫秒,看起来根本不值一提。可架不住调用频率高、对象创建多,GC压力上去后整个接口的RT都受到影响。性能问题往往不是某个单点操作慢,而是大量的小开销累积成系统瓶颈。
5.2 并发场景:扩容时丢了数据怎么办
有同学会问:既然add不是原子的,那多线程add同一个ArrayList会发生什么?
实际情况比"线程不安全"四个字复杂得多。两个线程同时add,可能同时读取到同一个size值,然后各自在同一个数组下标处赋值,后写入的覆盖先写入的,导致数据丢失。更危险的是在扩容期间,线程A正在grow(),把老数组元素拷到新数组,线程B直接往老数组里写值——等A的Arrays.copyOf执行完,B写的数据可能就不在新数组里了。
这也是为什么CopyOnWriteArrayList存在的意义。它的读写策略是"写时复制":add时加锁,然后拷贝一整份数组,在新数组上操作,最后替换引用。读操作不加锁,因为读的是不可变数组的引用。这套设计的代价是每次写都全量拷贝,写多读少的场景千万别用。
如果你的场景是"读多写少"且并发访问频繁,CopyOnWriteArrayList是正解;如果写也频繁,那就老老实实加锁或换ConcurrentLinkedQueue之类的并发集合。
5.3 经典面试题变体:为什么要用Object[]而不是泛型数组
源码里elementData声明是transient Object[] elementData,而不是E[] elementData。为什么?
因为Java泛型是类型擦除机制,运行时并没有E的实际类型信息。如果你直接声明E[],在构造时需要一个具体类型的数组,比如:
E[] array = new E[capacity]; // 编译不通过所以ArrayList的设计采用了一个折中方案:底层用Object[]存储,所有方法里在读写时做强制类型转换。比如get()方法返回(E) elementData[index]。这个转换看着有风险,实际上因为add时插入的都是限定类型E的元素,类型安全有保障。
这个细节还可以延伸到toArray()方法的经典坑:list.toArray()返回的是Object[],而不是T[]。如果你把一个ArrayList<String>的toArray()结果强转成String[],运行时直接抛ClassCastException。必须用带泛型的toArray(T[] a)才行。
5.4 扩容相关参数速查表
| 参数/行为 | 说明 |
|---|---|
| DEFAULT_CAPACITY | 默认容量10,首次add时生效 |
| MAX_ARRAY_SIZE | Integer.MAX_VALUE - 8 |
| 扩容倍数 | 1.5倍(oldCapacity >> 1) |
| 触发条件 | add时size + 1 > elementData.length |
| 均摊复杂度 | add操作均摊O(1) |
| 最坏复杂度 | 单次扩容O(n),n为当前size |
| 线程安全性 | 不安全,多线程add会丢数据 |
| modCount作用 | 记录结构性修改次数,支持Fail-Fast |
6. 从扩容机制到集合选型:什么时候不用ArrayList
6.1 ArrayList、LinkedList和CopyOnWriteArrayList怎么选
很多人对这三者的选择还是停留在"ArrayList查快,LinkedList增删快"的层面。但实际开发中,LinkedList的"增删快"只在特定场景下成立——前提是你已经持有那个位置的节点引用。如果你只是list.add(element),LinkedList每次add都要从头或从尾遍历到插入位置,尾部add的话还要维护双向链表的引用关系,开销并不比ArrayList小。更别说LinkedList每个节点都要额外存前后指针,内存浪费明显。
我的建议是:
- 大多数场景:直接用ArrayList。原因很简单——你几乎总是按索引访问、遍历、尾部追加,这些都是ArrayList的强项
- 需要频繁在头部插入且数据量很大:考虑
ArrayDeque,或者LinkedList(但要接受内存开销) - 多线程读多写少:
CopyOnWriteArrayList - 只用来当栈用:
ArrayDeque,不要用Stack类(它继承自Vector,所有方法都加锁,性能差)
6.2 一个容易忽略的内存陷阱:ArrayList扩容后的残留空间
假如你new了一个容量为10000的ArrayList,但实际上只放了10个元素就长期持有这个引用。底层数组长度是10000,意味着这个对象占了80KB内存(假设是引用类型,每个引用8字节)。10个元素实际需要80字节左右的内存,却有接近8万字节被闲置。
这种"大容量小数据"的场景,在缓存、静态常量、单例对象里特别容易踩坑。处理办法就是上面提到的trimToSize(),或者干脆一开始就给精确的初始容量。
6.3 源码阅读方法:如何由点及面吃透一个集合类
最后分享一个我自己的源码阅读方法。很多人拿到源码不知道从哪看起,我的习惯是"从一次add的全过程出发,自上而下追调用链"。以ArrayList为例:
- 先读
add(E e),看它调用了什么 - 顺着
ensureCapacityInternal进入ensureExplicitCapacity,看到扩容判断条件 - 再进
grow(),看到1.5倍扩容计算和Arrays.copyOf - 问自己几个"为什么":为什么这里用移位?为什么有DEFAULTCAPACITY_EMPTY_ELEMENTDATA这个字段?为什么modCount在这里自增?
- 把这些"为什么"逐个在代码里找到答案
- 最后横向比较:跟LinkedList比内存布局,跟CopyOnWriteArrayList比并发策略,跟Vector比锁粒度
这样一轮下来,你对这个类的理解就不再是零散的知识点,而是一张完整的调用链加设计决策图。面试官再问你"ArrayList为什么线程不安全",你直接可以从modCount、size自增非原子、扩容期间写入三个角度回答,比背概念强太多了。
7. 我的实际体会
说点个人经验。我写Java快十年了,ArrayList大概是所有集合类里用得最多的,但真正把它研究透还是在一个线上OOM事故之后。那次事故是某个服务在启动时加载配置,用无参构造的ArrayList往里塞了几十万条数据,中间扩容了几十次,内存碎片化严重,最终在高峰期触发了OOM。后来改成预估容量初始化,问题直接消失。从那以后,我在写任何批量数据收集逻辑时都会先问一句:这里到底大概会有多少数据?
这个习惯很微小,但带来的收益非常实在。ArrayList是Java集合里最简单、最常用的一个类,可它的扩容机制设计其实蕴含了很多值得玩味的工程权衡——1.5倍的选择、均摊时间复杂度的保证、Fail-Fast的设计哲学。看懂它,你不仅理解了ArrayList本身,对Java集合框架的整体设计思路也会有一个更立体的认知。
如果你正在准备面试,我的建议是别只背结论,把grow()那几行代码自己跑一遍、走一遍调用链,理解每个判断分支都在干什么。面试官问"为什么",你能从源码到数学再到工程权衡层层展开,这远比"扩容1.5倍"五个字值钱得多。