1. ArrayList扩容机制,到底在扩什么
用Java的开发者,几乎每天都会碰到ArrayList,但真被问到“它底层怎么扩容的”,能一次说清楚的人真不多。我第一次带新人的时候,发现很多写了两年代码的同学,只知道ArrayList底层是数组、默认容量是10,但再往深问“为什么默认是10”“为什么扩容是1.5倍”“扩容时元素怎么搬过去”,就支支吾吾了。
这篇文章就把ArrayList扩容机制从头到尾拆开揉碎讲清楚。不仅讲“是什么”,更讲清楚“为什么这么设计”。不管你是刚学集合框架的初学者,还是准备面试的中级开发,或者是想优化线上代码性能的老手,这篇内容都能给你实打实的帮助。看完之后,你能回答出扩容触发的边界条件、扩容后数组怎么迁移、1.5倍这个数字背后的设计逻辑,以及如何在日常开发里避开扩容带来的性能坑。
2. 底层设计:为什么ArrayList用数组,又为什么非要扩容
2.1 数组的“固定长度”宿命与动态扩容需求
ArrayList的本质,就是一个会“自动长大”的数组。这句话听起来简单,但拆开来看,它同时包含了两层含义:底层结构用的是数组,行为表现上却要突破数组长度固定的限制。
数组在创建时就要指定长度,一旦确定,内存空间就固定了,后续没法直接在原位置扩长。如果想“塞进”更多元素,只能另找一块更大的内存,把原数组里的元素一个个复制过去,再把新元素追加进去。这个过程,专业术语叫“扩容”,英文是grow。
ArrayList存在的意义,就是替开发者屏蔽掉“数组长度不够了怎么办”这个问题。你只管往里面add,它自己在内部判断长度够不够,不够了就悄悄扩容。这就像你住在一间小房子里,东西越买越多放不下了,你不会自己动手拆墙,而是打电话让搬家公司帮你换一间更大的房子,再把旧房子里的家具原封不动搬过去。ArrayList就是那个搬家公司。
2.2 确保容量与扩容触发:先看“够不够”,再决定“动不动”
扩容不是无时无刻都在发生的。只有当数组真的装不下了,才会触发。ArrayList内部维护了一个elementData数组,还有一个size字段记录实际元素个数。注意,size是“实际存了几个”,不是“数组能存几个”。数组的长度是elementData.length,是“容量”。
每次调用add方法添加元素,ArrayList做的第一件事,就是调用一个叫ensureCapacityInternal的方法,把minCapacity传进去。这个minCapacity的值是size + 1,意思是“我即将要存储的元素个数”。如果这个值大于当前数组长度,说明数组装不下了,立刻触发扩容。如果小于等于当前长度,就什么都不做,直接往对应下标写数据。
这里藏着第一个容易搞混的点:ArrayList的size初始是0,数组容量初始也是0。第一次add的时候,minCapacity是1,而数组长度是0,1 > 0,触发了扩容。但这里有个特例,第一次扩容时并不是直接扩到1,而是先初始化成一个默认容量,这个默认容量是10。也就是说,你第一次往里add一个元素,底层数组直接就变成了10的长度。这也是面试里常问的“ArrayList默认容量是多少”的标准答案。
2.3 默认容量为什么是10:一个经典的“空间换时间”取舍
为什么默认容量选10,而不是5、不是20?这其实没有官方特别严格的解释,但业界普遍看法是:大多数业务场景下,一个列表的初始元素数量级在个位数到十几之间。取10,既不会像1那样频繁扩容,也不会像100那样给大部分场景造成内存浪费。
更关键的是,如果你能提前预判需要存储的数据量,可以通过构造函数指定初始容量,new ArrayList<>(1000),这样从一开始就分配好足够大的数组,后续add全程零扩容。而如果你不指定,它就用默认的10,等装满了再慢慢扩。这种“懒加载”式的设计,本质上是用“未来可能发生的扩容代价”换取“大多数情况下的低内存占用”。
这里再补充一个很多人不知道的细节:ArrayList还有另一个构造函数,接收一个Collection参数。这种构造方式会用集合的toArray()方法直接拿到数组,再赋值给elementData。但如果传入的集合是空的,会用空数组EMPTY_ELEMENTDATA来初始化,而不是直接给10的容量。
3. 扩容全流程拆解:从add到底层数组翻倍
3.1 一次add的完整执行路径
为了让你看清楚扩容在add里到底处在什么位置,我直接拆解add(E e)的执行流程。用大白话描述就是四步:判断容量、决定是否扩容、写入元素、更新size。
第一步,调用ensureCapacityInternal(size + 1)。这一步内部会先检查elementData是不是那个共享的空数组DEFAULTCAPACITY_EMPTY_ELEMENTDATA。如果是,就取minCapacity和DEFAULT_CAPACITY里的较大值作为目标容量,也就是把10和size+1比大小,第一次add时size+1是1,取10。第二步,调用ensureExplicitCapacity(minCapacity),这一步才真正和当前数组长度比较,如果minCapacity大于elementData.length,就调用grow方法。第三步,grow方法执行扩容,返回一个更大的新数组。第四步,把新元素写入elementData[size],然后size加1。
整个过程用一个生活例子类比就是:你去餐厅吃饭,座位不够了,服务员不会等客人全到了才加桌子,而是在你刚开始点菜的时候就预估人数,发现不够赶紧去隔壁借桌子。ArrayList的ensureCapacityInternal就是那个提前预估人数的服务员,grow就是搬运桌子的过程。
3.2 扩容公式详解:为什么是oldCapacity + (oldCapacity >> 1)
扩容的核心逻辑在grow方法里。我直接贴出关键源码的逻辑描述,然后逐行解释。
先记录oldCapacity为当前数组长度。然后计算newCapacity = oldCapacity + (oldCapacity >> 1)。这里的>>是右移运算符,oldCapacity >> 1等于oldCapacity除以2然后向下取整。所以整个公式就是:新容量 = 旧容量 + 旧容量/2,也就是旧容量的1.5倍。
举个具体例子:如果oldCapacity是10,那么newCapacity = 10 + 5 = 15。如果oldCapacity是15,newCapacity = 15 + 7 = 22。注意这里是向下取整,15右移1位是7,不是7.5,因为数组长度是整数。
这个1.5倍的策略,比“每次扩容多1个”要高效得多,因为扩容次数的增长速度是对数级别的。假设从10开始,每次扩1.5倍,扩到1000只需要十几次。如果每次只加1,就要扩990次。每次扩容都伴随着整个数组的复制,元素越多,单次复制成本越高。所以扩容倍率越大,扩容次数越少,但浪费的内存也越多。1.5倍是经过权衡后的一个相对均衡的选择。
3.3 两个特殊分支:防止溢出和超大数组
grow方法里还有两个边界处理,是很多人忽略的细节。
第一个分支:如果计算出来的newCapacity还是小于minCapacity,说明1.5倍的扩容幅度还不够用,那就直接取minCapacity作为新容量。这种情况什么时候发生?最常见的是第一次add时。前面说过,第一次add直接初始化成10。但如果通过构造函数指定了一个很大的初始容量,比如new ArrayList<>(5),然后一次性add进去6个元素,那么minCapacity就是6,而5的1.5倍是7,7 > 6,不会走这个分支。真正会走这个分支的,是比较极端的情况,例如ArrayList长度是6,你调用addAll方法一次性塞入大量元素,此时minCapacity一下变得很大,1.5倍根本不够,就直接按minCapacity扩容。
第二个分支:如果newCapacity超过了MAX_ARRAY_SIZE,也就是Integer.MAX_VALUE - 8,会触发hugeCapacity方法。这个方法里做了一个非常关键的判断:如果minCapacity小于0,直接抛出OutOfMemoryError。然后如果minCapacity大于MAX_ARRAY_SIZE,就返回Integer.MAX_VALUE,否则返回MAX_ARRAY_SIZE。
为什么是Integer.MAX_VALUE - 8而不是Integer.MAX_VALUE?因为有些JVM实现会在数组头部存储一些元数据,比如对象头、数组长度信息,如果数组长度直接顶到Integer.MAX_VALUE,加上这些头部信息,内存可能就溢出了。减8是为了留出安全余量。但真要走到这一步,说明你的ArrayList已经存储了接近21亿个元素,现实中几乎不可能发生,除非是有人拿它做极端的内存压力测试。
3.4 元素迁移:System.arraycopy是真正干活的
扩容公式算出了newCapacity,接下来就要把旧数组里的元素搬过去。这一步调用的核心方法是Arrays.copyOf(elementData, newCapacity)。如果你看过JDK源码,会发现Arrays.copyOf底层调用的是System.arraycopy,这是一个native方法,由JVM底层实现,在C语言层面直接用内存拷贝的方式搬数据,效率远高于Java层的for循环逐个赋值。
数组扩容后,elementData变量就指向这个新数组,旧数组失去了引用,等待GC回收。这个过程有个非常形象的比喻:搬家不是把旧房子整个移走,而是在新小区买了一套更大的房子,把旧房子里的家具一件件搬过去,然后旧房子被推倒。
这里还要说一个有趣的细节:扩容后,新数组的尾部和旧数组相比多出来的那部分空间,元素值全部是null。也就是说,从旧数组拷过来的元素还保持在原下标位置,多出来的空间空着等待新元素填入。这也是为什么ArrayList允许存储null值,因为它底层就是普通的Object数组。
4. 扩容机制的实战影响:性能损耗与场景适配
4.1 频繁扩容到底有多伤性能
前面讲了扩容的原理,现在说说实际开发里它会造成什么影响。扩容最核心的代价是数组复制,这个复制是O(n)的,n是当前元素个数。如果元素很多,比如已经存了100万个元素,一次扩容就要把这100万个元素全部复制一遍。如果代码里频繁触发扩容,这个复制成本会非常可观。
举个例子,假设你用默认容量10的ArrayList,循环add 100万个元素。扩容次数大约是多少?从10开始,每次乘以1.5,到达100万大约需要29次扩容。每次扩容复制当前所有元素,总复制次数大约是10 + 15 + 22 + 33 + ... 加起来大概是几百万次元素复制。虽然大部分时候系统内存拷贝很快,但几百万次引用复制加上数组创建,还是会造成明显的耗时。
更麻烦的是,频繁扩容还会造成内存碎片化。旧数组在被GC回收之前,新数组已经分配出来了,这两个数组同时存在内存里,瞬间的峰值内存占用会翻倍。如果数组本身已经很大,比如500万容量,扩容到750万时,内存里会同时存在500万和750万两个数组,峰值内存吃紧。
4.2 预设容量:从源头消灭扩容
应对扩容性能问题,最直接的办法就是不让它扩容。怎么才能不让它扩容?在创建ArrayList时预估元素数量,用指定容量的构造函数。
比如你要从数据库查出10万条记录放到List里,你完全可以在查之前就知道大概会有多少条,然后直接new ArrayList<>(100000)。如果数据量不确定,也可以给一个相对宽松的估值,比如取上限。多分配几百个数组槽位根本占不了多少内存,但能省掉十几次扩容的复制开销。
如果数据量确实是动态变化的,还有一个折中办法:在批量添加之前,手动调用ensureCapacity方法。这个方法是public的,专门提供给开发者预分配容量。它内部就是调用了我们前面说过的ensureCapacityInternal。比如你要addAll一个很大的集合,可以先调用list.ensureCapacity(100000),一次性扩容到位,再执行批量add操作。这招在导入大批量数据、组装报表结果集等场景下特别好用。
4.3 ArrayList和LinkedList的选择:扩容问题真的重要吗
很多人在对比ArrayList和LinkedList时,只知道“ArrayList查快增删慢,LinkedList增删快查慢”。但如果你真正理解了扩容机制,你会发现这个对比还需要加上一个前提:ArrayList的“增”慢,很大一部分原因就来自扩容时的数组复制。如果预先分配好了容量,ArrayList的add操作其实非常快,因为它就是数组按下标赋值。
LinkedList没有扩容的概念,它每个节点都是单独new出来的,不存在“整块复制”的损耗。但它的节点对象本身有额外开销,每个节点除了存储数据,还要存前后指针,内存占用比ArrayList高。而且它对CPU缓存不友好,ArrayList是连续内存,遍历时CPU缓存命中率高,LinkedList节点分散在堆各处,遍历时需要频繁跳转。
所以实际项目里,95%以上的场景用ArrayList就够了,只要处理好容量预设,它的性能表现完全能扛住。LinkedList更多是特定场景下的选择,比如需要频繁在列表头部插入元素时。
5. 常见问题与进阶知识点
5.1 高频面试问题速答
“ArrayList默认容量是多少?什么时候第一次扩容?”
默认容量是10,但这是个延迟初始化的值。第一次add时,如果使用的是无参构造函数,底层数组会从空数组变成容量为10的数组。
“扩容多少倍?为什么是1.5倍?”
扩容后新容量是旧容量的1.5倍,即newCapacity = oldCapacity + (oldCapacity >> 1)。选择1.5倍是在“扩容次数”和“空间浪费”之间取平衡。
“扩容的时候元素是怎么搬的?”
调用Arrays.copyOf,底层是System.arraycopy,一个native方法,通过内存拷贝把旧数组元素复制到新数组。
“ArrayList最大能存多少元素?”
理论上是Integer.MAX_VALUE个,但实际受限于堆内存大小。一般超过Integer.MAX_VALUE - 8后,JVM会尝试分配Integer.MAX_VALUE大小的数组,但绝大多数机器内存根本不够。
“线程安全的ArrayList怎么扩容?”
用Collections.synchronizedList包装后的ArrayList,扩容逻辑和普通ArrayList一样,只是整个方法加了synchronized锁。而CopyOnWriteArrayList的“扩容”完全不一样,它每次add都是复制出一个新数组,修改的是副本,原数组不变,因此读操作不需要锁。
5.2 实战中容易踩的坑
第一坑:在for循环里频繁调用list.size()判断结束条件,同时循环体内又不断add导致扩容。这种写法本身不是问题,但如果循环次数极大,比如从数据库分页查数据往同一个List里塞,一定要预估总量并预设容量,否则每页数据加进来都可能触发一次扩容。
第二坑:把ArrayList的subList结果当成独立List使用,然后对原List进行add操作。subList只是原List的一个视图,当你操作subList时,原List的size发生了变化,再回头操作subList就会抛出ConcurrentModificationException。这个和扩容机制本身没有直接关系,但它和数组结构强相关,容易让人困惑。
第三坑:使用new ArrayList<>(0)初始化列表。虽然0也是一个合法参数,但如果循环往里add,第一次扩容会从0开始。0的1.5倍还是0,所以grow方法里会走那个oldCapacity为0的特殊分支,直接扩容到minCapacity,也就是第一次add时就扩到1,然后依次2、3、4、6...扩容次数非常多,性能很差。除非你能确保列表几乎不会增长,否则不要显式传0。
第四坑:误以为扩容只发生在add时。实际上addAll方法也会触发扩容,而且由于addAll一次性加入的元素可能很多,minCapacity可能很大,导致直接跳跃式扩容,而不是走1.5倍的老路。比如当前容量是10,你要一次性addAll 100个元素,minCapacity是110,1.5倍后是15,远小于110,所以新容量直接就是110。
5.3 源码阅读方法分享
源码读了这么多遍,我最大的感受是:读ArrayList源码,千万别从头到尾逐行读,而是带着问题读。你先问自己“它怎么扩容的”,然后从add方法入口进入,顺着ensureCapacityInternal、ensureExplicitCapacity、grow这个调用链往下追。追完扩容,再问“它能存null吗”,然后去看add方法有没有对null做判断,发现没有,所以能存null。再问“它线程安全吗”,看到所有方法都没有synchronized,所以不安全。
这种带着问题读源码的方式,比漫无目的地打开源码文件一行行看要高效得多。你每读一个方法,都把它和实际使用场景对应起来,脑子里就有了一幅完整的执行流程图。
6. 写在最后
ArrayList的扩容机制,说复杂其实也就是一个容量检查加一个数组复制,说简单它背后又牵扯到数组的内存布局、JVM的native方法、容量倍增带来的性能权衡。理解它最好的方式,就是打开你本地的JDK源码,找到ArrayList.java,把grow方法翻出来看一遍。然后写一个简单的测试用例,打印每次add后的数组长度变化,你会看到从10到15到22到33的跳跃曲线。
我自己当年吃过的最大教训,就是在一次数据报表导出功能里,没有预设容量,循环往ArrayList里添加几十万行数据,结果高峰期导出一次要等好几秒。后来排查发现大部分时间都耗在了扩容复制上,加了一行new ArrayList<>(rows.size())后,耗时就降到了几百毫秒。很多时候,性能和优雅就藏在这些“看起来很小”的细节里。