资讯动态

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

发布时间:2026/9/10 7:46:21 来源:尧图企业网站定制
我先把话说明白这篇文章会直接深入 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_CAPACITY10。这个设计的洞察在于很多 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 时传入初始容量避免多次扩容造成无谓拷贝。// 推荐的做法 ListString 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 NodeE { E item; NodeE next; NodeE prev; Node(NodeE prev, E element, NodeE 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 时代遗留类。线程安全但性能差。官方建议用ArrayListCollections.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); } }这段代码在删除后会触发一个问题删除 indexi 的元素后后面的所有元素都往前移了一位但你的循环变量 i 还在继续递增这样就会漏掉原本排在 i1 位置的元素。正确做法是删除后i--或者使用迭代器。IteratorString 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 当作副本来用。如果你需要的是一个独立的新列表请这样写ListString subList new ArrayList(originalList.subList(1, 3));或者用 StreamListString 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 ListEventListener 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依然会有并发问题ListString 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。ArrayListString 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 倒序遍历删除。三个方案各有适用场景// 方案一迭代器 IteratorString 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 ListString list new ArrayList(Arrays.asList(array)); // Java 8 ListString 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 很慢如果你写了一个ListUser要去重可能会写ListUser uniqueList new ArrayList(); for (User user : allUsers) { if (!uniqueList.contains(user)) { uniqueList.add(user); } }这段代码在小规模数据下没问题但在数据量大时contains需要逐个调用equals整体复杂度 O(n²)性能会急剧下降。正确做法是借助 Set 来辅助去重SetString seen new HashSet(); ListUser 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什么时候该抛弃 List6.1 一个决策表帮你摆脱选择困难场景首选理由随机访问多按下标获取元素为主ArrayListO(1) 寻址缓存友好尾部追加为主数据量可预估ArrayList摊还 O(1)扩容可控头部/尾部频繁插入删除LinkedListO(1) 修改指针无需移动元素多线程读多写少遍历频繁CopyOnWriteArrayList读无锁遍历稳定多线程读写均衡不使用 List 或使用并发集合替代普通 List 需要重度加锁易出性能瓶颈数据量不大逻辑简单ArrayList简单直接可读性好需要频繁在中间插入删除看情况多数场景仍选 ArrayList链表的查找成本抵消了插入优势这张表不是我随便拍的背后的核心逻辑是你的操作模式决定数据结构。6.2 大数据量下的性能实测方法与其争论理论谁快不如自己在本地写一个压测小 Demo。我习惯用 JMHJava 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 条做展示。代码大概长这样ListTransaction top10 transactions.subList(0, 10); top10.clear(); // 不小心执行了清空结果是什么clear()操作不仅在视图上生效还直接把原列表transactions的前 10 条记录全删了。转账明细直接少了一页这种事故的严重性不用多说。复盘结论subList返回的对象在语义上就是原列表的一个窗口修改窗口的增删操作会直接影响原列表。如果你尝试对 subList 做 clear、add、remove都会被原列表感知到。很多不熟悉视图语义的开发者会在不知情的情况下触发结构性修改甚至导致底层数据被误删。这不是 Java 的 bug是对视图概念理解不深导致的误用。安全姿势是拷贝副本除非你确实需要一个共享的视图来同时修改两边。7.2 用 Arrays.asList 后调用 add 导致的报错Arrays.asList的底层数组是固定长度的。有同事在写批量导入功能时这样搞ListString ids Arrays.asList(a, b, c); ids.add(d); // UnsupportedOperationException排查的时候他一脸懵因为他记得List 不是可以 add 的吗。这里的问题就在于Arrays.asList返回的 List 并不是真正的java.util.ArrayList而是一个不可变长度、但元素值可变的列表。换句话说它支持set(index, value)但不支持改变容量。如果你需要一个可变长度的列表一定记得套一层ListString 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。接下来 i1 对应的实际上变成了原来下标为 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 扩容 ArrayListInteger arrayList new ArrayList(); for (int i 0; i 15; i) { arrayList.add(i); } System.out.println(ArrayList size arrayList.size()); // 2. 验证 Arrays.asList 不可变长度 ListInteger 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 视图 ListString source new ArrayList(List.of(a, b, c, d)); ListString view source.subList(1, 3); view.set(0, x); System.out.println(source); // [a, x, c, d] // 4. 验证迭代器删除 ListString words new ArrayList(List.of(apple, banana, cherry)); IteratorString it words.iterator(); while (it.hasNext()) { String word it.next(); if (word.startsWith(b)) { it.remove(); } } System.out.println(words); // [apple, cherry] // 5. 验证 LinkedList 头尾操作 LinkedListString 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内部类的迭代逻辑。读一遍源码抵得上背十遍八股文。把为什么搞明白这些知识才会真正变成你自己的东西。

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

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

免费获取报价