ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Java顺序表实现:从数组到ArrayList的底层原理与性能优化

2026/8/16 2:41:20 拓冰建站 浏览量
Java顺序表实现:从数组到ArrayList的底层原理与性能优化 1. 从“线性表”到“顺序表”一个Java开发者的底层数据结构认知重塑如果你是一名Java开发者或者正在学习Java那么“顺序表”这个词对你来说可能既熟悉又陌生。熟悉是因为它在各种面试八股文、算法教程里高频出现陌生则是因为在Java的日常开发中我们几乎不会直接去写一个“顺序表”的类我们用的是ArrayList。那么为什么我们还要去理解甚至手写顺序表的代码呢这恰恰是很多初学者甚至一些工作了几年的开发者容易陷入的误区把API调用等同于底层原理掌握。今天我们就抛开ArrayList这个“黑盒”从最原始的数组出发用Java代码一步步构建一个自己的顺序表并在这个过程中彻底厘清线性表、数组、顺序表、链表、队列、栈这些基础数据结构之间纠缠不清的关系。这不仅是应对“Java面试八股文”的敲门砖更是你深入理解JVM内存模型比如面对OutOfMemoryError: Java heap space时、优化程序性能、乃至设计更优雅代码的底层基石。2. 概念澄清数组、线性表与顺序表的三角关系在动手写代码之前我们必须先理清几个核心概念否则很容易陷入“知其然不知其所以然”的境地。我看到很多网络热词如“线性表、数组、顺序表、链表、队列、栈的关系”被频繁搜索这说明混淆普遍存在。2.1 线性表一个抽象的行为规范首先线性表Linear List是一种逻辑结构。它描述的是一种数据元素之间“一对一”的线性关系。你可以把它想象成一条线线上的每个点数据元素除了第一个和最后一个都恰好有一个直接前驱和一个直接后继。线性表定义了一组操作比如获取元素、插入、删除、查找长度等但它并不规定这些操作在计算机内存中具体如何实现。它是一种“接口”或“抽象数据类型”ADT。2.2 数组一块连续的内存空间数组Array是一种物理存储结构。它在内存中申请一块连续的地址空间用来存放一组相同类型的数据。通过下标索引可以直接计算出任何一个元素的内存地址从而实现O(1)时间的随机访问。这是数组最大的优势。但是数组的容量在创建时就必须确定并且之后无法改变在Java等语言中这导致了它在插入、删除元素时可能需要进行大量的数据搬移效率较低。2.3 顺序表用数组实现的线性表现在我们把两者结合起来。顺序表Sequential List就是用数组这种存储结构来实现线性表逻辑结构的一种具体方式。它是线性表的一种物理实现。这里有一个关键的认知点ArrayList就是一个顺序表。它内部维护了一个Object[]或E[]数组在JDK中具体是Object[] elementData来存储所有元素。当我们调用add(E e)时它就是在操作这个内部数组。它通过封装动态地处理数组扩容当容量不足时新建一个更大的数组并把老数据拷贝过去让我们感觉像是在使用一个“可自动增长”的数组但其本质依然是数组。所以它们的关系是线性表接口/规范 - 顺序表一种实现 - 数组底层存储。链表则是用指针或引用连接的非连续存储来实现线性表的另一种方式。理解了这个我们再去看“队列”和“栈”。它们是特殊的线性表限制了插入和删除的位置队尾入、队首出栈顶入、栈顶出。它们既可以用顺序表数组实现也可以用链表实现。例如ArrayDeque是基于数组的双端队列而LinkedList可以作为队列或栈使用其底层是链表。3. 手撕Java顺序表从零实现核心功能理论清晰后我们开始实战。我们将实现一个泛型顺序表MyArrayListE包含最核心的功能。这个过程会让你对ArrayList的源码有更深刻的理解。3.1 类的骨架与核心成员变量我们首先定义类的骨架和两个核心的私有成员变量。/** * 一个简易的泛型顺序表实现 * param E 元素类型 */ public class MyArrayListE { // 默认初始容量 private static final int DEFAULT_CAPACITY 10; // 内部存储数据的数组 private Object[] elementData; // 顺序表中当前实际存储的元素个数 (size elementData.length) private int size; /** * 构造一个具有默认初始容量的顺序表 */ public MyArrayList() { this.elementData new Object[DEFAULT_CAPACITY]; this.size 0; } /** * 构造一个具有指定初始容量的顺序表 * param initialCapacity 指定的初始容量 * throws IllegalArgumentException 如果初始容量为负数 */ public MyArrayList(int initialCapacity) { if (initialCapacity 0) { this.elementData new Object[initialCapacity]; } else if (initialCapacity 0) { this.elementData new Object[]{}; // 空数组 } else { throw new IllegalArgumentException(Illegal Capacity: initialCapacity); } this.size 0; } }关键点解析为什么用Object[]而不是E[]这是Java泛型擦除导致的。在运行时E被擦除为Object。直接声明E[] elementData (E[]) new Object[capacity];也是常见写法但会有“未检查的转换”警告。使用Object[]在获取元素时进行强制转换是更直观和JDK源码采用的方式。size的意义size代表逻辑上的元素个数是用户感知的“列表长度”。elementData.length是物理上的数组容量总是 size。这是理解顺序表一切操作的基础。3.2 基础辅助方法扩容与边界检查在实现增删改查前我们需要两个关键的私有辅助方法。/** * 确保内部数组有至少minCapacity的容量如果不够则进行扩容 * param minCapacity 所需的最小容量 */ private void ensureCapacity(int minCapacity) { int oldCapacity elementData.length; if (minCapacity oldCapacity) { // 新容量 旧容量的1.5倍 (这是ArrayList的经典扩容策略) int newCapacity oldCapacity (oldCapacity 1); // 如果1.5倍仍不够则直接使用minCapacity if (newCapacity minCapacity) { newCapacity minCapacity; } // 如果新容量超过了数组最大限制接近Integer.MAX_VALUE进行大容量处理 if (newCapacity Integer.MAX_VALUE - 8) { newCapacity hugeCapacity(minCapacity); } // 创建新数组并拷贝数据 elementData Arrays.copyOf(elementData, newCapacity); } } private static int hugeCapacity(int minCapacity) { if (minCapacity 0) { // 溢出 throw new OutOfMemoryError(); } return (minCapacity Integer.MAX_VALUE - 8) ? Integer.MAX_VALUE : Integer.MAX_VALUE - 8; } /** * 检查给定的索引是否在有效范围内 [0, size) * param index 要检查的索引 * throws IndexOutOfBoundsException 如果索引越界 */ private void rangeCheck(int index) { if (index 0 || index size) { throw new IndexOutOfBoundsException(Index: index , Size: size); } } /** * 检查给定的索引是否在可插入范围内 [0, size] (用于add操作) * param index 要检查的索引 */ private void rangeCheckForAdd(int index) { if (index 0 || index size) { throw new IndexOutOfBoundsException(Index: index , Size: size); } }经验与避坑扩容策略oldCapacity (oldCapacity 1)等价于oldCapacity * 1.5。右移一位(1)是除以2的快速运算。这是ArrayList的标准扩容策略在空间和时间效率上取得了较好的平衡。盲目扩容2倍可能浪费空间扩容太小又会导致频繁拷贝。大容量处理当所需容量接近Integer.MAX_VALUE时直接扩容1.5倍可能导致整型溢出变成负数。hugeCapacity方法模仿了JDK的处理这是一个容易被忽略的边界条件但在处理海量数据时至关重要直接关联到OutOfMemoryError。边界检查分离rangeCheck用于访问get/set/removerangeCheckForAdd用于插入。因为插入时index可以等于size表示在末尾添加。分开检查使逻辑更清晰这也是源码中的做法。3.3 核心功能实现增、删、改、查现在实现用户最常用的方法。/** * 返回顺序表中元素的数量 * return 元素数量 */ public int size() { return size; } /** * 判断顺序表是否为空 * return 为空返回true否则返回false */ public boolean isEmpty() { return size 0; } /** * 在顺序表末尾添加一个元素 * param e 要添加的元素 * return 总是返回true (遵循Collection接口规范) */ public boolean add(E e) { // 确保容量至少为 size 1 ensureCapacity(size 1); elementData[size] e; return true; } /** * 在顺序表的指定索引处插入一个元素 * param index 要插入的索引位置 * param element 要插入的元素 * throws IndexOutOfBoundsException 如果索引越界 */ public void add(int index, E element) { rangeCheckForAdd(index); // 检查索引合法性 ensureCapacity(size 1); // 确保有空间 // 将index及其之后的元素向后移动一位 // System.arraycopy是native方法效率远高于自己写for循环 System.arraycopy(elementData, index, elementData, index 1, size - index); elementData[index] element; size; } /** * 移除指定索引处的元素 * param index 要移除的元素的索引 * return 被移除的元素 * throws IndexOutOfBoundsException 如果索引越界 */ public E remove(int index) { rangeCheck(index); // 检查索引合法性 E oldValue (E) elementData[index]; // 保存被移除的元素 // 计算需要移动的元素个数 int numMoved size - index - 1; if (numMoved 0) { // 将index1及其之后的元素向前移动一位覆盖被移除的元素 System.arraycopy(elementData, index 1, elementData, index, numMoved); } // 将最后一个位置置为null帮助GC回收。这是防止内存泄漏的好习惯。 elementData[--size] null; return oldValue; } /** * 移除第一个出现的指定元素如果存在 * param o 要移除的元素 * return 如果列表包含该元素并成功移除则返回true */ public boolean remove(Object o) { if (o null) { for (int i 0; i size; i) { if (elementData[i] null) { fastRemove(i); return true; } } } else { for (int i 0; i size; i) { if (o.equals(elementData[i])) { fastRemove(i); return true; } } } return false; } // 私有快速移除方法跳过边界检查和返回值 private void fastRemove(int index) { int numMoved size - index - 1; if (numMoved 0) { System.arraycopy(elementData, index 1, elementData, index, numMoved); } elementData[--size] null; } /** * 获取指定索引处的元素 * param index 元素的索引 * return 该索引处的元素 * throws IndexOutOfBoundsException 如果索引越界 */ public E get(int index) { rangeCheck(index); return (E) elementData[index]; // 需要进行类型转换 } /** * 修改指定索引处的元素 * param index 要修改的元素的索引 * param element 新的元素 * return 该位置原来的元素 * throws IndexOutOfBoundsException 如果索引越界 */ public E set(int index, E element) { rangeCheck(index); E oldValue (E) elementData[index]; elementData[index] element; return oldValue; } /** * 清空顺序表将所有元素置为null并重置size */ public void clear() { // 显式置null帮助GC。对于大列表这很重要。 for (int i 0; i size; i) { elementData[i] null; } size 0; } /** * 判断顺序表是否包含指定元素 * param o 要查找的元素 * return 如果包含则返回true */ public boolean contains(Object o) { return indexOf(o) 0; } /** * 返回指定元素第一次出现的索引如果不包含则返回-1 * param o 要查找的元素 * return 元素的索引或-1 */ public int indexOf(Object o) { if (o null) { for (int i 0; i size; i) { if (elementData[i] null) { return i; } } } else { for (int i 0; i size; i) { if (o.equals(elementData[i])) { return i; } } } return -1; }核心细节与性能分析System.arraycopyvsfor循环在add(int index, E element)和remove(int index)中我们都使用了System.arraycopy。这是一个本地native方法由JVM底层实现通常使用内存块复制等高效操作其性能远高于用Java写的for循环逐元素拷贝。这是顺序表实现中一个关键的优化点。删除操作与内存泄漏在remove和clear方法中我们都显式地将不再引用的数组位置设置为nullelementData[--size] null;。如果不这么做数组仍然持有对该对象的强引用即使这个对象在逻辑上已经从列表中“删除”垃圾收集器GC也无法回收它从而导致内存泄漏。这是很多自定义容器类容易忽略的细节。null值处理在indexOf、remove(Object o)等方法中我们都对null值进行了单独判断if (o null)。这是因为null.equals(...)会抛出NullPointerException。正确的做法是先判断对象是否为null如果是则用比较引用如果不是再用equals方法比较内容。这是实现健壮容器类的必备逻辑。时间复杂度分析get(int index)/set(int index, E element)O(1)得益于数组的随机访问。add(E e)平摊O(1)。虽然扩容时是O(n)但扩容不是每次都会发生平摊到每次操作上成本是常数。add(int index, E element)/remove(int index)O(n)。因为可能需要移动平均n/2个元素。contains(Object o)/indexOf(Object o)O(n)需要遍历。 理解这些复杂度是你在实际开发中选择ArrayList还是LinkedList链表增删O(1)访问O(n)的理论依据。4. 从理论到实战顺序表应用与经典问题剖析理解了实现我们来看看顺序表在解决实际问题中的威力并分析一些常见的误区。4.1 实战案例合并两个有序顺序表这是一个经典的面试题和算法练习题。假设有两个MyArrayListInteger内部元素已经按升序排列要求合并它们并保持有序。/** * 合并两个有序升序的顺序表返回一个新的有序顺序表 * 假设list1和list2本身已按升序排列 * param list1 第一个有序表 * param list2 第二个有序表 * return 合并后的有序表 */ public static MyArrayListInteger mergeSortedLists(MyArrayListInteger list1, MyArrayListInteger list2) { MyArrayListInteger mergedList new MyArrayList(list1.size() list2.size()); int i 0, j 0; // i指向list1, j指向list2 // 双指针遍历每次取较小的元素加入新列表 while (i list1.size() j list2.size()) { if (list1.get(i) list2.get(j)) { mergedList.add(list1.get(i)); } else { mergedList.add(list2.get(j)); } } // 将剩余的元素全部加入list1或list2有一个已遍历完 while (i list1.size()) { mergedList.add(list1.get(i)); } while (j list2.size()) { mergedList.add(list2.get(j)); } return mergedList; }为什么这样做高效这个算法的时间复杂度是O(mn)其中m和n是两个列表的长度。它只需要遍历每个列表一次利用了数组随机访问O(1)的特性。如果使用链表虽然算法逻辑一样但get操作是O(n)会导致整体效率下降。这正是顺序表在“需要频繁按索引访问”场景下的优势体现。4.2 避坑指南ConcurrentModificationException与迭代器如果你在自己的MyArrayList上使用增强for循环for-each或者Iterator并在遍历过程中调用remove方法很可能会遇到问题。因为增强for循环底层依赖于迭代器Iterator而迭代器在工作时会检查列表的“修改次数”modCount是否发生变化。如果列表在迭代过程中被结构性修改不是通过迭代器自身的remove方法就会抛出ConcurrentModificationException。注意我们上面实现的MyArrayList缺少modCount机制和Iterator实现因此不支持安全的并发修改检测。完整的实现需要添加一个protected transient int modCount 0;字段在所有会改变结构的方法add,remove,clear中递增它并实现一个内部类Itr来检查这个值。这是ArrayList源码中一个重要的细节旨在快速失败fail-fast防止在迭代过程中发生不可预期的行为。4.3 性能陷阱在列表头部频繁插入/删除这是顺序表最不擅长的场景。假设你有一个长度为n的顺序表每次都在索引0的位置插入一个元素那么每次插入都需要将后面所有的n个元素向后移动一位时间复杂度是O(n)。如果你需要执行k次这样的操作总时间就是O(k*n)效率极低。解决方案对于这种需要频繁在头部操作的场景应该考虑使用LinkedList基于链表因为链表在头部插入/删除的时间复杂度是O(1)。这也是为什么Java提供了ArrayDeque和LinkedList两种双端队列实现前者基于数组随机访问快后者基于链表头尾插入删除快需要根据具体场景选择。4.4 内存与“如何把表1员工工资匹配到表的员工顺序不同的表2上”这个网络热词描述的场景本质上是一个数据关联查询问题。假设表1是员工ID和工资的映射可以看作一个MapInteger, Double表2是一个员工ID的列表可以看作一个ListInteger但顺序不同。你需要为表2中的每个ID从表1中找到对应的工资。顺序表数组/列表在此场景的局限性如果你把表1也存成一个ListEmployeeEmployee对象包含id和salary那么为表2中的每个id去表1中查找最坏情况需要遍历整个表1时间复杂度是O(m*n)效率低下。更优的解决方案使用HashMap。将表1的数据存入HashMapInteger, Double键是员工ID值是工资。然后遍历表2的ID列表用map.get(id)来获取工资时间复杂度是O(1) * n O(n)。这里的HashMap底层虽然也用到数组但它通过哈希函数将键映射到数组索引实现了近乎常数的查找时间完美解决了顺序表在此类“查找”场景下的性能瓶颈。这个例子告诉我们选择数据结构首先要分析核心操作这里是“根据键查找值”然后选择最适合该操作的数据结构。5. 进阶思考顺序表与JVM内存模型的关联理解了顺序表的实现我们可以将其与更底层的JVM知识联系起来这能帮助你诊断和解决更复杂的问题。5.1OutOfMemoryError: Java heap space当你向ArrayList或我们的MyArrayList添加大量数据时最终可能会抛出这个错误。过程是这样的不断add元素导致内部数组elementData被填满。触发ensureCapacity需要创建一个新的、更大的数组比如1.5倍。Arrays.copyOf或System.arraycopy会将旧数组的数据复制到新数组。关键点在复制完成、旧数组被GC回收之前新旧两个数组会同时在堆内存中存在。此时需要的内存是旧数组大小 新数组大小。如果新数组所需的内存加上JVM堆中其他对象占用的内存超过了JVM堆的最大限制通过-Xmx参数设置就会抛出OutOfMemoryError。给你的启示如果你知道数据量会非常大在创建ArrayList时使用带初始容量的构造函数new ArrayList(initialCapacity)一次性分配足够大的数组可以避免多次扩容带来的额外内存峰值和拷贝开销。这也是处理大数据量时的一个常用优化技巧。5.2 缓存友好性与局部性原理顺序表数组在内存中是连续存储的。当CPU访问数组的一个元素时它不仅会加载这个元素到高速缓存Cache还会加载其相邻的一片内存区域一个缓存行Cache Line。这意味着当你顺序遍历一个ArrayList例如用for循环时后续的元素有很大概率已经在缓存中了访问速度极快。这称为空间局部性优势。相比之下链表LinkedList的节点在堆内存中是分散存储的访问一个节点后下一个节点很可能不在同一个缓存行中甚至不在同一内存页导致缓存命中率低性能下降。这也是在大多数需要遍历操作的场景下ArrayList的性能优于LinkedList的深层硬件原因之一。手写一遍顺序表其意义远不止于应付面试。它强迫你思考数组的边界、内存的分配与回收、时间的复杂度与空间的权衡。当你再使用ArrayList时你看到的将不再是一个简单的工具类而是一个在连续内存中精巧组织的动态容器你能预判它的扩容行为理解它为何在随机访问时飞快在中间插入时迟缓。这种从实现到原理的穿透式理解是区分“API调用者”和“系统设计者”的关键一步。下次当你面临是选ArrayList还是LinkedList是直接add还是预先设定capacity时希望今天的探讨能给你一个清晰而自信的答案。