news 2026/9/10 7:46:15

Java List深度解析:ArrayList与LinkedList源码、扩容机制及面试避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java List深度解析:ArrayList与LinkedList源码、扩容机制及面试避坑指南

我先把话说明白:这篇文章会直接深入 List 接口的源码、数据结构、扩容机制、迭代器、适用场景,以及面试里最容易翻车的细节。我写东西不喜欢绕弯子,也不会搞一堆从入门到放弃的营销废话,所以下面的内容尽量做到每个结论都能在源码里找到依据,每一段代码都是能直接跑起来验证的,每一个坑都是我自己踩过或者帮同事排查过的。

对于正在准备 Java 面试、或者做 Java 开发 1—3 年的朋友,这篇文章应该能帮你把 List 相关的问题彻底吃透。不需要你有多强的源码阅读经验,只要跟着文章的节奏走,每一步都打开 IDE 验证,学完一段就自己敲一段,基本不会再有"八股文背了忘、忘了背"的困惑。

1. 为什么 List 值得专门拎出来研究:接口设计的本质生意经

1.1 一个真实的线上事故:选错 List 实现类引发的连环雪崩

先讲一个我印象很深的案例。有一年我们做一个电商后台的报表导出功能,需求很简单:从数据库查出几十万条订单记录,按时间排序后输出到 Excel。第一批版本上线后,每到整点报表任务一跑,应用就出现明显卡顿,GC 日志里 Young GC 频率高得吓人,最严重的时候整台机器响应超时,下游的促销活动接口也跟着遭殃。

排查了很久才发现问题不在 SQL 也不在磁盘 IO,而在一个看起来人畜无害的写法——有人用 LinkedList 来承载这几十万条数据,然后频繁调用get(index)方法遍历读取。你知道 LinkedList 的get(i)是什么复杂度吗?O(n)。也就是说,在一个 50 万条数据的链表里,遍历一遍就是 50 万次从头部逐节点爬行,总耗时是 O(n²) 级别的。

单次读取几毫秒感觉不出来,但 50 万条数据叠加起来,单次导出任务消耗的时间直接以分钟计。后来改成 ArrayList 之后,同样的数据量,读取阶段耗时下降了整整两个数量级。这个案例告诉我一个道理:List 接口的"一夫多妻"并不是设计者的恶趣味,而是对不同场景的精准打击。理解这三个实现类的本质差异,不是面试用到的死知识,而是能直接影响线上系统稳定性的关键技术决策。

1.2 List 在整个集合框架中的准确定位

在很多人的认知里,List 就是一个"能装东西的数组"。这个说法不算错,但完全不够。List 是 Collection 接口下最核心的子接口之一,它规定了一组"有序、可重复、可通过下标访问"的集合行为规范。

  • 有序:元素按照插入顺序维护,遍历结果与插入顺序一致(不是排序,是 insertion order)。
  • 可重复:同一个对象可以多次 add 进去,List 不关心元素是否唯一,这跟 Set 有本质区别。
  • 下标访问:可以通过list.get(index)精确命中第几个元素,这是 List 区别于 Queue、Set 的杀手锏。

这三个语义就是 List 的"不变式"。ArrayList、LinkedList、Vector 无论内部结构怎么折腾,对外都要保证这三个语义成立。从架构角度来看,List 接口本身就是一套契约——契约背后隐藏着设计者对"有序集合"这一抽象概念的完整思考。

理解了这一点,你就能理解为什么后面要讲"快速失败机制"时,需要在迭代器上做文章;为什么subList会返回一个视图而不是新集合;为什么Arrays.asList得到的东西不能随便 add。这些细节背后全是接口语义在兜底。

2. 三个实现类的"底盘"差异:从数据结构的角度剥洋葱

2.1 ArrayList:动态数组的秘密

2.1.1 底层存储结构与初始化

ArrayList 的底层极其简单,就是一段连续的内存空间,对应 Java 里的Object[] elementData。这句话说出来容易,但它背后决定了 ArrayList 的两个极端优势和一个致命短板:

  • 优势一:随机访问极快。因为数组在内存里是连续的,只要知道首地址,根据下标计算出偏移量就能直接命中指定元素,时间复杂度 O(1)。
  • 优势二:局部性好。连续内存天然对 CPU 缓存友好,遍历时预取效率远超链式结构。
  • 短板:插入和删除操作需要移动后续所有元素,最坏情况下是整个数组的元素搬家,时间复杂度 O(n)。

在 JDK 8 之后的源码中,初始化有一个"懒加载"的设计思路:直接new ArrayList()时并不会立刻分配数组容量,而是使用一个共享的空数组DEFAULTCAPACITY_EMPTY_ELEMENTDATA,只有在第一次add时才扩容到DEFAULT_CAPACITY(10)。

这个设计的洞察在于:很多 ArrayList 对象创建出来只是为了占个位,可能从头到尾都没 add 过任何元素。如果创建对象时就直接分配 10 个长度的数组,那一堆空列表就会白白占用内存。这种"按需分配"的思路是 Java 集合框架里常见的性能优化哲学。

// JDK 1.8 ArrayList 部分源码 private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {}; private static final int DEFAULT_CAPACITY = 10; public ArrayList() { this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA; } private static int calculateCapacity(Object[] elementData, int minCapacity) { if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) { return Math.max(DEFAULT_CAPACITY, minCapacity); } return minCapacity; }
2.1.2 扩容机制:1.5 倍背后的数学逻辑

当 ArrayList 的元素数量超过数组容量时,就需要扩容。源码里有一个被无数人背诵过的grow方法:

private void grow(int minCapacity) { int oldCapacity = elementData.length; int newCapacity = oldCapacity + (oldCapacity >> 1); // 即 1.5 倍 if (newCapacity - minCapacity < 0) newCapacity = minCapacity; if (newCapacity - MAX_ARRAY_SIZE > 0) newCapacity = hugeCapacity(minCapacity); elementData = Arrays.copyOf(elementData, newCapacity); }

关键点是oldCapacity + (oldCapacity >> 1),右移一位相当于除以 2,所以扩容倍数是 1.5。为什么要选 1.5 而不是 2?

如果扩容 2 倍,空间浪费会比较严重——尤其当数据量很大时,申请的内存超出实际需求太多,导致 GC 压力上升。如果扩容系数太小(比如 1.1),意味着频繁触发扩容,每次扩容都要Arrays.copyOf,数组拷贝的耗时也会线性累积。1.5 这个折中值在大多数场景下能很好地平衡空间浪费和拷贝次数。

另外注意:每次扩容之后,Arrays.copyOf会把旧数组的所有元素搬到新数组,这个操作本身是 O(n) 的。如果要提前知道数据量,强烈建议在构造 ArrayList 时传入初始容量,避免多次扩容造成无谓拷贝。

// 推荐的做法 List<String> list = new ArrayList<>(expectedSize);
2.1.3 随机访问的底层原理

ArrayList 的get(int index)之所以那么快,是因为它直接操作数组下标:

public E get(int index) { rangeCheck(index); return elementData(index); }

rangeCheck负责做下标越界检查,然后返回elementData[index]。这个操作不涉及任何遍历、指针跳转,是一次纯粹的内存寻址。这也是为什么"ArrayList 随机访问 O(1)"这一结论在微观层面几乎不需要解释。

2.2 LinkedList:双向链表的代价与回报

2.2.1 节点模型与链式存储

LinkedList 的底层是双向链表,每一个节点Node里面保存三样东西:

private static class Node<E> { E item; Node<E> next; Node<E> prev; Node(Node<E> prev, E element, Node<E> next) { this.item = element; this.next = next; this.prev = prev; } }

与数组的"连续内存"不同,链表中的节点对象在内存里是分散的,通过prevnext指针串起来。这个结构决定了 LinkedList 的以下特性:

  • 头部/尾部插入删除极快:只需要修改相邻节点的指针,不需要移动任何元素,O(1)。
  • 指定位置插入删除要看运气:需要先通过遍历找到指定节点。LinkedList 做了一定的优化——node(int index)方法会先判断 index 离头部近还是离尾部近,选择从哪一头开始遍历,复杂度是 O(n/2),也就是 O(n)。
2.2.2 内存占用的隐藏属性

很多人以为 LinkedList 不占额外空间,这是误区。链表里每个节点除了存储元素本身,还要存两个指针(JDK 8 默认开启压缩指针时,每个指针占 4 字节,两个就是 8 字节;如果关闭压缩指针,每个指针 8 字节,两个就 16 字节)。

再加上 Node 对象的对象头开销,一个元素在 LinkedList 里的实际内存占用往往比在 ArrayList 里高出一截。当数据量达到十万级别以上时,这个差距会变得非常可观。在做内存规划时,不要只看"逻辑上有几个元素",还要看每个容器到底吃了多少字节。

2.2.3 为什么它没成为"王者"

严格来说,LinkedList 打出了"插入删除快"的招牌,但在实际开发里,普通应用很少以"头部插入删除"为绝对主导。如果数据量很大,LinkedList 的遍历劣势会被无限放大;如果数据量很小,ArrayList 的移动成本根本不值一提;加上 LinkedList 对缓存极不友好(节点在内存中离散分布),性能表现反而更不稳定。

所以真实场景中,LinkedList 的使用频率远低于 ArrayList。它不是不好,而是适用面太窄。

2.3 Vector:被时代遗忘的前浪

2.3.1 Vector 到底做了什么

Vector 在 JDK 1.0 就存在了,比 ArrayList 出生得更早。从继承关系上看,它的底层也是Object[] elementData,和 ArrayList 几乎一模一样。区别在于:

  • Vector 的所有核心方法都用synchronized修饰,是线程安全的。
  • Vector 扩容时,如果指定了capacityIncrement,则按该增量扩容;否则扩容为原来的 2 倍。
public synchronized boolean add(E e) { modCount++; ensureCapacityHelper(elementCount + 1); elementData[elementCount++] = e; return true; }

看起来"线程安全"是一个优点,但它付出的代价是:每个方法都加锁,导致并发性能差。在多线程环境下,你真正需要的是在复合操作上加锁,而不仅仅是让单个 add/remove 安全。Vector 这种粗粒度的同步方式反而制造了更多竞争。

2.3.2 为什么现在的代码里几乎看不到 Vector

从 JDK 1.2 开始,Java 集合体系引入了 Collections 框架,ArrayList 作为 Vector 的非同步替代品被设计出来。再到 JDK 1.5 引入了java.util.concurrent包,提供了CopyOnWriteArrayList这类并发性更好的替代方案。Vector 剩下的价值基本只剩历史包袱。

面试的时候如果被问到 Vector,最好记住这三句话:

  • 它是 JDK 1.0 时代遗留类。
  • 线程安全但性能差。
  • 官方建议用ArrayList+Collections.synchronizedList()或者CopyOnWriteArrayList代替。

3. 源码级视角:方法实现如何影响你的日常代码

3.1 add(E e) 与 add(int index, E e) 的差异

表面上看都是"加一个元素",但底层完全是两种不同的复杂度模型。

boolean add(E e)在 ArrayList 里默认追加到数组尾部:

  • 如果还有空闲容量,直接elementData[size++] = e,时间复杂度 O(1)。
  • 如果容量不够,需要先扩容,再赋值,摊还下来平均也是 O(1)。

void add(int index, E e)则要先把 index 及之后的元素全部后移一位,再赋值:

public void add(int index, E element) { // 检查 index 合法性 System.arraycopy(elementData, index, elementData, index + 1, size - index); elementData[index] = element; size++; }

这里用到了System.arraycopy,它虽然是 JVM 提供的高效 native 方法,但当元素数量很大时,复制开销依然明显。所以"ArrayList 中间插入慢"不是空穴来风。

LinkedList 的add(E e)默认加到尾部,但如果调用add(int index, E e),同样要先node(index)找节点,所以也没快到哪儿去。

提示:如果你确定业务逻辑大部分是"往头部插元素"且数据量不小,LinkedList 确实有优势,比如实现一个简单的消息队列时,用 LinkedList 的addFirstremoveFirst就很顺手。否则别盲目迷信"链表插入快"。

3.2 remove(Object o) 与 remove(int index) 的天壤之别

这是 Java 面试里最容易挖坑的地方。

  • remove(Object o)表示删除"第一个等于 o 的元素"。它需要遍历列表,找到目标对象,然后调用 fastRemove 或者 unlink 方法执行删除。这里的复杂度是 O(n)。
  • remove(int index)表示删除"某一个下标对应的元素"。ArrayList 是直接删除对应下标后,把后续元素整体前移,也是 O(n);LinkedList 是找到节点后修改指针,但找节点的过程是 O(n)。两种实现依然都是 O(n)。

真正麻烦的是"遍历时删除"。很多人喜欢这样做:

for (int i = 0; i < list.size(); i++) { if (condition) { list.remove(i); } }

这段代码在删除后会触发一个问题:删除 index=i 的元素后,后面的所有元素都往前移了一位,但你的循环变量 i 还在继续递增,这样就会漏掉原本排在 i+1 位置的元素。正确做法是删除后i--,或者使用迭代器。

Iterator<String> iterator = list.iterator(); while (iterator.hasNext()) { String item = iterator.next(); if (condition) { iterator.remove(); } }

如果是 JDK 8 及以上,直接用removeIf最省事,它内部就是迭代器遍历:

list.removeIf(item -> condition);

3.3 size() 与 isEmpty() 到底谁更靠谱

几乎所有人都会说:isEmpty()就是size() == 0。这话在 ArrayList 里成立,因为它有单独的size字段。但如果你遇到的是某些自定义的 List 实现,size()可能是 O(n) 的——比如在某些链式结构里,size 不是维护好的计数器,而是现算的。

所以我的建议是:判断集合是否为空,永远用isEmpty(),而不是size() == 0。这是一个极小但影响面极广的好习惯。

3.4 subList 并不只是"截取一段"

subList(int fromIndex, int toIndex)返回的是原列表的视图,而不是新列表。底层实现是SubList内部类,它没有复制数组,而是持有了对父列表的引用,并通过offsetsize来限定范围。

这意味着:

  • 修改 subList 里的元素,原列表也会跟着变。
  • 对 subList 做 add/remove,原列表也会变。
  • 父列表结构被修改(add/remove/clear 等)后,再操作 subList 会抛出ConcurrentModificationException,因为 subList 内部通过维护expectedModCount来校验结构性修改。

大量生产环境事故都源于有人把 subList 当作"副本"来用。如果你需要的是一个独立的新列表,请这样写:

List<String> subList = new ArrayList<>(originalList.subList(1, 3));

或者用 Stream:

List<String> subList = originalList.stream().skip(1).limit(2).collect(Collectors.toList());

4. 并发场景下 List 的生存指南:不仅要知道怎么用,还要知道什么时候不能用

4.1 快速失败机制:为什么 foreach 里 remove 会抛异常

ArrayList、LinkedList 内部都有一个modCount字段,用来记录"结构被修改的次数"。所谓结构性修改,指的是改变列表大小、改变元素个数这一类操作,而不是修改某个元素的值。

迭代器生成时会保存expectedModCount = modCount。每次调用next()时都会检查两者的值是否一致,不一致就抛出ConcurrentModificationException

final void checkForComodification() { if (modCount != expectedModCount) throw new ConcurrentModificationException(); }

这种设计叫"快速失败"。它不保证一定抛异常,而是尽最大努力尽早暴露并发修改问题。为什么说"尽最大努力"?因为如果是在单线程里你先改了结构,又重新创建了迭代器,那么 modCount 和 expectedModCount 会重新对齐,异常就不会触发。快速失败机制不是绝对可靠的并发检测工具,更准确地说,它更像一个"建议性安全信号"。

在 foreach 循环里直接list.remove()之所以报错,是因为 foreach 的底层就是迭代器,而list.remove()会修改 modCount,却没有同步迭代器内部的 expectedModCount,所以下一次调用iterator.hasNext()next()时就会触发异常。

4.2 CopyOnWriteArrayList 是什么,什么时候该用

java.util.concurrent.CopyOnWriteArrayList是并发包里的一个特殊 List。它在写操作时会对底层数组做一次完整拷贝,然后在新副本上修改,修改完成后再把引用指向新数组。读操作不需要加锁,直接在旧数组上读。

这个设计非常极端——写操作代价极大,读操作几乎无锁。适合"读多写少"、列表大小不大、遍历频繁的场景,比如监听器列表、配置项缓存等。

// 适合 CopyOnWriteArrayList 的典型场景 private final List<EventListener> listeners = new CopyOnWriteArrayList<>(); public void addListener(EventListener listener) { listeners.add(listener); } public void notifyListeners(Event event) { for (EventListener listener : listeners) { listener.onEvent(event); } }

如果你在写一个多线程环境下频繁 add/remove,同时又要遍历元素的列表,CopyOnWriteArrayList虽然写开销大,但比"手动加锁 + 避免 ConcurrentModificationException"要优雅得多。

4.3 Collections.synchronizedList 的坑

Collections.synchronizedList会把 List 的方法整体用同步块包起来。但它解决的问题很有限:单次调用是安全的,复合操作仍然不是原子的。

比如下面这段代码,就算用了 synchronizedList,依然会有并发问题:

List<String> list = Collections.synchronizedList(new ArrayList<>()); // 线程 A if (!list.contains("key")) { list.add("key"); } // 线程 B if (!list.contains("key")) { list.add("key"); }

两个线程可能同时通过contains检查,然后先后执行 add,最终导致重复元素。要解决这种问题,必须在外层手动加锁,锁对象建议直接用这个 list 本身:

synchronized (list) { if (!list.contains("key")) { list.add("key"); } }

记住,Collections.synchronizedList只保证"单个方法"的原子性,不保证"组合逻辑"的原子性。

5. 面试高频场景拆解:避坑指南与答题套路

面试里 List 相关的问题几乎必考,但考察方式往往不是"背出 ArrayList 和 LinkedList 的区别"这么简单。多数面试官喜欢从一个代码片段出发,让你预测结果或者找出性能瓶颈。下面列几个我遇到过的经典问道,以及对应的思考链路。

5.1 问:ArrayList 扩容一次会浪费多少空间?

如果被问到这个问题,别只回答"1.5 倍",而是要展现出你对内存分配的理解。

先说结论:扩容后数组容量从 oldCapacity 变成约 oldCapacity * 1.5,但实际元素数量可能没变。也就是说,扩容后最多可能浪费约 1/3 的数组空间。举个例子,一个 ArrayList 当前容量 10,存了 9 个元素,下一次 add 触发扩容到 15,但实际只需要 10 个位置,瞬间多出 5 个空位。这 5 个空位对引用类型来说就是 5 个 4/8 字节的引用槽位,积少成多也值得关注。

补救措施是:如果明确知道数据量上限,就显式指定初始容量。如果存储的是大对象,可以适时用trimToSize()把容量压缩到实际 size。

ArrayList<String> list = new ArrayList<>(1000); // 填充大量数据... list.trimToSize(); // 数据稳定后压缩容量

5.2 问:LinkedList 真的比 ArrayList 插入快吗?

这是一个典型的"看似正确但经不起推敲"的面试题。

如果比较的是"在列表头部插入一个元素":

  • LinkedList 的addFirst只需要修改头节点的 prev 指针和新建节点,O(1)。
  • ArrayList 的add(0, e)需要把所有元素都后移一位,O(n)。

此时确实是 LinkedList 快。

但如果比较的是"在列表中部插入一个元素",LinkedList 需要先遍历找到中间位置(遍历成本 O(n)),再修改指针(O(1));ArrayList 直接通过下标定位(O(1)),然后移动后面的元素(O(n))。两者的整体复杂度都是 O(n),但常数项差距很大——链表的多一次遍历是节点间的指针跳转,数组的移动则是连续内存的 copy,后者的实际耗时通常更小,因为 JVM 对连续内存复制做了优化,而且缓存命中率高得多。

所以真实场景里"插入速度快"这一优势,只有在头尾插入时才明显。这个知识点放面试里,你如果能主动指出"复杂度相同但常数不同,实际表现要看场景",面试官对你的分数会明显不一样。

5.3 问:如何在遍历 List 时安全删除元素?

这个问题的完整答案链应该是:

  1. 使用迭代器的remove()方法。
  2. 使用 JDK 8 的removeIf()
  3. 使用 fori 倒序遍历删除。

三个方案各有适用场景:

// 方案一:迭代器 Iterator<String> it = list.iterator(); while (it.hasNext()) { String s = it.next(); if (s.startsWith("A")) { it.remove(); } } // 方案二:removeIf list.removeIf(s -> s.startsWith("A")); // 方案三:倒序遍历 for (int i = list.size() - 1; i >= 0; i--) { if (list.get(i).startsWith("A")) { list.remove(i); } }

第一种是最通用的,底层可以配合任何 List 实现;第二种可读性最好,适合 Java 8 以上环境;第三种利用了"删除后面的元素不影响前面元素下标"的特性,适合必须用下标操作的场合。

不要用 foreach + list.remove(),除非你在循环体里主动 break 退出,否则几乎肯定抛ConcurrentModificationException

5.4 问:List 和数组如何互相转换

数组转 List,最常用的是Arrays.asList。这个方法的坑在于它返回的是Arrays内部的一个私有 ArrayList,而不是java.util.ArrayList。这个私有 ArrayList 是定长的,底层仍然引用原数组,所以:

  • 不能调用add/remove,会抛UnsupportedOperationException
  • 修改原数组元素,会影响这个 List;修改 List 元素,也会影响原数组。

正确的转换姿势:

// 数组 -> 可变 List List<String> list = new ArrayList<>(Arrays.asList(array)); // Java 8+ List<String> list = Arrays.stream(array).collect(Collectors.toList()); // List -> 数组 String[] array = list.toArray(new String[0]);

JDK 8 推荐list.toArray(new String[0]),因为 JVM 会检测到目标数组大小为 0,直接反射创建新的数组,性能比传入一个预分配相同长度的数组更好。

5.5 排序稳定性问题

List 的sort排序是稳定的,也就是说,相同 key 的元素会维持原有顺序。这在按多个维度排序时尤为重要。

例如有一个订单列表,需要先按用户 ID 分组,再按下单时间排序。如果你先按时间排序,再按用户 ID 排序,由于第二次排序是稳定的,同一个用户 ID 下的元素依然保持时间顺序。这个特性直接支持了"多次排序实现多字段排序"的写法。

list.sort(Comparator.comparing(Order::getTime)); list.sort(Comparator.comparing(Order::getUserId)); // 稳定排序,userId 相等时保留 time 顺序

注意,这里的"稳定"是 TimSort 算法保证的,ArrayList 和 LinkedList 的内部排序实现都使用 TimSort,所以两者都是稳定的。

5.6 自定义对象去重:为什么直接 contains 很慢

如果你写了一个List<User>,要去重,可能会写:

List<User> uniqueList = new ArrayList<>(); for (User user : allUsers) { if (!uniqueList.contains(user)) { uniqueList.add(user); } }

这段代码在小规模数据下没问题,但在数据量大时,contains需要逐个调用equals,整体复杂度 O(n²),性能会急剧下降。正确做法是借助 Set 来辅助去重:

Set<String> seen = new HashSet<>(); List<User> uniqueList = new ArrayList<>(); for (User user : allUsers) { if (seen.add(user.getId())) { uniqueList.add(user); } }

这里seen.add返回 true 表示第一次出现,利用 HashSet 的 O(1) 查找把整体复杂度降到了 O(n)。

5.7 Arrays.asList 源码分析:一个隐藏得极深的设计缺陷

Arrays.asList返回的内部类继承自AbstractList,这个基类的addremove默认实现就是抛UnsupportedOperationException,子类如果不重写就永久不可用。所以这个"假 List"从设计上就不是让你动态增删元素的。面试的时候如果能主动提到"这是 AbstractList 的设计模式问题——可选操作(optional operation)",会很加分。

6. 选型方法论:什么时候用 ArrayList,什么时候用 LinkedList,什么时候该抛弃 List

6.1 一个决策表帮你摆脱选择困难

场景首选理由
随机访问多,按下标获取元素为主ArrayListO(1) 寻址,缓存友好
尾部追加为主,数据量可预估ArrayList摊还 O(1),扩容可控
头部/尾部频繁插入删除LinkedListO(1) 修改指针,无需移动元素
多线程读多写少,遍历频繁CopyOnWriteArrayList读无锁,遍历稳定
多线程读写均衡不使用 List 或使用并发集合替代普通 List 需要重度加锁,易出性能瓶颈
数据量不大,逻辑简单ArrayList简单直接,可读性好
需要频繁在中间插入删除看情况,多数场景仍选 ArrayList链表的查找成本抵消了插入优势

这张表不是我随便拍的,背后的核心逻辑是"你的操作模式决定数据结构"。

6.2 大数据量下的性能实测方法

与其争论理论谁快,不如自己在本地写一个压测小 Demo。我习惯用 JMH(Java Microbenchmark Harness)做这类基准测试,它比手写 for 循环计时要靠谱得多,能规避 JVM 预热、即时编译等干扰因素。

一个简单的参考思路:

// 不必担心这段代码的具体实现,重点是方法对比 @Benchmark public void testArrayListGet(Blackhole bh) { for (int i = 0; i < list.size(); i++) { bh.consume(list.get(i)); } } @Benchmark public void testLinkedListGet(Blackhole bh) { for (int i = 0; i < list.size(); i++) { bh.consume(list.get(i)); } }

同样的代码,数据量设为 10 万条,跑出来的差距会非常直观。这种测试方式的价值在于:它帮助你建立"结构差异 -> 性能差异"的直觉,而不是人云亦云。

6.3 业务建模中的"反直觉"案例

我曾见过一个消息推送系统,处理逻辑是这样的:每来一条消息就塞进一个列表,同时有个消费者线程不断从头部取消息处理。初版开发用 ArrayList,后来被 JVM 内存中的数组复制搞到崩溃,改成 LinkedList 后头部操作变成 O(1),问题立刻消失。这个案例说明,当你确定数据结构的使用方式是"一端进、一端出"时,不要再想什么数组了,直接上 LinkedList 或者 ArrayDeque

反过来,如果你要存储大量记录,接着做条件过滤、排序、随机翻页,那几乎必然是 ArrayList 的天下。先定义操作模式,再选实现类,这个顺序永远不要颠倒。

7. 易错点案例复盘:我在排障时见到的那些 List 血案

7.1 subList 误用导致线上数据丢失

有一个财务管理服务,需要从一组交易记录中取前 10 条做展示。代码大概长这样:

List<Transaction> top10 = transactions.subList(0, 10); top10.clear(); // 不小心执行了清空

结果是什么?clear()操作不仅在视图上生效,还直接把原列表transactions的前 10 条记录全删了。转账明细直接少了一页,这种事故的严重性不用多说。

复盘结论:subList返回的对象在语义上就是"原列表的一个窗口",修改窗口的增删操作会直接影响原列表。如果你尝试对 subList 做 clear、add、remove,都会被原列表感知到。很多不熟悉视图语义的开发者会在不知情的情况下触发结构性修改,甚至导致底层数据被误删。这不是 Java 的 bug,是对"视图"概念理解不深导致的误用。

安全姿势是拷贝副本,除非你确实需要一个共享的视图来同时修改两边。

7.2 用 Arrays.asList 后调用 add 导致的报错

Arrays.asList的底层数组是固定长度的。有同事在写批量导入功能时这样搞:

List<String> ids = Arrays.asList("a", "b", "c"); ids.add("d"); // UnsupportedOperationException

排查的时候他一脸懵,因为他记得"List 不是可以 add 的吗"。这里的问题就在于Arrays.asList返回的 List 并不是真正的java.util.ArrayList,而是一个不可变长度、但元素值可变的列表。换句话说,它支持set(index, value),但不支持改变容量。

如果你需要一个可变长度的列表,一定记得套一层:

List<String> ids = new ArrayList<>(Arrays.asList("a", "b", "c"));

7.3 并发 remove 导致数据不一致

在一个多线程导出任务里,多个线程同时往一个 ArrayList 里 add 数据,同时主线程遍历这个列表做汇总。结果,遍历线程偶尔会抛出ConcurrentModificationException,而且 add 的数据偶尔也会丢。问题根因就是 ArrayList 的非线程安全性。

线程安全不是简单的给方法加个锁就能解决。像 ArrayList 这种非线程安全的类,你需要从架构上避免多个线程同时读写同一个实例,或者使用并发包里的替代方案。这里最直接的替代就是CopyOnWriteArrayList,但要注意它不适合"写多读少"的场景,因为写时复制的开销非常大。

7.4 fori 循环删除的"下标回退"问题

删除列表中第 1、3、5 个位置的元素,很多人这样写:

for (int i = 0; i < list.size(); i++) { if (i % 2 == 1) { list.remove(i); } }

但这个循环会在删除第一个元素后,把所有后续元素的下标往前移 1。接下来 i=1 对应的实际上变成了原来下标为 2 的元素,这样原本下标为 3 的元素就被跳过了,永远不会被检查到。

正确的写法是倒序遍历:

for (int i = list.size() - 1; i >= 0; i--) { if (i % 2 == 1) { list.remove(i); } }

倒序删除时,删除后面的元素不会影响前面元素的下标,所以安全。

7.5 equals 和 hashCode 在 List 查找中的微妙影响

List.contains(Object o)依赖o.equals(element)逐个比较。如果你的自定义类没有重写equals,默认继承了Object.equals,比较的是对象引用地址。这意味着即使两个对象的内容完全一样,只要它们是 new 出来的不同实例,contains也会返回 false。

要修复,必须重写equalshashCode。并且注意,hashCode的变更会影响散列集合的查找,但 List 里只用equals逐个比较,所以只要equals逻辑正确,contains就能正常工作。但如果你把一个对象放进 HashSet,再修改它的hashCode相关字段,后续查找会失效,所以可变对象放进散列集合时要格外小心。

8. 最后一个实战建议:动手设计一个"万用 List 测试台"

如果你不想只停留在"看懂了"的层面,我强烈建议你写一个自己的 List 测试类,专门用来验证本文提到的所有行为。我当初就是这么干的,效果比读十篇博客都好。

public class ListPlayground { public static void main(String[] args) { // 1. 验证 ArrayList 扩容 ArrayList<Integer> arrayList = new ArrayList<>(); for (int i = 0; i < 15; i++) { arrayList.add(i); } System.out.println("ArrayList size = " + arrayList.size()); // 2. 验证 Arrays.asList 不可变长度 List<Integer> fixed = Arrays.asList(1, 2, 3); System.out.println(fixed.getClass()); // java.util.Arrays$ArrayList try { fixed.add(4); } catch (UnsupportedOperationException e) { System.out.println("fixed list add throws UnsupportedOperationException"); } // 3. 验证 subList 视图 List<String> source = new ArrayList<>(List.of("a", "b", "c", "d")); List<String> view = source.subList(1, 3); view.set(0, "x"); System.out.println(source); // [a, x, c, d] // 4. 验证迭代器删除 List<String> words = new ArrayList<>(List.of("apple", "banana", "cherry")); Iterator<String> it = words.iterator(); while (it.hasNext()) { String word = it.next(); if (word.startsWith("b")) { it.remove(); } } System.out.println(words); // [apple, cherry] // 5. 验证 LinkedList 头尾操作 LinkedList<String> deque = new LinkedList<>(); deque.addFirst("head"); deque.addLast("tail"); System.out.println(deque.getFirst()); // head System.out.println(deque.getLast()); // tail } }

把这五个用例跑通,你就已经把 List 的核心行为模型刻在脑子里了。以后面试、开发、排障时碰到 List 相关问题,基本都能做到心里有底。

我个人强烈建议,在看这篇文章时,打开一个 JDK 8 或 JDK 11 环境,把那几段源码自己翻出来读一读。不要只读我贴出来的片段,要读ArrayListLinkedListVector的完整实现,关注modCount的维护、grow的触发条件、SubList的类定义、Itr内部类的迭代逻辑。读一遍源码抵得上背十遍八股文。把"为什么"搞明白,这些知识才会真正变成你自己的东西。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/10 7:46:01

大模型Checkpoint恢复基准:AWS存储方案实测与优化指南

如果你的训练任务在AWS上跑了三天&#xff0c;好不容易推进到第2000步&#xff0c;结果一个Spot实例回收通知下来&#xff0c;节点全没了。重新拉起之后&#xff0c;最想做的事情不是骂人&#xff0c;而是赶紧把Checkpoint读回来&#xff0c;继续跑。但等你真的开始做这件事&am…

作者头像 李华
网站建设 2026/9/10 7:45:40

AI Agent跨会话记忆系统:从Redis存储到RippleMem认知重建

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/10 7:45:30

STM32G4驱动IHM08M1电机模块实战指南

简介&#xff1a;本资源是一套基于STM32G431微控制器的电动窗帘电机控制完整工程&#xff0c;面向嵌入式开发工程师及智能硬件爱好者&#xff0c;解决直流电机精准启停、正反转与速度调节等核心控制问题&#xff0c;适用于智能家居场景下的窗帘自动化集成。压缩包含2000个文件&…

作者头像 李华
网站建设 2026/9/10 7:44:59

CANN/ge ACL算子形状推断API

aclopInferShape 【免费下载链接】ge GE&#xff08;Graph Engine&#xff09;是面向昇腾的图编译器和执行器&#xff0c;提供了计算图优化、多流并行、内存复用和模型下沉等技术手段&#xff0c;加速模型执行效率&#xff0c;减少模型内存占用。 GE 提供对 PyTorch、TensorFlo…

作者头像 李华
网站建设 2026/9/10 7:42:39

深入解析hermes-agent:从架构到生产落地的Agent框架实战指南

前阵子有个有意思的项目叫hermes-agent&#xff0c;名字起得很妙。Hermes 在希腊神话里是信使之神&#xff0c;干的就是传递消息、接引灵魂、协调众神之间事务的活。而这个项目做的事&#xff0c;跟这位神的职责高度重合&#xff1a;它是大模型与外部工具之间的中间层&#xff…

作者头像 李华
网站建设 2026/9/10 7:42:28

OpenCV双目测距实战:从标定到毫米级深度图生成

简介&#xff1a;本资源是一套基于Python与OpenCV实现的普通相机图像测距系统完整工程&#xff0c;面向计算机视觉初学者、高校课程设计学生及立体成像技术实践者&#xff0c;解决单目/双目相机标定、视差计算与深度测量等核心问题&#xff0c;适用于工业检测、机器人导航、三维…

作者头像 李华