一文搞懂 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) 删除下标还是数字 1
- 10.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[]fixed=newString[3];fixed[0]="牛奶";fixed[1]="面包";fixed[2]="苹果";// fixed[3] = "纸巾"; // ArrayIndexOutOfBoundsExceptionList<String>cart=newArrayList<>();cart.add("牛奶");cart.add("面包");cart.add("苹果");cart.add("纸巾");System.out.println(cart);数组结构简单、内存紧凑、按下标访问快,但要提前确定长度。ArrayList 仍用数组保存元素,只是把申请新数组、复制元素和维护有效数量封装了起来。
LinkedList 通过节点引用连接。它修改链接很方便,但按下标寻找节点通常需要遍历:
| 操作 | ArrayList | LinkedList |
|---|---|---|
| 按下标读取 | O(1) | O(n) |
| 尾部添加 | 摊销O(1) | O(1) |
| 中间插入 | 定位快,搬运约O(n) | 定位约O(n),改链接O(1) |
| 内存局部性 | 较好 | 节点分散、额外保存引用 |
所以“LinkedList 插入删除永远更快”并不准确。大多数业务要先找到位置,定位成本不能忽略。
三、add、get、set、remove 基础操作
List<String>cart=newArrayList<>();cart.add("牛奶");// 尾部添加cart.add("面包");cart.add(1,"苹果");// 在下标 1 插入Stringfirst=cart.get(0);// 牛奶Stringold=cart.set(2,"纸巾");// 返回面包Stringremoved=cart.remove(1);// 删除苹果System.out.println(first);System.out.println(old);System.out.println(removed);System.out.println(cart);// [牛奶, 纸巾]下标从0开始。假设size为 3:
get、set、remove的合法下标是0~2;add(index, value)的合法下标是0~3,因为允许插到末尾;- 越界会抛出
IndexOutOfBoundsException。
size()返回有效元素数量,不是内部数组长度。购物车有 10 个格子,但只放了 3 件商品时,size 是 3,capacity 是 10。
3.1 JDK 25 中的首尾操作
ArrayList 实现的List继承了SequencedCollection,JDK 25 可以直接使用addFirst、addLast、getFirst、getLast和reversed。这些 API 让首尾语义更清楚,但addFirst仍要把已有元素整体向后移动,底层成本不会消失。
四、elementData、size 与 capacity
ArrayList 最重要的字段可以压缩成:
publicclassArrayList<E>{transientObject[]elementData;// 真正保存元素privateintsize;// 有效元素个数}放入牛奶、面包和苹果后,内部结构大致如下:
下标 0 1 2 3 4 elementData 牛奶 面包 苹果 null null size = 3 capacity = elementData.length = 5需要注意:
size只统计有效区间[0, size);- capacity 是数组长度,公开 API 没有直接读取方法;
- 后面的
null是预留槽位,不属于 List; - ArrayList 允许主动添加
null,不能靠 null 判断 size; - 删除后源码会清空不再使用的尾部槽位,避免继续引用旧对象。
五、为什么 new ArrayList<>() 没有立刻创建长度 10 的数组
OpenJDK 25 使用两个长度都是 0、但身份不同的空数组:
privatestaticfinalObject[]EMPTY_ELEMENTDATA={};privatestaticfinalObject[]DEFAULTCAPACITY_EMPTY_ELEMENTDATA={};publicArrayList(){this.elementData=DEFAULTCAPACITY_EMPTY_ELEMENTDATA;}publicArrayList(intinitialCapacity){if(initialCapacity>0){elementData=newObject[initialCapacity];}elseif(initialCapacity==0){elementData=EMPTY_ELEMENTDATA;}else{thrownewIllegalArgumentException();}}new ArrayList<>()刚执行完时,内部数组长度仍然为 0,第一次添加才创建默认长度为 10 的数组。这样,如果程序创建了很多 ArrayList,但其中一部分从未放入元素,就不用提前为每个空 List 分配 10 个槽位。
JavaDoc 把无参构造器描述为构造一个初始容量为 10 的空列表,这是对外可理解的容量语义;当前 OpenJDK 实现采用延迟分配。阅读 API 和源码时,要区分“公开行为”和“内部时机”。
六、add 与 grow:商品怎样放进购物车
尾部添加和扩容的主干可以精简为:
publicbooleanadd(Eelement){modCount++;if(size==elementData.length){grow(size+1);}elementData[size++]=element;returntrue;}privateObject[]grow(intminCapacity){intoldCapacity=elementData.length;if(oldCapacity>0||elementData!=DEFAULTCAPACITY_EMPTY_ELEMENTDATA){intnewCapacity=ArraysSupport.newLength(oldCapacity,minCapacity-oldCapacity,oldCapacity>>1);returnelementData=Arrays.copyOf(elementData,newCapacity);}returnelementData=newObject[Math.max(10,minCapacity)];}minCapacity是本次操作至少需要的容量,oldCapacity >> 1是期望增长量。ArraysSupport.newLength还要处理一次添加大量元素、整数上限和超大数组等边界,并不只是简单计算 1.5 倍。
七、10 → 15 → 22:约 1.5 倍扩容
当前 JDK 25 常规路径的期望容量可以这样理解:
intpreferredGrowth=oldCapacity>>1;intnewCapacity=oldCapacity+preferredGrowth;// 10 + 5 = 15// 15 + 7 = 22// 22 + 11 = 33右移一位相当于非负整数除以 2并向下取整,所以常说“扩容为原来的约 1.5 倍”。
每次只增加一个槽位会频繁复制;一次扩大很多倍又可能浪费内存。约 1.5 倍是在复制次数和空闲空间之间做折中。
但必须强调:
Java SE API 没有规定 ArrayList 必须按 1.5 倍扩容。业务代码只能依赖容量会自动增长和相关复杂度语义,不能依赖某个版本的具体容量序列。
扩容不是在旧数组后面直接接几格。Java 数组长度创建后不能改变,真正过程是:
旧数组 capacity=10 ↓ 创建新数组 新数组 capacity=15 ↓ 复制已有元素 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(size==elementData.length){grow(size+1);}System.arraycopy(elementData,index,elementData,index+1,size-index);elementData[index]=element;size++;}privatevoidshiftTailOverGap(Object[]data,intindex){intmoved=size-index-1;if(moved>0){System.arraycopy(data,index+1,data,index,moved);}data[--size]=null;}插入或删除的位置越靠前,需要搬动的元素越多。删除后把尾部设为null也很重要,否则内部数组可能继续强引用已经删除的对象。
九、modCount 与 fail-fast:购物清单版本变了
迭代器创建时会记住expectedModCount。如果遍历期间 ArrayList 被其他路径结构性修改,modCount发生变化,迭代器会尽快抛出ConcurrentModificationException:
List<String>cart=newArrayList<>(List.of("牛奶","过期面包","苹果"));try{for(Stringitem:cart){if(item.startsWith("过期")){cart.remove(item);// 错误}}}catch(ConcurrentModificationExceptione){System.out.println("检测到结构性修改");}Iterator<String>iterator=cart.iterator();while(iterator.hasNext()){if(iterator.next().startsWith("过期")){iterator.remove();// 正确}}fail-fast 只是尽力而为的错误检测,不是线程安全协议,也不保证所有并发修改都一定抛异常。不能依赖这个异常维持业务正确性。
十、三个高频陷阱
10.1 remove(1) 删除下标还是数字 1
10.2 subList 是视图还是副本
10.3 Arrays.asList 和 List.of 能否增删
把三个问题放在一起看:
List<Integer>numbers=newArrayList<>(List.of(1,2,3,2));numbers.remove(1);// 删除下标 1numbers.remove(Integer.valueOf(1));// 删除对象 1List<String>cart=newArrayList<>(List.of("A","B","C","D"));List<String>view=cart.subList(1,3);view.clear();System.out.println(cart);// [A, D]List<String>independent=newArrayList<>(cart.subList(0,cart.size()));List<String>fixed=Arrays.asList("牛奶","面包");List<String>immutable=List.of("牛奶","面包");// fixed.add("苹果"); // UnsupportedOperationException// immutable.set(0, "苹果"); // UnsupportedOperationExceptionremove(1)优先匹配remove(int index);要删除值 1,需要包装成Integer。
subList返回原列表的区间视图,修改会反映到原列表;需要独立集合时应再new ArrayList<>(subList)。创建视图后,如果绕开视图直接结构性修改原列表,再继续使用旧视图,可能抛出ConcurrentModificationException。
Arrays.asList返回由数组支持的定长 List,List.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{privatestaticfinalFieldDATA=elementDataField();privatestaticFieldelementDataField(){try{Fieldfield=ArrayList.class.getDeclaredField("elementData");field.setAccessible(true);returnfield;}catch(ReflectiveOperationExceptione){thrownewIllegalStateException(e);}}publicstaticvoidmain(String[]args)throwsException{ArrayList<String>list=newArrayList<>();intprevious=-1;for(inti=0;i<=23;i++){if(i>0){list.add("商品-"+i);}intcurrent=capacity(list);if(current!=previous){System.out.printf("[扩容] size=%d, capacity=%d%n",list.size(),current);previous=current;}}varcart=newArrayList<>(List.of("A","B","C","D"));cart.add(1,"X");cart.remove(3);System.out.println("[搬运] "+cart);varwrong=newArrayList<>(List.of("牛奶","面包"));varstale=wrong.iterator();wrong.add("纸巾");try{stale.next();}catch(ConcurrentModificationExceptione){System.out.println("[fail-fast] 检测到版本变化");}varsafe=newArrayList<>(List.of("牛奶","过期面包","苹果"));variterator=safe.iterator();while(iterator.hasNext()){if(iterator.next().startsWith("过期")){iterator.remove();}}System.out.println("[安全删除] "+safe);varnumbers=newArrayList<>(List.of(1,2,3,2));numbers.remove(1);numbers.remove(Integer.valueOf(1));System.out.println("[remove 重载] "+numbers);vardefaultList=newArrayList<Integer>();varplannedList=newArrayList<Integer>();plannedList.ensureCapacity(100);System.out.printf("[容量规划] 默认扩容=%d 次,提前规划=%d 次%n",countGrowths(defaultList,100),countGrowths(plannedList,100));varsequenced=newArrayList<String>();sequenced.addFirst("面包");sequenced.addFirst("牛奶");sequenced.addLast("苹果");System.out.printf("[首尾] first=%s, last=%s, reversed=%s%n",sequenced.getFirst(),sequenced.getLast(),sequenced.reversed());}privatestaticintcountGrowths(ArrayList<Integer>list,intcount)throwsException{inttimes=0,previous=capacity(list);for(inti=0;i<count;i++){list.add(i);intcurrent=capacity(list);if(current!=previous){times++;previous=current;}}returntimes;}privatestaticintcapacity(ArrayList<?>list)throwsException{return((Object[])DATA.get(list)).length;}}反射读取了 JDK 私有字段,因此运行时需要显式开放包:
javac ArrayListShoppingCartDemo.javajava--add-opens java.base/java.util=ALL-UNNAMED ArrayListShoppingCartDemo关键输出包括:
[扩容] size=0, capacity=0 [扩容] size=1, capacity=10 [扩容] size=11, capacity=15 [扩容] size=16, capacity=22 [扩容] size=23, capacity=33 [搬运] [A, X, B, D] [fail-fast] 检测到版本变化 [安全删除] [牛奶, 苹果] [remove 重载] [3, 2] [容量规划] 默认扩容=7 次,提前规划=0 次 [首尾] first=牛奶, last=苹果, reversed=[苹果, 面包, 牛奶]反射与--add-opens只用于观察实验,不应成为业务代码读取容量的方式。生产代码应依赖公开 API。
十三、总结:车有容量,货有数量,装满再搬家
- ArrayList 的底层核心是
Object[] elementData; size是有效元素数量,capacity 是内部数组长度;- 默认构造器使用空数组占位,第一次添加时才落实默认容量;
- 当前 JDK 25 常规路径约按 1.5 倍扩容;
- 具体扩容比例属于源码实现,不是 Java SE 契约;
- 扩容需要创建新数组并复制旧元素;
- 尾部添加是摊销
O(1),中间插入和删除通常需要搬运; - fail-fast 用来尽早发现错误修改,不保证线程安全;
remove重载、subList视图和不可修改 List 是高频坑;- 已知数据量时合理规划容量,可以减少复制。
一句话记忆:
数组定长,List 管账;size 看货,capacity 看车;装满扩容,复制搬家。
参考资料
- Oracle Java SE 25:ArrayList API
- Oracle Java SE 25:List API
- OpenJDK JDK 25:ArrayList.java 源码
- OpenJDK JDK 25:ArraysSupport.java 源码
本文完整示例使用 JDK 25 编译运行。源码部分为了突出主线进行了删减;实际开发请以目标 JDK 的公开 API、对应版本源码和真实压测结果为准。
- 个人小游戏