我先把话说明白:这篇文章会直接深入 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; } }与数组的"连续内存"不同,链表中的节点对象在内存里是分散的,通过prev和next指针串起来。这个结构决定了 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 的
addFirst和removeFirst就很顺手。否则别盲目迷信"链表插入快"。
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内部类,它没有复制数组,而是持有了对父列表的引用,并通过offset和size来限定范围。
这意味着:
- 修改 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 时安全删除元素?
这个问题的完整答案链应该是:
- 使用迭代器的
remove()方法。 - 使用 JDK 8 的
removeIf()。 - 使用 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,这个基类的add和remove默认实现就是抛UnsupportedOperationException,子类如果不重写就永久不可用。所以这个"假 List"从设计上就不是让你动态增删元素的。面试的时候如果能主动提到"这是 AbstractList 的设计模式问题——可选操作(optional operation)",会很加分。
6. 选型方法论:什么时候用 ArrayList,什么时候用 LinkedList,什么时候该抛弃 List
6.1 一个决策表帮你摆脱选择困难
| 场景 | 首选 | 理由 |
|---|---|---|
| 随机访问多,按下标获取元素为主 | ArrayList | O(1) 寻址,缓存友好 |
| 尾部追加为主,数据量可预估 | ArrayList | 摊还 O(1),扩容可控 |
| 头部/尾部频繁插入删除 | LinkedList | O(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。
要修复,必须重写equals和hashCode。并且注意,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 环境,把那几段源码自己翻出来读一读。不要只读我贴出来的片段,要读ArrayList、LinkedList、Vector的完整实现,关注modCount的维护、grow的触发条件、SubList的类定义、Itr内部类的迭代逻辑。读一遍源码抵得上背十遍八股文。把"为什么"搞明白,这些知识才会真正变成你自己的东西。