
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而数组长度是01 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和size1比大小第一次add时size1是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是15newCapacity 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倍是77 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是1101.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())后耗时就降到了几百毫秒。很多时候性能和优雅就藏在这些“看起来很小”的细节里。