- 文档
- 教程
- 后端
【免费下载链接】JCSprout
👨🎓 Java Core Sprout : basic, concurrent, algorithm
导读
本文基于 JCSprout 仓库中的《ArrayList/Vector 的底层分析》文档(见 docs/collections/ArrayList.md 及其原始版本 MD/ArrayList.md),结合仓库内真实的 JMH 基准测试源码 CollectionsTest.java,系统拆解 ArrayList 的动态数组扩容机制、指定位置插入的数组拷贝开销、自定义序列化实现,以及 Vector 作为同步容器的线程安全策略。读完本文,你将能深入理解 ArrayList 与 Vector 的底层工作原理,掌握"指定初始容量、减少指定位置插入"等实战优化手段,并能在面试中从源码层面讲清两者的本质区别。
一、ArrayList 概览:动态数组的接口与核心属性
ArrayList实现了List与RandomAccess接口,是一个基于动态数组实现的顺序存储结构。它具备两个关键能力:
- 可以插入空数据(null):底层数组只存放对象引用,不限制空值;
- 支持随机访问:实现了
RandomAccess标记接口后,通过下标即可在 O(1) 时间内访问元素。
在 ArrayList 中,最重要的两个属性分别是:
elementData:真正存放数据的Object[]数组,也就是动态数组的"容器";size:当前实际存放的元素个数,注意它并不等同于数组的容量(elementData.length),数组中往往存在未被使用的空位。
正是因为"数组容量 >= 实际元素个数",才衍生出了扩容与自定义序列化这两大核心话题,下文将逐一展开。
二、add() 的扩容校验与尾部追加
当调用无参的add(E e)向尾部追加元素时,源码逻辑如下:
public boolean add(E e) { ensureCapacityInternal(size + 1); // Increments modCount!! elementData[size++] = e; return true; }整个过程只有两步:
- 扩容校验:调用
ensureCapacityInternal(size + 1),确保数组至少有size + 1的空位; - 尾部追加:将新元素写入
elementData[size],随后size自增 1,完成追加。
这里传入的是size + 1而不是固定值,原因是扩容只在"空间不足"时才触发:只要当前容量足够,就只是 O(1) 的数组赋值,这也是尾部追加(append)效率远高于指定位置插入的根本原因。
三、add(index, e) 的数组拷贝与数据搬移
在指定位置插入数据时,ArrayList 无法像链表那样只移动指针,它必须为腾出插入位置而搬移后续所有元素:
public void add(int index, E element) { rangeCheckForAdd(index); ensureCapacityInternal(size + 1); // Increments modCount!! //复制,向后移动 System.arraycopy(elementData, index, elementData, index + 1, size - index); elementData[index] = element; size++; }具体步骤为:
- 下标校验:
rangeCheckForAdd(index)检查index是否在[0, size]范围内,越界会抛出IndexOutOfBoundsException; - 扩容校验:与尾部追加一样,先确保容量足够;
- 数组搬移:通过
System.arraycopy将[index, size)区间内的元素整体向后移动一位,把index位置空出来——这一步的时间复杂度为 O(n),是最主要的性能开销; - 写入与自增:将新元素写入
elementData[index],size++。
System.arraycopy是 JVM 提供的高效原生数组复制方法,即便如此,当index越靠近数组头部、被搬移的元素越多时,代价就越大。因此在实际业务中,应当尽量减少在 ArrayList 头部或中间插入数据的操作;若确实存在大量此类需求,应优先考虑 LinkedList(其插入仅需移动指针)。
四、扩容机制 grow():1.5 倍增长与溢出保护
无论是尾部追加还是指定位置插入,扩容校验最终都会汇聚到grow()方法,它才是真正执行扩容的核心:
private void grow(int minCapacity) { // overflow-conscious code int oldCapacity = elementData.length; int newCapacity = oldCapacity + (oldCapacity >> 1); if (newCapacity - minCapacity < 0) newCapacity = minCapacity; if (newCapacity - MAX_ARRAY_SIZE > 0) newCapacity = hugeCapacity(minCapacity); // minCapacity is usually close to size, so this is a win: elementData = Arrays.copyOf(elementData, newCapacity); }这段代码的关键点如下:
| 步骤 | 说明 |
|---|---|
| 计算新容量 | newCapacity = oldCapacity + (oldCapacity >> 1),即扩容为原来的 1.5 倍(右移一位等价于除以 2)。例如容量 10 扩容后为 15,容量 100 扩容后为 150 |
| 最小容量兜底 | 若 1.5 倍后的容量仍小于minCapacity(例如首次添加时旧容量为 0),则直接取minCapacity作为新容量 |
| 上限保护 | 若新容量超过MAX_ARRAY_SIZE(一般指Integer.MAX_VALUE - 8),则转入hugeCapacity(minCapacity)做最大容量处理,防止数组过大导致 OOM |
| 执行扩容 | elementData = Arrays.copyOf(elementData, newCapacity),本质仍是一次数组复制:申请新数组并把旧数据整体拷贝过去 |
从代码中的注释overflow-conscious code也可以看出,JDK 在容量计算上刻意做了防溢出设计(例如用newCapacity - minCapacity < 0而非newCapacity < minCapacity,避免极端情况下加法溢出后误判)。
由此可以得出一个重要结论:ArrayList 的主要性能消耗集中在数组扩容与指定位置插入,两者本质上都是"数组复制"。因此日常使用时,最佳实践是:
- 在构造时预估并指定初始容量,如
new ArrayList<>(10000),尽可能减少扩容次数; - 避免在指定位置插入数据,尤其避免在头部/中部频繁插入。
仓库中的 JMH 基准测试 CollectionsTest.java 正是对这一结论的量化验证:它对比了new ArrayList<>()(默认容量,需多次扩容)、new ArrayList<>(TEN_MILLION)(预分配容量)和new LinkedList<>()三种方式各自向列表追加一千万个元素时的平均耗时(Mode.AverageTime,单位微秒),是理解"预分配容量能显著降低扩容拷贝开销"的可复现实验。
五、序列化优化:transient + 自定义 writeObject/readObject
由于 ArrayList 基于动态数组实现,elementData的实际长度(容量)往往大于已使用的size。如果直接序列化整个数组,会把大量未被使用的空位也写进流中,造成空间浪费。
为此 ArrayList 做了两件事:
1. 用transient修饰数组,屏蔽默认序列化:
transient Object[] elementData;transient关键字告诉 JVM:默认序列化机制不要序列化这个字段。
2. 自定义序列化与反序列化方法:
private void writeObject(java.io.ObjectOutputStream s) throws java.io.IOException{ // Write out element count, and any hidden stuff int expectedModCount = modCount; s.defaultWriteObject(); // Write out size as capacity for behavioural compatibility with clone() s.writeInt(size); // Write out all elements in the proper order. //只序列化了被使用的数据 for (int i=0; i<size; i++) { s.writeObject(elementData[i]); } if (modCount != expectedModCount) { throw new ConcurrentModificationException(); } } private void readObject(java.io.ObjectInputStream s) throws java.io.IOException, ClassNotFoundException { elementData = EMPTY_ELEMENTDATA; // Read in size, and any hidden stuff s.defaultReadObject(); // Read in capacity s.readInt(); // ignored if (size > 0) { // be like clone(), allocate array based upon size not capacity ensureCapacityInternal(size); Object[] a = elementData; // Read in all elements in the proper order. for (int i=0; i<size; i++) { a[i] = s.readObject(); } } }这里有两个值得深入理解的设计点:
- 序列化契约:当对象中自定义了
writeObject和readObject方法时,JVM 会优先调用这两个自定义方法来实现序列化与反序列化,而不再使用默认的反射式序列化流程; - 只序列化有效数据:
writeObject只遍历[0, size)区间逐个写出被使用的元素,空位完全被跳过;readObject则按size读取,并通过ensureCapacityInternal(size)按"实际元素个数"而非"原始容量"重新分配数组(代码注释allocate array based upon size not capacity也印证了这一点,且与clone()行为保持兼容)。
此外,两个方法都在读写完成后校验modCount与expectedModCount是否一致,若不一致则抛出ConcurrentModificationException,这正是对"序列化过程中集合被并发修改"的防御性检测。
六、Vector:synchronized 加持的同步容器
Vector同样实现于List接口,底层数据结构和ArrayList类似,也是一个动态数组。两者的核心差异在于:Vector 在add()方法上使用synchronized进行同步写数据。
public synchronized boolean add(E e) { modCount++; ensureCapacityHelper(elementCount + 1); elementData[elementCount++] = e; return true; }指定位置插入时同样走同步路径:
public void add(int index, E element) { insertElementAt(element, index); } public synchronized void insertElementAt(E obj, int index) { modCount++; if (index > elementCount) { throw new ArrayIndexOutOfBoundsException(index + " > " + elementCount); } ensureCapacityHelper(elementCount + 1); System.arraycopy(elementData, index, elementData, index + 1, elementCount - index); elementData[index] = obj; elementCount++; }从源码可以清晰看出:
insertElementAt是synchronized方法,且先对index做了越界检查(index > elementCount时抛出ArrayIndexOutOfBoundsException),随后同样是"扩容校验 +System.arraycopy搬移 + 写入"的动态数组套路;- Vector 通过
synchronized保证了单次写操作的原子性,但这也意味着每个方法调用都要经历加锁/解锁的完整开销。
因此,从并发编程的角度严格来说:Vector 是一个同步容器(synchronized container),而不是并发容器(concurrent container)。原因在于:
- 它使用粗粒度的方法级锁,锁的粒度大、开销高,多线程竞争时吞吐量受限;
- 单个方法内部虽然安全,但"先判断再操作"这类复合操作(如
if (!v.isEmpty()) v.get(0))依然存在竞态窗口,无法提供真正的并发安全; - 现代 Java 并发编程中,一般推荐使用
CopyOnWriteArrayList、Collections.synchronizedList()或并发包下的其他容器来替代 Vector。
七、实战建议与源码验证
综合全文,对 ArrayList 与 Vector 的选型和使用,可以给出如下经过源码验证的结论:
- 能预估规模就预分配容量:
new ArrayList<>(expectedSize)可以大幅减少grow()触发的数组复制次数,这是仓库基准测试 CollectionsTest.java 中专门对比的场景; - 避免指定位置插入:
add(index, e)需要System.arraycopy搬移 O(n) 个元素,频繁在中部/头部插入应改用 LinkedList; - 随机访问场景坚持用 ArrayList:实现
RandomAccess接口使其按下标访问为 O(1),而 LinkedList 的node()最坏需要 O(n/2) 遍历; - 并发场景别用 Vector:方法级
synchronized使其成为高开销的同步容器而非并发容器,应选择 JUC 包下的并发容器; - 理解序列化语义:ArrayList 通过
transient数组 + 自定义writeObject/readObject只序列化有效数据,这也是面试中关于"为什么 ArrayList 的 elementData 用 transient 修饰"的标准答案。
本仓库的 docs/collections/ArrayList.md 为本文核心参考文档,其原始版本位于 MD/ArrayList.md,可在阅读时相互对照;同系列文档 docs/collections/LinkedList.md 则从链表角度给出了与动态数组的对比视角,建议一并阅读以形成完整的 List 体系认知。
- 文档
- 教程
- 后端
【免费下载链接】JCSprout
👨🎓 Java Core Sprout : basic, concurrent, algorithm
相关推荐
JCSprout项目解析:深入理解ArrayList与Vector的底层实现
JCSprout项目解析:深入理解ArrayList与Vector的底层实现 引言:为什么需要深入理解ArrayList和Vector? 在日常Java开发中,
文档教程后端JCSprout 源码解读:从 HashMap 到 HashSet,Java 去重集合的底层实现原理
JCSprout 源码解读:从 HashMap 到 HashSet,Java 去重集合的底层实现原理 HashSet 是 Java 集合框架中最常用的去重容器,
文档教程后端JCSprout 源码精读:LinkedList 底层双向链表实现与增查性能分析
JCSprout 源码精读:LinkedList 底层双向链表实现与增查性能分析 导读 本文基于 JCSprout 知识库中的 LinkedList 底层分析
文档教程后端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考