1. List不是"一个类",是三个实现撑起的容器江湖
很多初学者学了List之后,会觉得它就是ArrayList的别名,底下一个数组,查得快、插得慢,完事。但真正被业务代码毒打、被面试官追问之后,你才会意识到:List是一个接口,站在它背后的至少有ArrayList、LinkedList、Vector三个主力实现,它们的行为差异、适用场景、性能边界完全不同。把这三者的脾气摸透,才是真正"会"用List的第一步。
先看一个最常见的误区。网上铺天盖地的说法是"ArrayList查询快、增删慢,LinkedList增删快、查询慢"。这句话听起来对,但它粗糙到会误导人。LinkedList的增删"快"是有前提的,前提是你已经拿到了目标位置的节点引用,比如用listIterator在遍历过程中边找边删,这种情况下LinkedList确实是O(1)的删除。可如果你是用list.remove(index)去删中间某个元素,LinkedList依然要花O(n)从头遍历找到那个节点,删除本身是O(1)不假,但前置查找已经是O(n),整体根本谈不上快。更糟的是,LinkedList每个节点还要额外存前驱和后继两个引用,内存开销比ArrayList大得多。我在一个实际项目里测过,存100万个Integer对象,ArrayList底层的Object数组一次连续分配,而LinkedList要创建100万个Node对象,GC压力直接上一个台阶。所以"LinkedList增删快"这句话,真实适用面非常窄,绝大多数业务场景里ArrayList都是更稳的选择。
再看Vector。这个类现在基本属于历史遗留,它所有公开方法都用synchronized修饰,线程安全但代价是全局锁。单线程环境下Vector比ArrayList慢,多线程环境下Vector的粗粒度锁又比专门设计的并发容器差。我见过一些老项目中还在用Vector,大概率是从Java 1.x时代迁移过来的代码,没什么特殊理由的话,新代码完全没有理由再碰它。
下面这张表我把三种实现的关键差异列出来,是我在实际选型时真正会参考的维度,不是教科书上那种泛泛而谈:
| 维度 | ArrayList | LinkedList | Vector |
|---|---|---|---|
| 底层结构 | Object数组 | 双向链表 | Object数组(方法加锁) |
| 随机访问复杂度 | O(1) | O(n) | O(1) |
| 尾部插入复杂度 | 均摊O(1) | O(1) | 均摊O(1) |
| 指定位置插入 | 需要移动后续元素 | 需要先遍历定位 | 需要移动后续元素 |
| 内存占用 | 连续内存,有少量闲置容量 | 每个节点多两个引用 | 同ArrayList |
| 线程安全 | 否 | 否 | 是(全局锁) |
| 适用场景 | 绝大多数业务场景 | 频繁在头部操作或实现队列 | 几乎不推荐 |
选型结论就一句话:99%的场景无脑ArrayList,剩下的1%是当你确定要频繁地在List头部插入删除、且数据量很大时,才考虑LinkedList,或者如果你需要有界队列行为,用ArrayDeque都比LinkedList更轻量。
2. 源码视角下的扩容陷阱:为什么性能问题总藏在"看不见的地方"
ArrayList最容易被忽略的两个细节,一个是扩容机制,一个是modCount。这两个东西表面上看是源码层面的事情,但实际上它们决定了你在业务代码里写出来的循环、批量插入、甚至遍历删除,是流畅还是卡顿、是安全还是抛异常。
先说扩容。ArrayList初始容量是10,当元素个数达到容量上限时,会用grow方法扩容到原来的1.5倍,也就是int newCapacity = oldCapacity + (oldCapacity >> 1)。这里用右移一位实现除以2,是典型的位运算优化。为什么是1.5倍而不是2倍?扩容之后旧的数组要废弃,如果扩得太大,内存浪费多;如果扩得太小,频繁扩容导致频繁的数组拷贝。1.5倍是开销和空间利用率的折中。但这里有个实战问题,如果你不断用add逐条往里面塞100万条数据,ArrayList会从容量10一路扩容:10→15→22→33→……总共扩容大约log1.5次方次,每次都触发一次System.arraycopy,这个拷贝成本虽然均摊下来是O(1),但GC和内存抖动的开销是实打实的。所以如果你事先能估算出数据规模,直接用new ArrayList<>(expectedSize)或者ensureCapacity把容量预分配到位,性能提升非常明显。我自己压测过,预分配容量比不预分配在插入100万条时能快30%~50%,在GC耗时上的改善更显著。
再讲modCount。这个字段记录的是结构性修改次数。所谓结构性修改,就是改变List大小的操作,比如add、remove,而单纯的set替换元素不算。迭代器在创建时会记住当时的modCount,每次调用next或remove都会校验当前modCount是否和预期一致,不一致就抛ConcurrentModificationException。这就是为什么下面这段代码会炸:
List<String> list = new ArrayList<>(Arrays.asList("a", "b", "c", "d")); for (String s : list) { if (s.equals("b")) { list.remove(s); // 触发 ConcurrentModificationException } }foreach语法糖编译后用的是迭代器,迭代器发现modCount变了,立刻抛异常。这就是fail-fast机制——一有风吹草动马上暴露问题,而不是等到数据错乱的那一天才崩溃。但要强调一点:modCount是单线程内的自我保护机制,它不保证多线程下的原子性,所以它本质上查的是"迭代过程中有没有人动过集合结构",不是"集合是否线程安全"。
正确删除方式有这么几种,按推荐程度排:
- 使用
Iterator.remove(),这是唯一在迭代过程中安全的删除方法,因为它会把expectedModCount同步更新。 - 使用Java 8引入的
removeIf(Predicate),内部通过索引遍历并批量删除,一次性处理完再调整结构,效率很高。 - 倒着遍历索引,从size()-1递减到0,然后
remove(index),这样可以避免删除后索引错位的问题,但每次都触发现有元素的移动,性能一般。
还有一个我在代码评审里经常见到的坑,就是subList。List.subList(0, 5)返回的不是一个独立的List,而是原List的视图。对这个子List做任何结构性修改,都会反映到原List上,并让原List以及所有其他subList的modCount状态失效。很多人不知道这一点,拿subList去删除子区间元素,删完之后再去操作原List,莫名其妙抛ConcurrentModificationException,查半天都查不到原因。如果你确实需要一份独立的子集合,正确做法是new ArrayList<>(list.subList(0, 5)),拷贝一份出来。
这些源码细节,平时写CRUD代码时你觉得无所谓,但一旦涉及大数据量批量处理,或者线上偶现ConcurrentModificationException,回头来查的时候,知道这些原理的人五分钟定位,不知道的人排查一天。基本功这东西,平时看不见,出事时就见高下了。
3. 多线程环境下的List:CopyOnWriteArrayList和Collections.synchronizedList的博弈
多线程并发修改同一个ArrayList,最直接的结果不只是抛异常的问题,而是数据错乱:两个线程同时扩容,各自拷贝各自的数组,然后互相覆盖,最后大小对不上、元素凭空消失,这种问题一旦发生,现场通常已经不可复现,只能靠日志硬猜。所以多线程环境下用List,必须换思路。
Java给我们的并发List选择,说白了就两条路:要么用CopyOnWriteArrayList,要么用Collections.synchronizedList(new ArrayList<>())。这两者的取舍很多人在面试时背得滚瓜烂熟,但真正落到代码层面就分不清了。我帮你把逻辑理一遍。
CopyOnWriteArrayList的核心思想是"写时复制"。每次add、remove这类修改操作,都会把底层数组完整复制一份,在新数组上做修改,然后把volatile修饰的数组引用切换为新数组。读操作不加锁,直接读,因为数组引用是volatile的,写线程对数组内容的修改在读线程切换引用后是可见的。这个设计使得读操作性能极高,非常适合读多写少的场景,典型例子是监听器列表,可能被很多线程同时读取遍历,但注册和移除监听器的事件频率低。
代价呢?每一次写都要O(n)的数组复制。如果你在一个循环里往CopyOnWriteArrayList里塞一万条数据,那就是一万次全量复制,复杂度直接从O(n)变成O(n^2)。所以CopyOnWriteArrayList绝不适合"频繁写"的场景。我见过有人拿它当普通业务列表用,结果线上CPU飙高,一看GC日志全是年轻代晋升失败,就是因为复制太频繁。
Collections.synchronizedList的思路更简单粗暴,它用synchronized块把所有读写方法都锁住。这在写多读也多的场景下更实用,但它有两个隐蔽问题。第一,它的迭代器不是线程安全的,遍历的时候依然要手动加锁,否则可能抛ConcurrentModificationException。官方源码注释里明确写了这一点,但很多人没注意。第二,它锁的是整个List,并发程度低,一旦数据量大、操作频繁,锁竞争会非常严重。
那有没有兼具两者优点的方案?说实话,没有银弹。如果读远大于写,选CopyOnWriteArrayList;如果读写比较平均、数据量不大,选synchronizedList;如果并发度要求很高、数据量又大,你就得考虑换别的数据结构了,比如ConcurrentLinkedDeque,或者用分段思路自己设计。顺便说一句,Vector也是线程安全的,但它直接用synchronized修饰方法,和synchronizedList其实是同一类思路,只是Vector是JDK原生的、没有额外的迭代器加锁提示,所以在新代码里没有任何理由选Vector。
我在实际项目里测过一组数据:4个线程并发写、8个线程并发读,每个线程操作5万次,CopyOnWriteArrayList的总耗时大约是synchronizedList的2.3倍,因为写操作的复制成本压过了读操作的无锁优势。反过来,如果改成1个线程写、20个线程读,CopyOnWriteArrayList就反超了,快大概40%。所以别再背结论了,先明确你业务的读写比例,再做选型。
4. 从"老式for循环"到Stream:List操作的方式进化与性能得失
Java 8之后,List的操作方式发生了一次很大的变化。以前我们要过滤、转换、分组一个List,要么写for循环,要么写一堆临时变量,代码又长又容易出错。现在用Stream API,几行流式操作就结束了。但这里有个常见分歧:很多人觉得Stream就是优雅的玩具,性能不如传统for循环;也有很多人觉得Stream天下无敌,所有集合操作都应该用Stream。这两种观点都过于极端。我做了不少基准测试,我的结论是:数据量不大(几千条以内)的时候,两者性能差距微乎其微,根本构不成选型理由;但代码可读性和维护性的差距是肉眼可见的。
举例来说,从一个订单List里找出金额超过1000的订单并按时间排序,用传统写法是这样:
List<Order> result = new ArrayList<>(); for (Order order : orders) { if (order.getAmount() > 1000) { result.add(order); } } result.sort(Comparator.comparing(Order::getCreateTime));用Stream写法是这样:
List<Order> result = orders.stream() .filter(o -> o.getAmount() > 1000) .sorted(Comparator.comparing(Order::getCreateTime)) .collect(Collectors.toList());哪个更直观?显然是后者,它把"过滤"和"排序"的操作意图直接写在方法名上。尤其是团队协作时,Stream的可读性能让接手的人一眼看懂这段代码在干什么,而for循环需要一行一行读逻辑。所以除非是性能敏感的热点路径,我建议优先用Stream写集合操作。
再补充几个Stream时代的高频操作,都是实际业务里特别常用的:
- 分组统计:
list.stream().collect(Collectors.groupingBy(Order::getStatus)),直接得到一个Map<status, List >。如果你想要每个key的计数,用Collectors.groupingBy(Order::getStatus, Collectors.counting())。 - 转成Map并处理key冲突:
list.stream().collect(Collectors.toMap(Order::getId, Function.identity(), (oldVal, newVal) -> newVal))。第三个参数是关键,遇到重复key时保留新值,不写这个参数只要id重复就抛IllegalStateException。 - 拆分为两个集合:
list.stream().collect(Collectors.partitioningBy(o -> o.getAmount() > 1000)),返回一个Map<Boolean, List<Order>>,true键和false键分别对应满足和不满足条件的元素。比你自己写两个for循环分别add省事得多。
但Stream也不是万能药。有几个场景我不建议用Stream:第一个是循环内部涉及复杂的状态累积,比如遍历时既要根据上一个元素做判断,又要维护多个中间变量,这种逻辑写成Stream会非常绕;第二个是性能极端敏感、数据量百万级以上、并且需要控制在毫秒级的场景,Stream的lambda装箱和额外的中间操作会有可测的开销;第三个是老到不能再老的代码风格统一问题,如果整个项目都是Java 7风格,只有你一个人用Stream,那维护成本反而上升。
从Java 9开始,List还多了个List.of工厂方法,可以快速创建不可变列表。注意是不可变,任何add、remove操作都会抛UnsupportedOperationException。这个API在初始化常量列表时特别方便,比如配置项、枚举值列表,比Arrays.asList更安全,因为Arrays.asList返回的是固定大小的List,但可以通过set修改元素。这一点经常有人搞混,单独拎出来说一下。
5. 业务场景高频操作:去重、排序、取差集的一网打尽方案
实战篇来点真东西。在我的代码评审经验里,List相关的操作无非是这么几类高频需求:去重、排序、批量转换为其他结构、求交集差集。这些操作看起来简单,但每类都有两三个隐蔽的坑。
先看去重。最简单的方法是list.stream().distinct().collect(Collectors.toList()),这个方法依赖元素的equals方法。如果你的元素是String、Integer这种基础类型,直接用没问题;如果是自定义对象,比如Order,你要么重写equals和hashCode,要么用指定字段去重。指定字段去重有一个很经典的技巧,用Collectors.toMap收集器:
List<Order> deduplicated = orders.stream() .collect(Collectors.collectingAndThen( Collectors.toMap(Order::getId, Function.identity(), (oldVal, newVal) -> oldVal), map -> new ArrayList<>(map.values()) ));这个写法用toMap的merge函数控制保留哪个,然后取values收集成List。注意一点,Collectors.toMap默认返回的是HashMap,不保证放入顺序。如果你需要保持原始List的相对顺序,就要用LinkedHashMap作为Map工厂,也就是Collectors.toMap(Order::getId, Function.identity(), (oldVal, newVal) -> oldVal, LinkedHashMap::new)。这个细节很容易被忽略,一旦你去重后的顺序乱了,线上排查半天。
再来看排序。List自身的sort方法在Java 8之后可以直接传入Comparator,不用再经过Collections.sort了,写法是list.sort(Comparator.comparing(Order::getAmount))。如果你想多字段排序,可以链式调用Comparator.comparing(Order::getAmount).thenComparing(Order::getCreateTime)。注意空指针问题,如果排序字段可能为null,要先写Comparator.nullsLast(Comparator.comparing(Order::getAmount)),否则排序时一遇到null就抛NPE。我见过不少线上问题就是排序字段里混了几个null值,直接导致整个批量任务失败。
最后说交集差集。很多人第一反应是用list.retainAll和list.removeAll,但这两个方法是会改动原List的,而且内部是双层循环,效率是O(n*m)。数据量小的时候无所谓,数据量大了就很慢。更优雅的方案是先把一个List转成HashSet,然后用另一个List去stream过滤,这样复杂度降为O(n+m)。比如两个用户ID列表,要找出A有B没有的:
Set<String> idInB = new HashSet<>(listB); List<String> onlyInA = listA.stream() .filter(id -> !idInB.contains(id)) .collect(Collectors.toList());这个模式我几乎每天都在用,屡试不爽。核心思路就是:集合判断用HashSet,List只做有序存储的容器,不要让List自己承担集合运算。
另外还有一个经常出现在业务代码里的需求:把List转成用逗号分隔的字符串。老式写法是for循环拼接然后去掉末尾逗号,Java 8之后用String.join(",", list)一行搞定,或者如果你需要对每个元素做格式化后再拼接,可以用list.stream().map(String::valueOf).collect(Collectors.joining(","))。这个API简单到容易被人忽略,但在生成SQL的IN子句、拼接日志、导出CSV头的时候非常常用。
6. 从List出发,重新理解Java集合框架的设计哲学
聊了这么多List的细节,最后想跳出来看一眼整个集合框架的设计。你会发现List接口的设计其实贯穿着Java容器的一个核心思想:接口和实现分离,行为和性能解耦。我们面向List编程,不考虑底层是数组还是链表,这就是多态的意义。理解这一点,你写代码时才会自然地用List<String> list = new ArrayList<>()而不是ArrayList<String> list = new ArrayList<>()。前者让代码依赖抽象,后续替换实现类不需要改动业务代码;后者把实现细节暴露给所有依赖方,一换实现类可能牵连一片。
集合框架里还有一条隐藏的设计主线:快速失败(fail-fast)和安全失败(fail-safe)。List的迭代器是fail-fast的,一旦迭代过程中结构被修改,立即抛异常;而java.util.concurrent包下的容器,比如CopyOnWriteArrayList的迭代器是fail-safe的,它迭代的是创建时的快照,所以迭代过程中其他线程修改集合不会抛异常,但也读不到这些修改。这两者的取舍没有对错,只是设计目标的差异。fail-fast是尽早暴露编程错误,fail-safe是保证并发遍历的稳定。理解这个哲学背景,你就不容易在使用时产生迷思。
还有一点很重要:equals和hashCode契约。List的contains、indexOf、remove(Object)都依赖equals;HashSet、HashMap的依赖更加强,hashCode和equals必须保持一致,否则相同的逻辑对象可能被当成不同元素。业务中经常遇到的情况是,自定义对象只重写了equals没重写hashCode,或者两者都没重写,导致去重和包含判断全错。这一点在List操作中尤其容易踩雷因为很多人默认List的contains是"比较引用",其实不然,它调用的就是element.equals,所以是否重写equals直接决定contains的行为。
从我个人经验来说,学集合框架最有效的方式不是背API,而是去看源码。ArrayList三百行核心代码,CopyOnWriteArrayList两百行,LinkedHashMap两百行,看透了这几个类,你会对Java容器的并发策略、扩容思想、迭代机制有直觉级别的理解。之后无论是在面试中聊集合,还是在线上排查和List相关的问题,都能做到心里有数,而不是临时查文档。
我在实际干活的时候,有一个习惯:凡是遇到循环里有集合操作的代码,都会多问一句"这个操作的时间复杂度是多少""有没有办法用Set或者Map降低复杂度"。这不是矫情,而是线上数据量一上来,O(n*m)的代码就是事故的种子。List作为门槛最低的容器,从来不是"会add、get、remove"就算会了,关键是在合适的场景做出正确的设计选择。希望这篇基于实操经验的梳理,能帮你把List从"会用的工具"变成"用得好的武器"。