资讯动态

ArrayList扩容机制全解析:从源码到性能优化实践

发布时间:2026/10/14 7:19:52 来源:尧图企业网站定制
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个元素size9数组长度10。再add一个size110等于容量10不需要扩容。但如果已经有10个元素再add一个size111大于容量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、数组赋值、size1回写三步。单线程下没问题多线程环境下就存在数据覆盖风险。这也是ArrayList被反复强调线程不安全的原因之一。3. 扩容源码逐行拆解从add到grow的完整链路3.1 ensureCapacityInternal到ensureExplicitCapacityadd方法调用的ensureCapacityInternal源码长这样基于JDK 1.8private 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还是0newCapacity0。这时候就需要兜底直接取minCapacity。比如首次addminCapacity是10newCapacity是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就直接抛OutOfMemoryErrorprivate 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条记录 ListString list new ArrayList(1000);这样在add过程中只要不超过1000个元素就完全不会触发扩容省掉了所有数组拷贝和中间态内存。那预估准确度怎么保证几个经验数据量稳定且可数比如从数据库查出的记录数、解析固定格式文件的行数直接给容量等于预估上限别给精确值留点余量数据量波动大可以给一个偏保守的上界比如平时最多500高峰可能800那就new ArrayList(800)如果你确实不知道多大但知道会持续add可以分阶段扩容list.ensureCapacity(nextSize)手动触发一次性扩容避免多次半途扩容这里还要提一个方法trimToSize()。它能把底层数组的大小调整到跟size一样去掉多余空间ArrayListInteger 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)专门让调用方在插入大量数据前主动扩容ArrayListUser users new ArrayList(); int estimatedSize fetchCountFromDb(); users.ensureCapacity(estimatedSize); // 然后开始批量add这方法底层走的就是ensureCapacityInternal但它会先判断当前容量是否够够就直接返回不够才扩容。所以你可以放心调用不会有多余开销。可能有人问那我在循环里每次add前都调用ensureCapacity(size100)行不行当然可以但这属于过度设计。ArrayList本身的设计目标就是自动扩容足够好用你只需要在数据量变化跨越量级时比如从百级到万级提前打一针预防针没必要精确到每次add都管。4.4 为什么选1.5倍而不是2倍数学层面的门道这是面试里最高频的追问。直接说结论1.5倍是在空间利用率和时间开销之间折中的结果。如果扩容倍数设为2倍那么扩容后新数组容量是2^{n1}而旧数组是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的结论不依赖初始容量NN只影响扩容次数而不影响总搬运量。用2倍的话总搬运量也差不多是2M但峰值内存大用1.2倍的话峰值内存小但扩容次数更多。1.5倍算是平衡点。5. 常见问题与排查技巧实录5.1 线上问题批量导入接口偶发超时先说一个我实际遇到的案例。某次做数据同步功能需要把一批外部数据解析后批量写入ArrayList再统一入库。代码写得很快直接new ArrayList()然后循环add数据量在高峰期能到几万条。上线后监控发现这个接口的P99延迟在高峰期明显上升Young GC次数也异常。排查思路先看GC日志确认对象分配速率极高。JFRJava 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[]。如果你把一个ArrayListString的toArray()结果强转成String[]运行时直接抛ClassCastException。必须用带泛型的toArray(T[] a)才行。5.4 扩容相关参数速查表参数/行为说明DEFAULT_CAPACITY默认容量10首次add时生效MAX_ARRAY_SIZEInteger.MAX_VALUE - 8扩容倍数1.5倍oldCapacity 1触发条件add时size 1 elementData.length均摊复杂度add操作均摊O(1)最坏复杂度单次扩容O(n)n为当前size线程安全性不安全多线程add会丢数据modCount作用记录结构性修改次数支持Fail-Fast6. 从扩容机制到集合选型什么时候不用ArrayList6.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倍五个字值钱得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑