
两个List之间的操作可以说是Java日常开发里最高频的集合处理场景之一。不管是做订单与商品匹配、用户与权限关联还是老系统里内存数据比对凡是涉及到“两拨数据对一对”的需求最终都会落到List的交集、差集、或者类似SQL左连接这种“以左表为准补全右表信息”的操作上。这篇内容我按自己平时写代码的习惯来梳理从最基础的JDK写法到Stream流方式再到复杂度分析、典型面试问法一次说透。1. 先搞清楚需求边界你要的是交集还是“左连接”很多同学一上来就写listA.retainAll(listB)但写着写着发现不对——retainAll是直接改原List的而且它依赖元素的equals方法一旦元素是自定义对象而你没有重写equals结果就是空的或者错的。所以在动代码之前必须先把需求拆清楚你是要对两个集合中的某个字段做匹配还是对整个对象做相等判断是要找出共有的元素还是要以左边List为准把右边List的额外信息拼过来这两类需求在实现上完全是两码事。取交集通常只需要返回公共元素集合不关心右表还有哪些额外字段左连接则不一样左连接的语义是“左表记录全部保留右表有匹配就补上字段没有匹配就补null”对应到Java里就是你有一个ListOrder和一个ListUser你想给每个订单补充用户昵称即使某些订单的userId在用户表里找不到订单照样要显示出来。搞清楚这个区别后面选型才不会跑偏。另外还要注意一个细节取交集的结果是否要去重SQL里的INNER JOIN如果两边是一对多关系结果会膨胀Java里如果两个List本身就有重复元素retainAll的结果也会出现重复。所以动手之前把“去不去重、要不要保持顺序、以谁为准”这三个问题先定了远比写代码更重要。2. 取交集的四种实现方式2.1 最直接的JDK原生写法retainAllListInteger listA new ArrayList(Arrays.asList(1, 2, 3, 4, 5)); ListInteger 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的正确姿势是这样的ListInteger listA new ArrayList(Arrays.asList(1, 2, 3, 4, 5)); ListInteger listB Arrays.asList(4, 5, 6, 7, 8); ListInteger result new ArrayList(listA); result.retainAll(listB);这样既保留了原数据又拿到了交集。如果你的List元素是自定义对象就必须重写equals()和hashCode()否则比较的是对象引用几乎不会相等。2.2 contains结合ArrayList遍历ListInteger listA Arrays.asList(1, 2, 3, 4, 5); ListInteger listB Arrays.asList(4, 5, 6, 7, 8); ListInteger 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)ListInteger listA Arrays.asList(1, 2, 3, 4, 5); ListInteger listB Arrays.asList(4, 5, 6, 7, 8); SetInteger setB new HashSet(listB); ListInteger result new ArrayList(); for (Integer item : listA) { if (setB.contains(item)) { result.add(item); } }这段代码的关键就一句话把listB转成HashSetcontains就从O(n)变成了O(1)。整个交集计算的复杂度从O(n*m)降到O(nm)对于大数据量来说这个优化是质变。为什么HashSet的contains是O(1)因为哈希表通过哈希函数直接定位桶的位置理想情况下一次命中不需要遍历。这也是为什么我在任何需要频繁做成员判断的场景里第一反应永远是“先转Set再说”。当然这里有个前提元素要正确重写hashCode()否则哈希值不唯一性能优势就没了。2.4 Stream API写法代码最优雅ListInteger listA Arrays.asList(1, 2, 3, 4, 5); ListInteger listB Arrays.asList(4, 5, 6, 7, 8); SetInteger setB new HashSet(listB); ListInteger result listA.stream() .filter(setB::contains) .collect(Collectors.toList());Stream写法本质上和第三种一样也是用Set加速但代码可读性更高。filter(setB::contains)的意思就是“只要集合B里有的元素”一眼就能看懂。如果想顺便去重加一个.distinct()就行如果想保持原顺序Stream默认按流顺序收集天然保持listA的顺序。ListInteger 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(nm)。3.2 手写一个LeftJoinMap分组实现假设有两个类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 省略 }模拟左连接的经典写法如下ListOrder orders getOrders(); ListUser users getUsers(); // 1. 以userId为key把users转为Map MapLong, 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加第三个参数冲突时保留旧的或合并MapLong, User userMap users.stream() .collect(Collectors.toMap( User::getUserId, Function.identity(), (oldValue, newValue) - newValue ));第三个参数(oldValue, newValue) - newValue表示遇到重复key时用后出现的值覆盖先出现的值。如果业务上需要的是保留所有记录而不是覆盖那就要在分组时用groupingBy把相同key的多条记录收集成List然后处理时再决定拿第一条还是合并字段。MapLong, ListUser userMap users.stream() .collect(Collectors.groupingBy(User::getUserId)); orders.forEach(order - { ListUser 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.copyPropertiesListOrderVO 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推荐数据量取交集retainAllO(n*m)O(1)是百级以内取交集contains 循环O(n*m)O(1)否百级以内取交集HashSet containsO(nm)O(m)否万级以上取交集Stream SetO(nm)O(m)否万级以上左连接双重循环O(n*m)O(1)视实现而定百级以内左连接Map分组O(nm)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把大任务拆成小任务多线程并行处理。代码改动也很小ListOrder 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最简单但会修改原集合且复杂度高HashSetcontains是性能最优解复杂度O(nm)Stream API是语义最清晰的写法底层同样可以用Set优化。面试官如果继续问性能就把核心点抛出来——把大集合转成HashSet让contains变成O(1)集合操作就从n乘m变成n加m。5.3 手写左连接你会怎么写手写题的核心得分点其实不是代码本身而是你有没有意识到“用Map做连接键索引”这个优化。写双层循环是最容易想到的答案但面试官会追问“如果数据量特别大呢”这时候能写出Map分组方案并且说明为什么复杂度从O(n*m)降到O(nm)基本就能过关。另外一个常考的点是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还有一个小细节容易被面试官抓着考为什么代码里通常写ListInteger listA new ArrayList()而不是ArrayListInteger listA new ArrayList()。因为面向接口编程后续如果想换成LinkedList只需要改右边的构造部分调用方的所有List方法都保持不变。同样的道理当我们用Set转List再转回Set时变量类型最好都声明为接口类型代码的扩展性会好很多。6. 实战避坑我踩过的那些List操作相关的坑6.1 用final修饰List里面的值还是能改这是很多Java初学者乃至面试者都会搞混的问题。final ListInteger 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.hashOverride 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); }实际开发中如果两个对象表示同一条业务数据比如同一个订单通常只需要比较业务主键而不是所有字段。所以专门为“交集”定义仅包含业务主键的相等逻辑也是一个常用手段比如用MapLong, 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你需要先过滤MapLong, 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关联方案是什么我会说取交集用HashSetcontains方案左连接用Map分组方案两者都不会修改原集合性能都是O(nm)而且代码简洁易维护。如果你在乎代码可读性取交集优先用Stream API如果你在乎老代码兼容性就别用List.of()统一用Collections.unmodifiableList如果数据量小到几十条怎么简单怎么写别过度设计。记住一个核心原则集合操作永远先评估数据量再选方案最后才是微调代码格式。这套思路能让你在绝大多数场景下少踩坑、少返工。