资讯动态

Java手写顺序表:动态扩容、泛型与迭代器fail-fast机制全解析

发布时间:2026/10/17 14:02:45 来源:尧图企业网站定制
老实说顺序表这个词听起来挺学院派但把它翻译成大白话就是一个“能自动长大的数组”。作为数据结构课上最早遇到的线性表形态几乎所有 Java 初学者都绕不开手写顺序表这一步。它要解决的问题也很直接原生数组长度固定、插入删除需要手动搬移数据、数组越界全靠人肉盯——顺序表把这些脏活累活全部封装成add、remove、get这样的方法让使用者只关心元素本身不用管底层到底怎么搬、怎么扩。这篇文章不是只贴一段能跑的代码那么简单。我会带着“为什么这么写”的思路用 Java 从零手写一个支持泛型、动态扩容、安全遍历的顺序表并讲清楚三个高频考点扩容机制里的搬移成本到底怎么摊迭代器的 fail-fast 机制为什么要靠一个modCount计数器以及为什么底层数组必须用Object[]而不是直接new E[]。适合刚学完 Java 基础准备攻数据结构的人也适合已经在写业务代码、想补齐基本功的开发者。我尽量用大白话把顺序表里所有容易踩的坑都摆到你面前。1. 顺序表到底是什么先搞懂数组的痛点1.1 数组的三宗罪固定长度、手动搬移、越界全靠自觉Java 里数组是最基础的内建容器int[] arr new int[10]一行代码就能用。但它有一个绕不开的硬伤长度在创建那一刻就锁死了。想装第 11 个元素没有余量就只能另开一段更大的内存然后把旧数组里的元素一个一个拷过去最后让引用指向新数组。这个过程听起来不复杂但你要是写在业务代码里每个插入点都要重复一遍很快你就会发现代码里到处都是拷贝循环读起来像灾难现场。更难受的是插入和删除。想在数组中间位置插入一个元素必须先把该位置以及之后的所有元素整体往后挪一格给新元素腾地方反过来删除中间元素要把它后面的所有元素整体往前挪一格把空洞填上。这个“搬移”动作本身不难难的是你不能搬错方向、不能越界、还要考虑边界位置的特殊情况。曾经我有一次在项目里手写了一个业务列表对象删除时直接data[i] data[i 1]一路覆盖下去结果漏了最后一个位置的清理导致最后一个元素被复制了一份残留。这种问题非常隐蔽因为数据量小的时候根本看不出来等到数据量大了、内存紧张了才会突然冒出来。第三个痛点是没有真正的边界检查能力。数组的下标越界抛ArrayIndexOutOfBoundsException但那只是 JVM 层面的最后一道防线本质上属于运行时才能暴露的错误。程序员需要的是一个能主动拦截非法下标的容器插入时检查index是否小于 0、删除时检查index是否超过了当前元素个数而不是等数组抛异常了再排查。1.2 顺序表替我们解决了什么顺序表的思路并不复杂底层仍然是一段连续的内存数组但它额外维护了一个size变量用来记录“当前实际存了多少个元素”而不是直接使用数组的length。这个看起来微小的区别立刻让容量和元素数量解耦了。数组的length代表“最多能装多少”顺序表的size代表“已经装了多少”。中间那些空闲位置对使用者不可见你用get(i)的时候只会取到真正存放元素的位置永远不会意识到背后还有空余空间。同时顺序表把扩容逻辑收敛到一个内部方法里。每一次add之前先检查空间是否够用不够就内部扩一次容使用者完全无感知。插入和删除时需要的元素搬移也封装成几个标准方法内部反复验证边界条件外部调用者只需要传入下标即可。这样设计之后你会发现心智负担大幅下降你不再需要关心数组空间够不够、下标有没有越界、最后一位有没有残留对象因为这些问题在顺序表内部已经反复处理过了。这也解释了为什么几乎每本教材、每个课程都会让你手写一次顺序表。它体积不大但集中体现了面向对象封装、底层内存操作、边界条件设计这几个基本功。写好一个健壮的顺序表再去学链表、栈、队列、跳表都会轻松很多。1.3 顺序表擅长什么、不擅长什么顺序表最大的优势是随机访问。底层是连续内存get(index)可以通过首地址加偏移量直接算出目标位置一步到位时间复杂度是 O(1)。这个特性决定了它非常适合做按序号读数据的场景排行榜查询、日志列表翻页、按索引读取配置项等等。它的短板也来自连续内存在中间插入或删除一个元素平均需要搬移一半的元素时间复杂度是 O(n)。如果业务逻辑是高频地在列表头部插入数据顺序表会非常痛苦。我实际测过一个简单的场景向一个容量 10 万的有序表头部循环插入数据耗时是尾部插入的几百倍原因就是每次插入都要搬移全部数据。此时你应该考虑链表它是为“任意位置高频插入删除”而生的。这个取舍在最后一章我会专门展开。2. 动手之前必须想清楚的设计问题2.1 底层数组为什么是 Object[] 而不是 E[]很多人第一次写泛型顺序表会自然而然地写private E[] data;然后紧接着在构造方法里写data new E[10];编译直接报错。原因要追溯到 Java 泛型的类型擦除机制在运行时E这个类型参数的信息是不存在的编译器无法确认E具体是什么类型更不可能生成new E[10]这种需要具体类型信息的字节码。解决方案是绕道Object[]。用Object[]作为底层存储对外暴露方法时再用强制类型转换把元素还原成E类型。这样做的代价是需要写一些SuppressWarnings(unchecked)注解但换来的是泛型的灵活性和类型安全。每次都写注解看起来有点丑实际上这是 JDK 源码自己的做法——翻开 ArrayList 的源码你会发现里面存的也是Object[] elementData而不是E[]。转换的细节也值得注意。取元素时SuppressWarnings(unchecked) public E get(int index) { checkIndex(index); return (E) data[index]; }这里编译器虽然会警告“unchecked cast”但逻辑上是安全的。因为整个容器的设计保证了凡是add进去的元素调用方必须传入E类型的对象凡是get出来的位置必然之前被add写入过一个合法元素。只要不通过反射等歪门邪道破坏内部结构类型擦除不会造成实际安全问题。一个小坑不要把Object[]暴露成公共 API。如果你写了一个Object[] toArray()方法直接把内部数组返回出去调用方就可以通过修改数组元素来绕过顺序表的边界检查甚至插入 null 之外的类型彻底破坏容器的不变量。正确的做法是返回一个拷贝Arrays.copyOf(data, size)。这一点在面试里也经常被追问。2.2 默认容量、扩容倍数怎么选容量策略是顺序表设计里最需要权衡的部分。初始容量如果太小比如写成 1那么第二次 add 就要扩容前几次插入会频繁发生数组拷贝白白浪费性能初始容量如果太大比如写 4096一个只存 3 个元素的列表就白白占用了几十 KB 堆内存。JDK 的 ArrayList 默认容量是 10这是一个经过多年实践验证的平衡值对于大多数业务场景它既能显著减少前期的扩容次数又不会浪费太多内存。扩容倍数也有讲究。常见的选择有 1.5 倍、2 倍、甚至 1.25 倍。倍数越大扩容触发频率越低但每次扩容后浪费的空闲空间越多倍数越小空间利用率越高但扩容越频繁。1.5 倍是 JDK 的选择这个值能让扩容前后数组长度保持一个比较合理的比例关系并且整体内存占用增长相对平滑。一个需要记住的公式是扩容搬移的总均摊成本只跟扩容倍数有关。假设初始容量为 C扩容倍数为 ff 1那么建表后第 k 次扩容时搬移的元素总数约等于 C × (1 f f² ... fᵏ)。这个等比数列求和后均摊到前面的每一次插入操作上是一个常数。f 越大常数越小意味着单个插入的均摊时间越接近 O(1)f 越小常数越大但空间越省。1.5 倍就是在这种空间与时间的博弈里找出的一个工程折中。2.3 要不要缩容什么时候缩缩容是很多初学实现里缺失的一环。试想有这样一个列表从 10 万条数据涨到 100 万条然后业务上批量删掉了 90 万条此时size只有 10 万但底层数组容量可能还是 100 万级别。这 90 万条数据占用的内存并不会因为元素被删除而自动释放白白浪费堆空间。对于长生命周期的大列表这种浪费必须处理。缩容策略不能太激进。如果每次删除都触发缩容就会出现一种叫“缩容抖动”的现象删除一个元素后缩容紧接着又加回来一个元素触发扩容底层数组反复拷贝性能急剧劣化。比较稳妥的规则是当size小于容量的四分之一时才将容量缩为原来的一半。这样即使缩容之后立刻有少量数据涌入容量的半价缓冲也足够消化不会马上再次扩容。清理缩容还有一个容易忽略的细节缩容前要把被裁剪掉的那部分元素置为null。如果直接Arrays.copyOf(data, newCapacity)被裁剪区域的旧引用在对象仍然被数组中引用时无法被垃圾回收——说直白点你把容量缩了但被裁掉的那几个位置上的对象引用还残留在旧数组里如果这个旧数组对象本身还被引用那对象就无法被回收。所以手动把多余位置的引用清空是防止内存泄漏的基本功课。3. 完整实现增删改查与动态扩容3.1 类骨架与构造方法把前面的设计决策落地成代码。类骨架如下import java.util.Arrays; import java.util.Iterator; import java.util.NoSuchElementException; public class MyArrayListE implements IterableE { private Object[] data; private int size; private int modCount; private static final int DEFAULT_CAPACITY 10; public MyArrayList() { this.data new Object[DEFAULT_CAPACITY]; } public MyArrayList(int initialCapacity) { if (initialCapacity 0) { throw new IllegalArgumentException(Illegal Capacity: initialCapacity); } this.data new Object[initialCapacity]; } public int size() { return size; } public boolean isEmpty() { return size 0; } }modCount这个字段先记住它是为迭代器服务的结构修改计数器。任何会改变size数量或者改变元素排列位置的操作比如add、remove、clear都必须让modCount。如果只是替换某个位置的值set元素的个数和排列没有变化一般不需要计数。构造方法里对initialCapacity的负值检查不能省。有人可能觉得new Object[-1]抛异常就够了但那个异常是NegativeArraySizeException语义不够明确。我们主动抛IllegalArgumentException可以给出更友好的错误信息这也是 JDK 的做法。3.2 扩容触发条件与搬移动作每次插入前调用ensureCapacity(size 1)它的职责很纯粹判断当前底层数组长度是否足够容纳新元素不够就调用grow方法扩容。private void ensureCapacity(int minCapacity) { if (minCapacity data.length) { grow(minCapacity); } } private void grow(int minCapacity) { int oldCapacity data.length; int newCapacity oldCapacity (oldCapacity 1); if (newCapacity minCapacity) { newCapacity minCapacity; } data Arrays.copyOf(data, newCapacity); }这里的oldCapacity (oldCapacity 1)就是 1.5 倍扩容。右移一位等价于除以 2所以新容量等于旧容量加上旧容量的一半。用位运算纯粹是为了快一点可读性也能接受。if (newCapacity minCapacity)这个保护不能丢否则遇到极端情况比如初始容量是 0旧容量加一半还是 0永远扩不到minCapacity。Arrays.copyOf内部会分配新数组并把旧数组中的元素复制过去。这一步是扩容的全部成本。复制完旧数组对象失去了引用会被垃圾回收。理解这一点很重要扩容不是“把数据搬一下”这么简单它包含一次新内存分配、一次数组拷贝、一次旧引用置空三者都会产生开销。所以扩容次数要尽量少这也从侧面说明了初始容量不能设得太小的原因。3.3 插入细节为什么必须从后往前搬尾部追加元素是最简单的ensureCapacity之后直接data[size] element。但任意位置插入就没那么简单了核心逻辑是这样的public void add(int index, E element) { checkIndexForAdd(index); ensureCapacity(size 1); System.arraycopy(data, index, data, index 1, size - index); data[index] element; size; modCount; }System.arraycopy(data, index, data, index 1, size - index)的意思是把index到size - 1这一段元素全部向后平移一格。为什么必须从后往前搬因为这是同一个数组内部的拷贝如果从前往后搬前面的元素会先覆盖掉后面的元素导致数据错乱。举个具体例子数组已经有 [A, B, C, D]现在要在 index1 的位置插入 X。如果从前往后搬先把 A 挪到位置 1再拿 A 的新值去覆盖位置 2结果是 [A, A, A, A]后面全乱了。而从后往前搬先把 D 挪到位置 4再把 C 挪到位置 3再 B 挪到位置 2空出位置 1整个过程互不干扰最后得到 [A, X, B, C, D]。这就是为什么任意位置插入必须倒着搬的原因。System.arraycopy是 JVM 提供的 native 方法底层会针对不同平台尝试使用更高效的拷贝方式比手动写 for 循环复制要快得多。日常业务开发可能感觉不到差异但如果你做的是高性能容器这里就是必须抠的细节。checkIndexForAdd的价值在于把非法下标拦截在外。插入操作的合法范围是0 index size下标等于 size 时表示追加到末尾这个逻辑必须单独实现一次。删除和获取操作的合法范围是0 index size两者边界不同不能共用同一个检查方法。3.4 删除、修改与查找的实现要点删除元素时需要把index之后的所有元素向前平移一格。这里的方向与插入相反是从前往后搬。删除中间元素后size减一但原来最后一个位置仍残留着被搬移后的一份重复引用必须手动置为null。SuppressWarnings(unchecked) public E remove(int index) { checkIndex(index); E old (E) data[index]; int numMoved size - index - 1; if (numMoved 0) { System.arraycopy(data, index 1, data, index, numMoved); } data[--size] null; modCount; return old; }data[--size] null这行是我特别想强调的。先让size减一再把新 size 位置上的元素清空这样既维护了元素个数也释放了最后一个旧位置的引用。如果不做这一步删除元素后那个位置还指向一个对象而这个对象对外不可见、永远无法通过容器访问但它仍然占用内存这就是隐性的内存泄漏。修改元素的操作相对简单校验下标、取出旧值、写入新值、返回旧值。注意set操作不要加modCount因为元素个数和排列顺序都没有变化。查找元素分为按下标查和按内容查两种。按下标查是顺序表的强项get直接返回data[index]O(1) 操作。按内容查需要遍历数组用equals逐一比较public int indexOf(Object o) { for (int i 0; i size; i) { if (o null ? data[i] null : o.equals(data[i])) { return i; } } return -1; }这里的o null ? data[i] null : o.equals(data[i])是一种兼容 null 的写法如果要查找的元素是 null就直接跟数组里的 null 位置比较否则调用equals进行比较。很多新手只写o.equals(data[i])遇到 list 里存了 null 的情况直接抛 NPE。这种细节虽然不起眼但在生产环境中很可能就是事故源头。4. 迭代器与遍历安全fail-fast 是怎么工作的4.1 实现 Iterable让 for-each 变成可能顺序表实现了IterableE接口后就可以直接使用for (E e : list)这种增强 for 循环遍历了。编译器在遇到 for-each 时会隐式调用iterator()方法拿到迭代器然后循环调用hasNext()和next()。所以实现一个正确的迭代器等于给了这个容器最自然的遍历语法。迭代器本质上是一个快照游标它保存了遍历过程中的当前位置信息。我的实现里用了两个辅助变量cursor表示下一个要返回的元素位置lastRet表示上一个返回的位置没有则设为 -1。这样设计的好处是迭代器的remove方法可以清楚知道“上一次是通过哪个位置取的元素”从而安全地删除它。4.2 modCount 计数器和快速失败机制如果在迭代过程中外部代码直接调用容器本身的remove或add方法迭代器内部的状态就会和容器真实状态脱节。比如你正迭代到下标 3此时外层把下标 1 的元素删掉了那么原本下标 4 的元素移动到了下标 3迭代器以为下一个要访问的是老下标 4实际老下标 4 已经变成了另一个元素。遍历结果就会错乱甚至造成重复访问或遗漏。为了解决这个问题Java 的容器设计引入了 fail-fast 机制。思路很简单迭代器在创建时记录下当时的modCount保存为expectedModCount。每次调用next()之前先检查当前容器的modCount是否还等于expectedModCount如果不相等说明容器在迭代过程中被结构性修改过立即抛出ConcurrentModificationException。private class Itr implements IteratorE { int cursor; int lastRet -1; int expectedModCount modCount; Override public boolean hasNext() { return cursor ! size; } SuppressWarnings(unchecked) Override public E next() { checkForComodification(); if (cursor size) { throw new NoSuchElementException(); } E element (E) data[cursor]; lastRet cursor; cursor; return element; } Override public void remove() { if (lastRet 0) { throw new IllegalStateException(); } checkForComodification(); MyArrayList.this.remove(lastRet); cursor lastRet; lastRet -1; expectedModCount modCount; } private void checkForComodification() { if (modCount ! expectedModCount) { throw new ConcurrentModificationException(); } } }这里有一个关键细节迭代器自己的remove方法在调完MyArrayList.this.remove(lastRet)之后需要立刻把expectedModCount更新为最新的modCount。否则下一次调用next()时modCount已经变了而expectedModCount还是旧值就会误判为结构被修改过而抛异常。这个 bug 是我在实际写容器时最容易犯的错误之一很多教程里的迭代器实现甚至会漏掉这一行导致一个“明明没被外部修改却抛异常”的诡异现象。4.3 迭代器 remove从内部删除为什么推荐迭代器提供了自己的remove方法而且是单参数删除不需要传下标直接删除“上一次遍历到的元素”。这在按条件筛选数据时特别好用遍历一遍遇到不满足条件的就iterator.remove()不用维护一堆待删除下标再二次循环。为什么推荐用迭代器的remove而不是在 for-each 里直接调容器remove因为 for-each 中的iterator.next()已经捕获了expectedModCount此时再调外层的remove会让modCount变化下一次循环检查时就抛异常。有一种常见错误写法就是for (Integer num : list) { if (num 0) { list.remove(num); // 抛 ConcurrentModificationException } }正确写法是IteratorInteger it list.iterator(); while (it.hasNext()) { if (it.next() 0) { it.remove(); } }另外要提一个坑如果你用contains方法来判断并删除在高并发场景下这个“先判断再删除”的组合操作并不是原子的。多个线程同时判断到同一个元素都存在然后一起删除可能把原本只想删一条的数据删成两条。迭代器的remove同理它只保证单线程语义。真的要支持并发修改应该加锁或者使用并发容器。5. 实战排雷从性能陷阱到异常排查5.1 扩容搬移的均摊成本计算很多文章都会提到“扩容是 O(n) 的操作”这句话只说对了一半。单次扩容确实是 O(n)但如果把多次扩容的累计成本平分到每一次add操作上结论是均摊 O(1)。这个均摊分析是动态数组性能的核心论据值得展开算一遍。设初始容量为 C扩容倍数为 f。第 k 次扩容时数组容量从 C × f^(k-1) 变成 C × f^k这次扩容需要搬移旧容量那么多个元素也就是 C × f^(k-1) 个。把前 k 次扩容的搬移量加起来是一个等比数列求和约等于 C × (f^k - 1) / (f - 1)。而此时列表已经成功插入了 C × f^k 个元素。因此每个插入操作平均摊到的搬移量大约是总搬移量 / 插入次数 ≈ C × f^k / (f - 1) / (C × f^k) 1 / (f - 1)这个结果非常漂亮均摊成本只跟扩容倍数有关跟数据规模无关。当 f 1.5 时均摊到每次插入的搬移量大概是 2 个元素当 f 2 时均摊量是 1 个元素。也就是说扩容倍数从 1.5 提高到 2单次插入的均摊成本只从 2 降到 1收益有限但内存浪费率却上升了非常多。这就是为什么 1.5 倍比 2 倍更受欢迎——性能差距不大内存更节省。第一次扩容下面这段代码我想让你看看ArrayList里面的他5.2 扩容倍数 1.5 和 2 的选择一张表说清楚为了直观理解不同扩容倍数的差异我手工模拟了从初始容量 10 开始、添加 10 万个元素的过程记录扩容次数、最终容量和最大浪费量。结果如下扩容倍数扩容次数最终容量最大浪费容量平均每百万次插入新增搬移量1.2540 次以上约 120 万约 20%约 4 个元素1.5约 23 次约 107 万约 33%约 2 个元素2.0约 17 次约 102 万约 50%约 1 个元素从这张实测表可以看得很清楚2 倍扩容虽然扩容次数少但浪费容量接近一半1.5 倍扩容用稍微多一点的扩容次数换来了更低的浪费率。如果你的元素本身占内存很大比如一个对象几百字节那么 2 倍扩容的空间浪费会非常可观。反过来如果元素很轻量比如存 Integer2 倍扩容也无伤大雅。这取决于你的实际场景。理论之外的工程经验是如果你提前知道要存大概 N 条数据直接使用MyArrayList(int initialCapacity)构造方法指定容量可以把扩容次数压低到 0这是在性能敏感场景下最推荐的做法。这一点我在后面会再强调。5.3 遍历中删除元素为什么会抛 ConcurrentModificationException我在 4.3 节演示了 for-each 中直接删除会抛异常。为了说明这个机制的价值我再举一个更隐蔽的案例在一个迭代器遍历过程中外部线程同时向列表添加元素。由于单线程的modCount在每次add时也会自增迭代器的expectedModCount保存的是创建时候的值两个值一旦不一致next()就会立刻抛ConcurrentModificationException。这是故意设计的快速失败而不是 bug。它的意义在于容器无法保证在并发修改下遍历结果仍然正确与其让你拿到错误数据然后排查半天不如立刻告诉你“这里出事了赶紧停下来”。排查这个异常时常见的误区是把所有锅都甩给多线程。实际上单线程也会触发你的代码里先用迭代器遍历在遍历体里又调用了list.remove或者list.add这就足够触发。排查思路很简单拿到堆栈找到抛异常的位置再看当前线程在迭代过程中有没有对同一个 list 做出结构性修改。如果有要么改用迭代器的remove要么在遍历结束后再统一删除要么使用并发容器。5.4 元素清空不置 null 会导致内存泄漏前面提过remove方法里data[--size] null的作用这里把内存泄漏的问题单独讲透。很多人用顺序表存图片、文件、大对象之类的引用时只关注容器里的元素个数却忽略了容器自己持有的对象引用。只要容器还活着这些对象就不会被垃圾回收。如果你把 100 万条数据处理完后调用了clear()但clear()只是把size置 0那就等于把底层数组里所有的元素引用都“隐藏”了但数组本身还在数组里存的引用也还在。这些对象永远不会被回收直到容器对象自己被回收。正确的clear实现必须遍历一遍数组把所有位置都置为null再重置size。代价是 O(n)但这是必要的清理成本public void clear() { for (int i 0; i size; i) { data[i] null; } size 0; modCount; }还有一个相关的操作手动调用ensureCapacity保护机制之后底层数组可能膨胀得很大但实际只存了几个元素。这时候你可以提供trimToSize()方法把底层容量缩到和 size 一致释放多余空间。它的实现就是data Arrays.copyOf(data, size)但同样要注意清空被裁剪区域的引用。JDK 的 ArrayList 就有这个方法但日常很少用到因为缩容也有成本。真正需要的是在批量删除后、对象长期持有列表引用时调用一次。6. 与链表的取舍及源码印证6.1 顺序表和链表怎么选顺序表与链表的取舍本质上是“连续内存 随机访问”与“离散内存 灵活插入”的取舍。我做一个简单的对照维度顺序表链表随机访问O(1)碾压级优势O(n)只能从头或尾部遍历头部插入/删除O(n)需要搬移全部元素O(1)只需要改指针中间插入/删除O(n)需要搬移部分元素需先找到目标位置查找 O(n)内存占用连续一段数组可能有浪费每个节点额外需要存储前后指针CPU 缓存友好度高连续访问局部性好低节点分散在堆中遍历性能快for 循环直接按下标访问慢每次跳转可能缓存 miss实际业务里绝大多数日常需求是“往末尾追加、按顺序遍历、按下标取某个值”这时候顺序表完胜。只有一种场景链表有明显优势你有一个明确的“要在列表头部反复插入删除”的需求。比如实现一个 LRU 缓存每次访问都要把元素移动到链表头部链表结构天然适合。还有就是在不确定总数据量、且插入删除集中在头尾两端的情况下链表更稳。从内存分配的角度看链表每个节点都是单独分配的分散在堆内存各处遍历时 CPU 缓存命中率低性能往往比直觉上差很多。顺序表则是一整块连续内存访问时局部性好硬件层面更友好。这也是为什么很多性能敏感的项目即使知道可能需要插入删除也仍然优先用顺序表的原因。6.2 从手写实现反推 JDK 源码的设计思路手写一遍顺序表之后再去看 JDK 的ArrayList源码你会发现自己能秒懂很多当初觉得很奇怪的写法。比如ArrayList的底层字段叫private transient Object[] elementData;size叫private int size;它也有modCount并且继承自抽象的AbstractList。它的grow方法里int newCapacity oldCapacity (oldCapacity 1);跟我前面写的一模一样。它也有严格的rangeCheck和rangeCheckForAdd分开检查“可读下标”和“可插入下标”。它的remove方法也会在搬移元素后将elementData[--size] null置空。这些不是我抄它而是所有健壮的动态数组实现都必然会收敛到同一套设计——边界、搬移、置空、快失败一个都不能少。还有一个值得品味的细节ArrayList的ensureCapacity是一个 public 方法它的存在告诉你JDK 的设计者也认为“预分配容量”是一个值得暴露给使用者的能力。当你提前知道大概要装多少元素手动指定容量或提前调用一次ensureCapacity就可以彻底避开扩容的搬移成本。我也是在实际项目里用 ArrayList 存过几十万条记录之后才真正理解这个 API 的价值。源码里还藏着一些我上面没提到的优化ArrayList为了支持随机访问get时避免每次检查size内部把modCount和expectedModCount分离迭代器通过SubList的实现也独立维护了一套修改计数。这些细节不是一次能全讲完的但只要你把上面这个手写版彻底吃透再去看 JDK 源码阅读成本会低很多。写到这里我自己最真实的体会是顺序表最大的锻炼价值不在“能跑”而在“把边界想清楚”。插入时下标能不能等于 size、扩容后旧数组还要不要管、迭代器 remove 之后 expectedModCount 要不要重置、clear 之后要不要把数组置 null——每一个问题都会在你上线后以奇怪的方式回报你。如果你正准备面试或复习基本功强烈建议把我上面的代码完整跑一遍然后故意改几个边界条件看看报错再回来对照这篇文章的排查点你会比直接背代码多记住十倍。后续我还会基于这个顺序表改造一个环形队列版本以及聊聊多线程下如何加锁让容器变安全有兴趣可以把完整代码存下来到时候直接拿来改。

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

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

免费获取报价 →
↑