一文搞懂 Java ArrayList:把它想成一辆会自动扩容的超市购物车
文章目录一、开场购物车装满以后是换车还是自动加长二、数组、ArrayList 与 LinkedList 怎么选三、add、get、set、remove 基础操作3.1 JDK 25 中的首尾操作四、elementData、size 与 capacity五、为什么 new ArrayList() 没有立刻创建长度 10 的数组六、add 与 grow商品怎样放进购物车七、10 → 15 → 22约 1.5 倍扩容八、中间插入与删除为什么比较贵九、modCount 与 fail-fast购物清单版本变了十、三个高频陷阱10.1 remove(1) 删除下标还是数字 110.2 subList 是视图还是副本10.3 Arrays.asList 和 List.of 能否增删十一、容量优化与并发边界十二、完整实验观察扩容、搬运与遍历十三、总结车有容量货有数量装满再搬家参考资料一、开场购物车装满以后是换车还是自动加长周末晚上超市正在促销。小李把牛奶、面包、水果和纸巾不断放进购物车很快就没有空位了。普通购物车装不下时我们只能换一辆更大的车再把原来的商品搬过去。如果这辆购物车叫ArrayList它会自动完成这件事发现内部数组已经没有空位申请容量更大的新数组把旧数组里的元素复制过去让elementData指向新数组放入新元素旧数组等待垃圾回收。这就是 ArrayList 的核心外表是一张可以不断添加元素的 List内部仍然是一块长度固定的数组所谓自动扩容其实是创建新数组并搬家。超市场景ArrayList 概念购物车ArrayList对象固定格子内部数组elementData已放入的商品数size一共拥有的格子数capacity放到车尾add(E e)换更大的车grow(minCapacity)搬运旧商品Arrays.copyOf/System.arraycopy购物清单版本modCount本文以 Java SE 25 API 和 OpenJDK JDK 25 源码为背景。JavaDoc 承诺 ArrayList 会自动增长并说明尾部追加具有摊销常数时间具体扩容比例属于实现细节。二、数组、ArrayList 与 LinkedList 怎么选数组创建后长度不能改变ArrayList 则帮我们管理扩容String[]fixednewString[3];fixed[0]牛奶;fixed[1]面包;fixed[2]苹果;// fixed[3] 纸巾; // ArrayIndexOutOfBoundsExceptionListStringcartnewArrayList();cart.add(牛奶);cart.add(面包);cart.add(苹果);cart.add(纸巾);System.out.println(cart);数组结构简单、内存紧凑、按下标访问快但要提前确定长度。ArrayList 仍用数组保存元素只是把申请新数组、复制元素和维护有效数量封装了起来。LinkedList 通过节点引用连接。它修改链接很方便但按下标寻找节点通常需要遍历操作ArrayListLinkedList按下标读取O(1)O(n)尾部添加摊销O(1)O(1)中间插入定位快搬运约O(n)定位约O(n)改链接O(1)内存局部性较好节点分散、额外保存引用所以“LinkedList 插入删除永远更快”并不准确。大多数业务要先找到位置定位成本不能忽略。三、add、get、set、remove 基础操作ListStringcartnewArrayList();cart.add(牛奶);// 尾部添加cart.add(面包);cart.add(1,苹果);// 在下标 1 插入Stringfirstcart.get(0);// 牛奶Stringoldcart.set(2,纸巾);// 返回面包Stringremovedcart.remove(1);// 删除苹果System.out.println(first);System.out.println(old);System.out.println(removed);System.out.println(cart);// [牛奶, 纸巾]下标从0开始。假设size为 3get、set、remove的合法下标是02add(index, value)的合法下标是03因为允许插到末尾越界会抛出IndexOutOfBoundsException。size()返回有效元素数量不是内部数组长度。购物车有 10 个格子但只放了 3 件商品时size 是 3capacity 是 10。3.1 JDK 25 中的首尾操作ArrayList 实现的List继承了SequencedCollectionJDK 25 可以直接使用addFirst、addLast、getFirst、getLast和reversed。这些 API 让首尾语义更清楚但addFirst仍要把已有元素整体向后移动底层成本不会消失。四、elementData、size 与 capacityArrayList 最重要的字段可以压缩成publicclassArrayListE{transientObject[]elementData;// 真正保存元素privateintsize;// 有效元素个数}放入牛奶、面包和苹果后内部结构大致如下下标 0 1 2 3 4 elementData 牛奶 面包 苹果 null null size 3 capacity elementData.length 5需要注意size只统计有效区间[0, size)capacity 是数组长度公开 API 没有直接读取方法后面的null是预留槽位不属于 ListArrayList 允许主动添加null不能靠 null 判断 size删除后源码会清空不再使用的尾部槽位避免继续引用旧对象。五、为什么 new ArrayList() 没有立刻创建长度 10 的数组OpenJDK 25 使用两个长度都是 0、但身份不同的空数组privatestaticfinalObject[]EMPTY_ELEMENTDATA{};privatestaticfinalObject[]DEFAULTCAPACITY_EMPTY_ELEMENTDATA{};publicArrayList(){this.elementDataDEFAULTCAPACITY_EMPTY_ELEMENTDATA;}publicArrayList(intinitialCapacity){if(initialCapacity0){elementDatanewObject[initialCapacity];}elseif(initialCapacity0){elementDataEMPTY_ELEMENTDATA;}else{thrownewIllegalArgumentException();}}new ArrayList()刚执行完时内部数组长度仍然为 0第一次添加才创建默认长度为 10 的数组。这样如果程序创建了很多 ArrayList但其中一部分从未放入元素就不用提前为每个空 List 分配 10 个槽位。JavaDoc 把无参构造器描述为构造一个初始容量为 10 的空列表这是对外可理解的容量语义当前 OpenJDK 实现采用延迟分配。阅读 API 和源码时要区分“公开行为”和“内部时机”。六、add 与 grow商品怎样放进购物车尾部添加和扩容的主干可以精简为publicbooleanadd(Eelement){modCount;if(sizeelementData.length){grow(size1);}elementData[size]element;returntrue;}privateObject[]grow(intminCapacity){intoldCapacityelementData.length;if(oldCapacity0||elementData!DEFAULTCAPACITY_EMPTY_ELEMENTDATA){intnewCapacityArraysSupport.newLength(oldCapacity,minCapacity-oldCapacity,oldCapacity1);returnelementDataArrays.copyOf(elementData,newCapacity);}returnelementDatanewObject[Math.max(10,minCapacity)];}minCapacity是本次操作至少需要的容量oldCapacity 1是期望增长量。ArraysSupport.newLength还要处理一次添加大量元素、整数上限和超大数组等边界并不只是简单计算 1.5 倍。七、10 → 15 → 22约 1.5 倍扩容当前 JDK 25 常规路径的期望容量可以这样理解intpreferredGrowtholdCapacity1;intnewCapacityoldCapacitypreferredGrowth;// 10 5 15// 15 7 22// 22 11 33右移一位相当于非负整数除以 2并向下取整所以常说“扩容为原来的约 1.5 倍”。每次只增加一个槽位会频繁复制一次扩大很多倍又可能浪费内存。约 1.5 倍是在复制次数和空闲空间之间做折中。但必须强调Java SE API 没有规定 ArrayList 必须按 1.5 倍扩容。业务代码只能依赖容量会自动增长和相关复杂度语义不能依赖某个版本的具体容量序列。扩容不是在旧数组后面直接接几格。Java 数组长度创建后不能改变真正过程是旧数组 capacity10 ↓ 创建新数组 新数组 capacity15 ↓ 复制已有元素 elementData 指向新数组 ↓ 旧数组等待 GC扩容那一次需要复制元素成本为O(n)但不是每次追加都扩容因此连续添加 n 个元素的总成本仍为O(n)单次追加表现为摊销O(1)。八、中间插入与删除为什么比较贵在[A, B, C, D]的下标 1 插入 X需要把 B、C、D 向后搬删除中间元素则要把后面的内容向前补位publicvoidadd(intindex,Eelement){rangeCheckForAdd(index);modCount;if(sizeelementData.length){grow(size1);}System.arraycopy(elementData,index,elementData,index1,size-index);elementData[index]element;size;}privatevoidshiftTailOverGap(Object[]data,intindex){intmovedsize-index-1;if(moved0){System.arraycopy(data,index1,data,index,moved);}data[--size]null;}插入或删除的位置越靠前需要搬动的元素越多。删除后把尾部设为null也很重要否则内部数组可能继续强引用已经删除的对象。九、modCount 与 fail-fast购物清单版本变了迭代器创建时会记住expectedModCount。如果遍历期间 ArrayList 被其他路径结构性修改modCount发生变化迭代器会尽快抛出ConcurrentModificationExceptionListStringcartnewArrayList(List.of(牛奶,过期面包,苹果));try{for(Stringitem:cart){if(item.startsWith(过期)){cart.remove(item);// 错误}}}catch(ConcurrentModificationExceptione){System.out.println(检测到结构性修改);}IteratorStringiteratorcart.iterator();while(iterator.hasNext()){if(iterator.next().startsWith(过期)){iterator.remove();// 正确}}fail-fast 只是尽力而为的错误检测不是线程安全协议也不保证所有并发修改都一定抛异常。不能依赖这个异常维持业务正确性。十、三个高频陷阱10.1 remove(1) 删除下标还是数字 110.2 subList 是视图还是副本10.3 Arrays.asList 和 List.of 能否增删把三个问题放在一起看ListIntegernumbersnewArrayList(List.of(1,2,3,2));numbers.remove(1);// 删除下标 1numbers.remove(Integer.valueOf(1));// 删除对象 1ListStringcartnewArrayList(List.of(A,B,C,D));ListStringviewcart.subList(1,3);view.clear();System.out.println(cart);// [A, D]ListStringindependentnewArrayList(cart.subList(0,cart.size()));ListStringfixedArrays.asList(牛奶,面包);ListStringimmutableList.of(牛奶,面包);// fixed.add(苹果); // UnsupportedOperationException// immutable.set(0, 苹果); // UnsupportedOperationExceptionremove(1)优先匹配remove(int index)要删除值 1需要包装成Integer。subList返回原列表的区间视图修改会反映到原列表需要独立集合时应再new ArrayList(subList)。创建视图后如果绕开视图直接结构性修改原列表再继续使用旧视图可能抛出ConcurrentModificationException。Arrays.asList返回由数组支持的定长 ListList.of返回不可修改 List它们都不是普通可增删 ArrayList。十一、容量优化与并发边界已知大致数据量时可以指定初始容量或使用ensureCapacity可以直接写new ArrayList(expectedSize)也可以先创建列表再调用ensureCapacity(expectedSize)。提前规划能减少扩容和复制但容量设置得过大也会浪费内存。trimToSize()可以把容量缩到当前 size却同样需要创建新数组并复制元素不适合在热路径里反复调用。ArrayList 不是线程安全容器。并发修改时应在外部用锁保护完整操作或根据场景评估Collections.synchronizedList、CopyOnWriteArrayList。即便使用 synchronizedList遍历期间也要按照文档要求在同一把锁上同步。十二、完整实验观察扩容、搬运与遍历下面的程序一次验证扩容序列、元素搬运、fail-fast、安全删除、remove重载、容量规划和首尾操作importjava.lang.reflect.Field;importjava.util.*;publicclassArrayListShoppingCartDemo{privatestaticfinalFieldDATAelementDataField();privatestaticFieldelementDataField(){try{FieldfieldArrayList.class.getDeclaredField(elementData);field.setAccessible(true);returnfield;}catch(ReflectiveOperationExceptione){thrownewIllegalStateException(e);}}publicstaticvoidmain(String[]args)throwsException{ArrayListStringlistnewArrayList();intprevious-1;for(inti0;i23;i){if(i0){list.add(商品-i);}intcurrentcapacity(list);if(current!previous){System.out.printf([扩容] size%d, capacity%d%n,list.size(),current);previouscurrent;}}varcartnewArrayList(List.of(A,B,C,D));cart.add(1,X);cart.remove(3);System.out.println([搬运] cart);varwrongnewArrayList(List.of(牛奶,面包));varstalewrong.iterator();wrong.add(纸巾);try{stale.next();}catch(ConcurrentModificationExceptione){System.out.println([fail-fast] 检测到版本变化);}varsafenewArrayList(List.of(牛奶,过期面包,苹果));variteratorsafe.iterator();while(iterator.hasNext()){if(iterator.next().startsWith(过期)){iterator.remove();}}System.out.println([安全删除] safe);varnumbersnewArrayList(List.of(1,2,3,2));numbers.remove(1);numbers.remove(Integer.valueOf(1));System.out.println([remove 重载] numbers);vardefaultListnewArrayListInteger();varplannedListnewArrayListInteger();plannedList.ensureCapacity(100);System.out.printf([容量规划] 默认扩容%d 次提前规划%d 次%n,countGrowths(defaultList,100),countGrowths(plannedList,100));varsequencednewArrayListString();sequenced.addFirst(面包);sequenced.addFirst(牛奶);sequenced.addLast(苹果);System.out.printf([首尾] first%s, last%s, reversed%s%n,sequenced.getFirst(),sequenced.getLast(),sequenced.reversed());}privatestaticintcountGrowths(ArrayListIntegerlist,intcount)throwsException{inttimes0,previouscapacity(list);for(inti0;icount;i){list.add(i);intcurrentcapacity(list);if(current!previous){times;previouscurrent;}}returntimes;}privatestaticintcapacity(ArrayList?list)throwsException{return((Object[])DATA.get(list)).length;}}反射读取了 JDK 私有字段因此运行时需要显式开放包javac ArrayListShoppingCartDemo.javajava--add-opens java.base/java.utilALL-UNNAMED ArrayListShoppingCartDemo关键输出包括[扩容] size0, capacity0 [扩容] size1, capacity10 [扩容] size11, capacity15 [扩容] size16, capacity22 [扩容] size23, capacity33 [搬运] [A, X, B, D] [fail-fast] 检测到版本变化 [安全删除] [牛奶, 苹果] [remove 重载] [3, 2] [容量规划] 默认扩容7 次提前规划0 次 [首尾] first牛奶, last苹果, reversed[苹果, 面包, 牛奶]反射与--add-opens只用于观察实验不应成为业务代码读取容量的方式。生产代码应依赖公开 API。十三、总结车有容量货有数量装满再搬家ArrayList 的底层核心是Object[] elementDatasize是有效元素数量capacity 是内部数组长度默认构造器使用空数组占位第一次添加时才落实默认容量当前 JDK 25 常规路径约按 1.5 倍扩容具体扩容比例属于源码实现不是 Java SE 契约扩容需要创建新数组并复制旧元素尾部添加是摊销O(1)中间插入和删除通常需要搬运fail-fast 用来尽早发现错误修改不保证线程安全remove重载、subList视图和不可修改 List 是高频坑已知数据量时合理规划容量可以减少复制。一句话记忆数组定长List 管账size 看货capacity 看车装满扩容复制搬家。参考资料Oracle Java SE 25ArrayList APIOracle Java SE 25List APIOpenJDK JDK 25ArrayList.java 源码OpenJDK JDK 25ArraysSupport.java 源码本文完整示例使用 JDK 25 编译运行。源码部分为了突出主线进行了删减实际开发请以目标 JDK 的公开 API、对应版本源码和真实压测结果为准。个人小游戏