两个List之间的操作,可以说是Java日常开发里最高频的集合处理场景之一。不管是做订单与商品匹配、用户与权限关联,还是老系统里内存数据比对,凡是涉及到“两拨数据对一对”的需求,最终都会落到List的交集、差集、或者类似SQL左连接这种“以左表为准补全右表信息”的操作上。这篇内容我按自己平时写代码的习惯来梳理,从最基础的JDK写法到Stream流方式,再到复杂度分析、典型面试问法,一次说透。
1. 先搞清楚需求边界:你要的是交集,还是“左连接”
很多同学一上来就写listA.retainAll(listB),但写着写着发现不对——retainAll是直接改原List的,而且它依赖元素的equals方法,一旦元素是自定义对象而你没有重写equals,结果就是空的或者错的。所以在动代码之前,必须先把需求拆清楚:你是要对两个集合中的某个字段做匹配,还是对整个对象做相等判断?是要找出共有的元素,还是要以左边List为准,把右边List的额外信息拼过来?
这两类需求在实现上完全是两码事。取交集,通常只需要返回公共元素集合,不关心右表还有哪些额外字段;左连接则不一样,左连接的语义是“左表记录全部保留,右表有匹配就补上字段,没有匹配就补null”,对应到Java里,就是你有一个List<Order>和一个List<User>,你想给每个订单补充用户昵称,即使某些订单的userId在用户表里找不到,订单照样要显示出来。搞清楚这个区别,后面选型才不会跑偏。
另外还要注意一个细节:取交集的结果是否要去重?SQL里的INNER JOIN如果两边是一对多关系,结果会膨胀;Java里如果两个List本身就有重复元素,retainAll的结果也会出现重复。所以动手之前,把“去不去重、要不要保持顺序、以谁为准”这三个问题先定了,远比写代码更重要。
2. 取交集的四种实现方式
2.1 最直接的JDK原生写法:retainAll
List<Integer> listA = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5)); List<Integer> listB = Arrays.asList(4, 5, 6, 7, 8); listA.retainAll(listB); System.out.println(listA); // [4, 5]这段代码最简单,但有两个坑需要注意。
第一个坑是retainAll会修改调用它的List,也就是listA本身。如果你后续还要用原始数据,必须先new ArrayList<>(listA)拷贝一份,否则原始数据就丢了。第二个坑是性能问题,retainAll底层是遍历调用方集合,逐个判断是否包含在参数集合中,而contains对于ArrayList来说是O(n)的线性查找,所以整体复杂度是O(n*m)。如果两个List都是上万级别的数据量,这个写法会卡到你怀疑人生。
用retainAll的正确姿势是这样的:
List<Integer> listA = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5)); List<Integer> listB = Arrays.asList(4, 5, 6, 7, 8); List<Integer> result = new ArrayList<>(listA); result.retainAll(listB);这样既保留了原数据,又拿到了交集。如果你的List元素是自定义对象,就必须重写equals()和hashCode(),否则比较的是对象引用,几乎不会相等。
2.2 contains结合ArrayList遍历
List<Integer> listA = Arrays.asList(1, 2, 3, 4, 5); List<Integer> listB = Arrays.asList(4, 5, 6, 7, 8); List<Integer> result = new ArrayList<>(); for (Integer item : listA) { if (listB.contains(item)) { result.add(item); } }这个写法和retainAll本质一样,都是遍历A然后查B,复杂度同样是O(n*m)。那为什么还要写它?因为这种写法给了你更大的控制权——你可以在这个循环里做额外的逻辑,比如收集匹配到的B元素而不是A元素,或者统计匹配次数,甚至同时记录匹配位置。retainAll是个黑盒,它不会告诉你哪个匹配了、哪个没匹配。我在做数据比对工具时经常用这种写法,因为我要的不只是结果集,还要知道“哪些在A里有但B里没有”的明细,一个循环全搞定。
2.3 用HashSet做优化:从O(n*m)降到O(n)
List<Integer> listA = Arrays.asList(1, 2, 3, 4, 5); List<Integer> listB = Arrays.asList(4, 5, 6, 7, 8); Set<Integer> setB = new HashSet<>(listB); List<Integer> result = new ArrayList<>(); for (Integer item : listA) { if (setB.contains(item)) { result.add(item); } }这段代码的关键就一句话:把listB转成HashSet,contains就从O(n)变成了O(1)。整个交集计算的复杂度从O(n*m)降到O(n+m),对于大数据量来说,这个优化是质变。
为什么HashSet的contains是O(1)?因为哈希表通过哈希函数直接定位桶的位置,理想情况下一次命中,不需要遍历。这也是为什么我在任何需要频繁做成员判断的场景里,第一反应永远是“先转Set再说”。当然这里有个前提:元素要正确重写hashCode(),否则哈希值不唯一,性能优势就没了。
2.4 Stream API写法:代码最优雅
List<Integer> listA = Arrays.asList(1, 2, 3, 4, 5); List<Integer> listB = Arrays.asList(4, 5, 6, 7, 8); Set<Integer> setB = new HashSet<>(listB); List<Integer> result = listA.stream() .filter(setB::contains) .collect(Collectors.toList());Stream写法本质上和第三种一样,也是用Set加速,但代码可读性更高。filter(setB::contains)的意思就是“只要集合B里有的元素”,一眼就能看懂。如果想顺便去重,加一个.distinct()就行,如果想保持原顺序,Stream默认按流顺序收集,天然保持listA的顺序。
List<Integer> result = listA.stream() .filter(setB::contains) .distinct() .collect(Collectors.toList());需要注意一个细节:distinct()的底层是LinkedHashSet,它也能保持遇见顺序,所以去重后顺序不乱。如果你的数据是百万级别的,建议在转Set时用HashSet而不是TreeSet,因为TreeSet的插入是O(log n),整体要额外付出排序成本。
3. 模拟SQL左连接:以左表为准,右表补信息
3.1 为什么List没有原生的leftJoin方法
JDK里确实没有一个叫leftJoin的API,这和集合类的设计哲学有关——List只负责存储和顺序,不负责关系运算。关系运算是SQL的强项,因为数据库有索引、有优化器,能自动选择最佳连接算法;但在内存里做两个List的连接,你需要自己决定用什么策略。
最直观的思路是双层循环:外层遍历左表,内层遍历右表,匹配到就补充信息。这种写法在数据量小的时候完全可行,可一旦两个List都过万,O(n*m)的复杂度就会让接口响应时间直线上升。所以我在实战中很少用双层循环,而是用Map分组的方式,把“连接键”作为Map的key,右表数据作为value,一次遍历把右表装进Map,第二次遍历左表直接O(1)查Map,总复杂度O(n+m)。
3.2 手写一个LeftJoin:Map分组实现
假设有两个类,Order和User,需求是给每个订单补充用户姓名,订单表里没有对应用户的,姓名留null。
public class Order { private Long orderId; private Long userId; private String userName; // getter/setter 省略 } public class User { private Long userId; private String name; // getter/setter 省略 }模拟左连接的经典写法如下:
List<Order> orders = getOrders(); List<User> users = getUsers(); // 1. 以userId为key,把users转为Map Map<Long, User> userMap = users.stream() .collect(Collectors.toMap(User::getUserId, Function.identity())); // 2. 遍历订单,匹配用户信息 orders.forEach(order -> { User user = userMap.get(order.getUserId()); if (user != null) { order.setUserName(user.getName()); } });这一步做完,orders里的每个订单只要在users里能找到对应的userId,就会把用户姓名填进去。找不到的就保持null,这恰恰是左连接的语义。
实际项目中经常遇到一个坑:Collectors.toMap如果遇到重复key,会直接抛IllegalStateException。比如用户表里真有两条相同的userId(数据库不该有,但脏数据经常出现),整个Stream就崩了。解决方法是给toMap加第三个参数,冲突时保留旧的或合并:
Map<Long, User> userMap = users.stream() .collect(Collectors.toMap( User::getUserId, Function.identity(), (oldValue, newValue) -> newValue ));第三个参数(oldValue, newValue) -> newValue表示遇到重复key时,用后出现的值覆盖先出现的值。如果业务上需要的是保留所有记录而不是覆盖,那就要在分组时用groupingBy,把相同key的多条记录收集成List,然后处理时再决定拿第一条还是合并字段。
Map<Long, List<User>> userMap = users.stream() .collect(Collectors.groupingBy(User::getUserId)); orders.forEach(order -> { List<User> matchedUsers = userMap.get(order.getUserId()); if (matchedUsers != null && !matchedUsers.isEmpty()) { order.setUserName(matchedUsers.get(0).getName()); } });3.3 嵌套循环与性能拐点
这里必须提一句,嵌套循环不是不能用,它有两个应用场景依然很香:一是数据量极小,比如两个List都不超过100条,这种量级下O(n*m)也就几万次操作,毫秒级完成,写双层循环反而比转Map更直观;二是你需要的是“笛卡尔积式的匹配”,比如找到所有能组成特定价格组合的商品对,这个时候任何索引优化都没用,必须全量两两比对。
但如果左表有10000条、右表有10000条,嵌套循环就是1亿次操作。在普通笔记本上,1亿次简单的对象getter调用大约需要几百毫秒到几秒,放到线上接口里基本就超时了。用Map方案,构建Map是O(m),查询是O(n),总共约2万次操作,连1毫秒都用不了。这就是我文章开头说的“性能是质变”的含义。
判断是否该用Map优化,我一般遵循一个经验法则:两个List的数量级乘积超过100万,就直接上Map方案,别犹豫。100万次操作在Java里虽然也很快,但代码一旦放到循环里做额外业务逻辑(比如调用远程服务、写日志、更新缓存),时间会成倍放大,留足性能余量永远没有错。
3.4 对象引用 vs 值拷贝:左连接时的字段更新策略
做左连接时,你面临一个选择:是直接修改左表对象的属性(引用更新),还是构造一个新的DTO对象(值拷贝)。
引用更新最简单,代码少,性能也好,但它有个隐患——如果orders这个List被多个线程共享,或者后续还有别的逻辑也在使用这批订单对象,修改字段会直接影响其他调用方。我之前在某个项目里就踩过这个坑:A服务把某个DTO列表传给B服务,B服务在补充字段时直接改了原对象,导致A服务后续打印日志时发现数据被“污染”了,排查了半天才发现是引用共享问题。
更稳妥的做法是构造新的返回对象,尤其是接口对外返回时,尽量不要改动内部模型的引用。实现上也很简单,用一个拷贝构造函数或者BeanUtils.copyProperties:
List<OrderVO> result = orders.stream().map(order -> { OrderVO vo = new OrderVO(); BeanUtils.copyProperties(order, vo); User user = userMap.get(order.getUserId()); if (user != null) { vo.setUserName(user.getName()); } return vo; }).collect(Collectors.toList());代码多了一些,但把对原对象的改动隔离了,后续再调试时不会因为“谁改了共享对象”这类问题头疼。如果你的系统里集合对象传递链路很复杂,我强烈建议走值拷贝这条路。
4. 两个List操作的复杂度与性能对比
4.1 大O分析是选型基础
写集合操作前,养成估算复杂度的习惯,比背任何八股文都重要。我把上面提到的各种方案整理成一张对照表,方便你直接用:
| 场景 | 实现方式 | 时间复杂度 | 空间复杂度 | 是否修改原List | 推荐数据量 |
|---|---|---|---|---|---|
| 取交集 | retainAll | O(n*m) | O(1) | 是 | 百级以内 |
| 取交集 | contains + 循环 | O(n*m) | O(1) | 否 | 百级以内 |
| 取交集 | HashSet + contains | O(n+m) | O(m) | 否 | 万级以上 |
| 取交集 | Stream + Set | O(n+m) | O(m) | 否 | 万级以上 |
| 左连接 | 双重循环 | O(n*m) | O(1) | 视实现而定 | 百级以内 |
| 左连接 | Map分组 | O(n+m) | O(m) | 视实现而定 | 万级以上 |
注意空间复杂度列,HashSet和Map方案都是用空间换时间,多出来的内存占用大概是右表大小乘以元素对象引用的大小,对于普通业务数据(几千到几万条)完全可以忽略,但如果你操作的是百万级大对象,就需要评估一下JVM堆内存了。
4.2 实测案例:从13秒到2毫秒
我之前在一个报表导出功能里处理过类似问题:从订单服务拉回一批订单明细,从用户服务拉回用户信息,需要把用户名填充到订单里。最初版本用的是双重循环,两个List分别是1.2万条和8000条,算下来就是9600万次匹配操作,加上中间做了字段拷贝和格式化,接口整体耗时13秒多,直接被前端调用方投诉超时。
改成Map方案后,构建Map耗时几乎为0,遍历订单补充字段只用了不到2毫秒,整个接口从13秒降到200毫秒以内。那次优化之后我彻底记住了这个经验:任何两个集合的连接操作,只要规模超过几千,都优先考虑Map分组或HashSet方案,不要迷信“先跑通再说”,因为数据量增长是指数的,代码返工的成本远高于一开始多写几行。
4.3 并行流是加速神器,但别乱用
Stream的parallelStream()可以把连接操作并行化,核心原理是ForkJoinPool把大任务拆成小任务,多线程并行处理。代码改动也很小:
List<Order> result = orders.parallelStream() .map(order -> { // 匹配和补字段逻辑 }) .collect(Collectors.toList());但并行流有三个前提条件:一是数据量要足够大,至少要几万条起步,否则线程创建和任务拆分的开销反而比单线程更大;二是操作必须是线程安全的,如果你的匹配逻辑里有共享可变状态,比如一个公共的HashMap在并发写入,那崩得会比串行更快;三是不要在并行流里调用远程接口或操作数据库,因为这会放大IO阻塞,导致线程池被占满。
我之前在某个定时任务里用parallelStream处理过20万条数据的关联,性能确实提升了4倍左右,但那次任务的匹配逻辑是纯内存计算,没有外部依赖。如果让我现在做选型,第一选择永远是串行Map方案,只有等真实压测发现性能瓶颈了,才会去动并行流。
5. 面试怎么问,怎么答:List操作背后的八股文
5.1 为什么ArrayList的contains是O(n),HashSet的contains是O(1)
这个问题是Java集合框架面试里最常被追问的考点。ArrayList底层是数组,contains的实现是遍历数组逐个调用equals,数组不像哈希表那样有“直接定位”的能力,所以只能线性查找。HashSet底层是HashMap,元素作为key存储,计算key的哈希值后定位到哈希桶,理想情况下每个桶只有一个元素,所以一次就能找到。
面试官追问的进阶点是:如果HashSet的哈希冲突严重(所有元素都落到同一个桶),contains退化成什么复杂度?答案是最坏情况O(n),Java 8之后的HashMap在桶内元素超过8个时会转成红黑树,把最坏情况从O(n)降到O(logn)。所以在自定义对象的hashCode()实现上,一定不能图省事返回固定值,否则HashSet性能会直接劣化成链表。
5.2 两个List取交集有哪些方式,你倾向哪种
面试题往往把答案限定在“你用过哪几种方式”,但最好的回答方式是先把复杂度讲清楚,再给出你的选型判断。我一般这样答:
retainAll最简单但会修改原集合且复杂度高;HashSet+contains是性能最优解,复杂度O(n+m);Stream API是语义最清晰的写法,底层同样可以用Set优化。面试官如果继续问性能,就把核心点抛出来——把大集合转成HashSet,让contains变成O(1),集合操作就从n乘m变成n加m。
5.3 手写左连接,你会怎么写
手写题的核心得分点其实不是代码本身,而是你有没有意识到“用Map做连接键索引”这个优化。写双层循环是最容易想到的答案,但面试官会追问“如果数据量特别大呢”,这时候能写出Map分组方案,并且说明为什么复杂度从O(n*m)降到O(n+m),基本就能过关。
另外一个常考的点是Collectors.toMap遇到重复key的处理方式。很多面试者在这里卡住,因为报错信息IllegalStateException: Duplicate key不会出现在平时的练习里。提前准备好第三参数(oldValue, newValue) -> newValue的写法,面试时就能直接答出来。
5.4 LinkedHashMap和HashMap在保持顺序上的区别
当左连接的输出顺序有要求时,比如“补全用户信息后,订单顺序不能变”,有人说用LinkedHashMap,有人说用HashMap。这里要分清Map的遍历顺序和查询顺序。HashMap不保证遍历顺序,但如果你只是用它做get查询,遍历左表时的顺序完全取决于左表自身,跟Map的类型没有关系。只有当你需要遍历Map本身,且要求按插入顺序输出时,才需要LinkedHashMap。
左连接场景下,你要保留的是左表的顺序,所以只需保证左表List本身有序,Map用什么实现都无所谓。这个点我在代码评审时见过很多次,有人为了“保持顺序”专门用LinkedHashMap建索引,其实是多余的。
5.5 基于接口编程:为什么用List声明而不是ArrayList
还有一个小细节容易被面试官抓着考:为什么代码里通常写List<Integer> listA = new ArrayList<>(),而不是ArrayList<Integer> listA = new ArrayList<>()。因为面向接口编程,后续如果想换成LinkedList,只需要改右边的构造部分,调用方的所有List方法都保持不变。同样的道理,当我们用Set转List再转回Set时,变量类型最好都声明为接口类型,代码的扩展性会好很多。
6. 实战避坑:我踩过的那些List操作相关的坑
6.1 用final修饰List,里面的值还是能改
这是很多Java初学者乃至面试者都会搞混的问题。final List<Integer> list = new ArrayList<>()代表的是引用的指向不能再赋值,也就是说不能再执行list = new ArrayList<>(),但是list.add()、list.remove()、list.set()都是合法的。
如果希望List本身也不可改,要用Collections.unmodifiableList()包装一层,或者用Java 9之后的List.of()。注意,Java 9之前的Arrays.asList()返回的是一个定长List,不能add也不能remove,但可以set替换已有元素。这几个“不可变”层级在不同版本里表现完全不同,建议写代码时亲手验证一遍,面试才不会说错。
6.2 两个List取交集时元素是自定义对象,必须重写equals
默认的equals()比较的是对象引用,也就是说两个对象即使字段完全一样,只要不是同一个对象,equals就返回false。如果你不重写equals()和hashCode(),retainAll、contains、HashSet的去重和查找全部失效。
正确重写方式是用IDE自动生成,或者用Objects.equals和Objects.hash:
@Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; User user = (User) o; return Objects.equals(userId, user.userId) && Objects.equals(name, user.name); } @Override public int hashCode() { return Objects.hash(userId, name); }实际开发中,如果两个对象表示同一条业务数据(比如同一个订单),通常只需要比较业务主键,而不是所有字段。所以专门为“交集”定义仅包含业务主键的相等逻辑,也是一个常用手段,比如用Map<Long, Order> map = orders.stream().collect(Collectors.toMap(Order::getOrderId, Function.identity()))来按主键做匹配。
6.3 null值处理:HashSet.contains(null)是安全的
HashSet允许null值,contains(null)也不会抛异常,这是HashMap支持null key带来的特性。所以如果你的List里混有null,用Set方案不会崩。但Collectors.toMap的key不能为null,会直接抛NullPointerException。也就是说,在做左连接时,如果右表的连接键字段可能为null,你需要先过滤:
Map<Long, User> userMap = users.stream() .filter(u -> u.getUserId() != null) .collect(Collectors.toMap(User::getUserId, Function.identity()));这个问题很隐蔽,因为线上数据一旦出现null连接键,接口就会500,而数据量小的时候又很难复现。提前加过滤,成本极低,收益却很大。
6.4 EasyExcel导出场景:嵌套List怎么配合
有些项目里,EasyExcel导出需要按行填充数据集,如果单元格内容是嵌套List,比如一行订单对应多个商品,你需要先把内层List拼成字符串,或者在模板里用{{$!{detailList}}}这种方式循环渲染。这和List左连接操作看似无关,但它们经常在同一段代码里出现——先做关联补全,再组装导出数据。
如果模板渲染里遇到嵌套List填充失败,优先检查两点:模板里的字段名是否和VO属性名一致;内层List的元素对象是否提供了正确的getter。很多时候导出不出数据,不是取交集或左连接的逻辑问题,而是导出模板的表达式写错了,排查时不要被“数据处理”带偏。
6.5 防止内容传递导致的混乱:List分组后有序性
Java的Collectors.groupingBy默认返回的是HashMap,不保证分组结果的key顺序。但分好组之后,每个key对应的value是List,这个value列表的顺序是保留原始数据顺序的,因为groupingBy内部用ArrayList接收元素。如果连分组key都要排好序,用groupingBy(Function.identity(), LinkedHashMap::new, Collectors.toList())指定Map实现。
7. 最后的选型建议
如果你问我日常开发中最常用的List关联方案是什么,我会说:取交集用HashSet+contains方案,左连接用Map分组方案,两者都不会修改原集合,性能都是O(n+m),而且代码简洁易维护。
如果你在乎代码可读性,取交集优先用Stream API;如果你在乎老代码兼容性,就别用List.of(),统一用Collections.unmodifiableList;如果数据量小到几十条,怎么简单怎么写,别过度设计。记住一个核心原则:集合操作永远先评估数据量,再选方案,最后才是微调代码格式。这套思路能让你在绝大多数场景下少踩坑、少返工。