Java List合并全解析:从addAll到Stream API的实战指南
2026/9/8 21:30:20 网站建设 项目流程

1. 项目概述:为什么合并List是Java开发者的基本功

在Java的日常开发中,处理集合数据是家常便饭。无论是从多个数据源聚合信息,还是对数据进行分批处理后的汇总,合并两个List集合的需求几乎无处不在。表面上看,这只是一个简单的“相加”操作,但背后却涉及到集合的底层实现、性能考量、线程安全以及代码的可读性。一个看似简单的list1.addAll(list2),在不同的场景下,可能隐藏着内存溢出、数据错乱甚至并发修改异常的风险。我见过不少初级开发者,在面试中被问到“如何合并两个List”时,只能答出addAll,一旦追问其原理、适用场景或替代方案,就哑口无言了。这恰恰说明,掌握这项基本功的多种实现方式及其背后的“为什么”,是区分代码熟练工与思考型开发者的关键。本文将带你深入解析Java中合并两个相同类型List集合的多种方法,从最基础的API调用到流式编程,再到应对并发场景的考量,并结合实际开发中的坑点,让你不仅会“用”,更懂“为何这么用”。

2. 核心需求与场景拆解

在深入方法之前,我们必须先明确“合并两个相同的List集合”这个需求的具体内涵和典型场景。这里的“相同”通常指泛型类型相同,例如都是List<String>List<User>

2.1 合并操作的几种核心诉求

  1. 简单聚合:将两个集合的所有元素合并到一个新的集合中,不关心顺序,也不去重。这是最常见、最基础的需求。例如,合并上午和下午的订单列表。
  2. 有序合并:在合并的同时,需要保持某种顺序。这可能基于元素本身的自然顺序(实现Comparable接口),或根据业务规则自定义排序。例如,合并两个已按时间排序的日志列表,合并后的列表仍需保持时间顺序。
  3. 去重合并:合并时,自动过滤掉重复的元素。这里的“重复”可能指对象引用相同,也可能指根据对象的某些字段(如ID)判重。例如,合并来自两个系统的用户列表,需要根据用户ID去重。
  4. 并发安全合并:当合并操作可能发生在多线程环境下时,需要确保线程安全,避免出现ConcurrentModificationException等异常。例如,在Web应用的监听器或定时任务中合并动态变化的列表。

2.2 不同场景下的方法选型考量

选择哪种合并方法,绝非随意,而是需要权衡以下几个维度:

  • 性能:数据量有多大?是O(n)还是O(n^2)的时间复杂度?是否需要频繁扩容?
  • 内存:是创建新集合还是在原有集合上修改?对原集合是否有影响?
  • 线程安全:操作环境是否是并发的?原集合是否是线程安全的集合(如CopyOnWriteArrayList)?
  • 可读性与简洁性:代码是否清晰易懂,符合团队规范和现代Java风格(如使用Stream API)?
  • 功能性:是否需要额外的功能,如去重、过滤、排序?

理解这些场景和考量点,我们才能为接下来的方法解析建立正确的评估框架。

3. 基础API方法:addAll的深入剖析

List.addAll(Collection<? extends E> c)java.util包下最直接、最古老的合并方法。几乎所有人第一个想到的就是它。

3.1 标准用法与源码窥探

List<String> list1 = new ArrayList<>(Arrays.asList("A", "B", "C")); List<String> list2 = new ArrayList<>(Arrays.asList("D", "E", "F")); // 方法1:在list1末尾追加list2的所有元素 list1.addAll(list2); System.out.println(list1); // 输出:[A, B, C, D, E, F] // 方法2:创建新集合,避免修改原集合 List<String> mergedList = new ArrayList<>(list1); mergedList.addAll(list2);

为什么ArrayListaddAll通常很快?我们简单看一下ArrayList.addAll的源码(简化逻辑):它会先计算需要扩容的大小,然后一次性将传入集合的元素通过System.arraycopy()批量拷贝到底层数组的末尾。System.arraycopy是JVM层面的本地方法,执行的是内存块的复制,效率远高于循环调用add方法。这也是为什么在已知两个列表大小的情况下,使用带初始容量的构造函数(new ArrayList<>(list1.size() + list2.size()))能获得最佳性能,因为它避免了中间可能发生的多次扩容。

3.2 注意事项与常见陷阱

注意addAll是“浅拷贝”(Shallow Copy)。它复制的是集合中元素的引用,而非元素对象本身。如果集合中存放的是可变对象(如自定义的User),那么通过任何一个引用修改了对象的状态,所有包含该引用的集合都会“看到”这个变化。

List<StringBuilder> builders1 = new ArrayList<>(); builders1.add(new StringBuilder("Hello")); List<StringBuilder> builders2 = new ArrayList<>(); builders2.add(new StringBuilder("World")); builders1.addAll(builders2); // 此时builders1包含两个StringBuilder对象的引用 builders1.get(0).append(" Java"); // 修改第一个元素 System.out.println(builders1.get(0)); // 输出:Hello Java // builders2中的元素对象本身没有被修改,但builders1和builders2持有的是不同对象的引用,所以这里不影响builders2。

实操心得

  1. 修改原集合:直接调用list1.addAll(list2)会修改list1。如果list1是方法参数或需要保持原样,务必先创建副本。
  2. 空指针安全list1.addAll(list2)list2null时会抛出NullPointerException。稳健的代码应该先判空:if (list2 != null) list1.addAll(list2)
  3. 自我合并list1.addAll(list1)在理论上是允许的,但通常没有意义,且对于ArrayList会导致其容量翻倍并复制自身所有元素,造成不必要的性能开销。

4. 工具类方法:Collections与第三方库的巧用

除了直接使用集合API,利用现有的工具类可以写出更简洁、功能更强大的代码。

4.1 使用Collections.addAll方法

java.util.Collections类提供了一个静态方法addAll(Collection<? super T> c, T... elements)。虽然它通常用于添加多个单独元素,但结合可变参数和数组,也能用于合并。

List<String> list1 = new ArrayList<>(Arrays.asList("A", "B")); List<String> list2 = Arrays.asList("C", "D"); // 注意:这里返回的是Arrays$ArrayList,固定大小 List<String> mergedList = new ArrayList<>(list1); // 将list2转换为数组,然后通过Collections.addAll添加 Collections.addAll(mergedList, list2.toArray(new String[0]));

为什么有时用它?它的主要优势在于添加零散元素时的便利性。对于合并两个集合,其内部实现最终还是循环调用add,性能上通常不如ArrayList.addAll的批量拷贝。但在合并多个小集合或集合与数组混合的场景下,代码可能更清晰。

4.2 使用Apache Commons Lang或Guava

第三方库如Apache Commons Lang的ListUtils或Google Guava的Iterables/Lists提供了更丰富的集合操作。

  • Apache Commons LangListUtils.union:

    // 需要引入org.apache.commons:commons-collections4或类似依赖 // List<String> union = ListUtils.union(list1, list2); // 注意:其union方法可能涉及去重逻辑(取决于具体版本和实现),使用前需查证。

    实际上,Commons Collections 4中的ListUtils.union返回的是两个列表的视图,并不直接创建一个包含所有元素的新列表,修改视图会影响原列表,且其迭代顺序是list1后接list2。这是一个“惰性”合并,适用于只读场景。

  • Google GuavaIterables.concat:

    // 需要引入com.google.guava:guava import com.google.common.collect.Iterables; import com.google.common.collect.Lists; Iterable<String> concatenated = Iterables.concat(list1, list2); // 转换为List List<String> mergedList = Lists.newArrayList(concatenated);

    Iterables.concat同样返回一个惰性的视图,只有在遍历时才会依次从两个原列表中获取元素。Lists.newArrayList(Iterable)则会真正地将所有元素复制到一个新的ArrayList中。Guava的方式非常灵活,可以轻松连接多个Iterable

工具类选型建议: 如果你的项目已经引入了这些库,使用它们可以提供更好的代码表达能力和一些高级特性(如惰性求值)。但如果只是为了合并列表而引入一个庞大的第三方库,则显得有些重,原生的addAll或Stream API通常是更轻量、更标准的选择。

5. Java 8+ Stream API:声明式合并与高级操作

Java 8引入的Stream API为集合操作带来了革命性的变化,它允许你以声明式的方式处理数据。合并列表在Stream中变得异常优雅和强大。

5.1 基础合并:Stream.concatCollectors.toList

List<String> list1 = Arrays.asList("A", "B"); List<String> list2 = Arrays.asList("C", "D"); // 使用Stream.concat连接两个流,然后收集为List List<String> mergedList = Stream.concat(list1.stream(), list2.stream()) .collect(Collectors.toList());

为什么推荐Stream API?

  1. 链式调用与声明式:代码清晰地表达了“连接流然后收集”的意图,更符合“做什么”而非“怎么做”的现代编程思想。
  2. 易于扩展:你可以在collect之前插入各种中间操作,实现复杂的合并逻辑。

5.2 高级合并:去重、过滤与排序

这才是Stream API在合并场景下大放异彩的地方。一行代码就能完成过去需要多行才能实现的功能。

  • 合并并去重

    List<String> list1 = Arrays.asList("A", "B", "C"); List<String> list2 = Arrays.asList("C", "D", "A"); List<String> distinctMergedList = Stream.concat(list1.stream(), list2.stream()) .distinct() // 去重 .collect(Collectors.toList()); // 输出:[A, B, C, D]

    去重依据是Object.equals()方法。对于自定义对象,需正确重写equalshashCode

  • 合并、过滤并排序

    List<Integer> numbers1 = Arrays.asList(1, 3, 5); List<Integer> numbers2 = Arrays.asList(2, 4, 6, 0); List<Integer> processedList = Stream.concat(numbers1.stream(), numbers2.stream()) .filter(n -> n > 0) // 过滤掉小于等于0的数 .sorted() // 自然排序 .collect(Collectors.toList()); // 输出:[1, 2, 3, 4, 5, 6]
  • 合并自定义对象并按属性去重: 假设有User对象,需要根据id合并去重。

    List<User> users1 = ...; List<User> users2 = ...; List<User> mergedUsers = Stream.concat(users1.stream(), users2.stream()) .collect(Collectors.collectingAndThen( Collectors.toMap(User::getId, Function.identity(), (u1, u2) -> u1), // 按id去重,保留前者 map -> new ArrayList<>(map.values()) ));

    这里使用了Collectors.toMap,将流元素转换为一个Map<Integer, User>,其中键是id,值是User对象。当id冲突时((u1, u2) -> u1)选择保留第一个出现的。最后将Map的值转换为ArrayList

Stream API实操心得

  1. 性能考量:Stream API会带来一定的开销(创建流、调用函数接口)。对于数据量极小(如几十个元素)的简单合并,addAll可能更快。但对于需要复杂处理(如过滤、映射、去重)或数据量较大的场景,Stream的并行流(parallelStream())和内部优化往往能带来更好的可读性和可维护性,性能也足够好。
  2. 不可变性:Stream操作不会修改源列表,总是产生一个新的结果,这符合函数式编程的不可变思想,减少了副作用。
  3. Collectors.toList()的返回值:在Java 16之前,Collectors.toList()返回的List实现类是未指定的(通常是ArrayList)。从Java 16开始,它返回一个不可变的List(调用add会抛异常)。如果需要可变的List,可以使用Collectors.toCollection(ArrayList::new)

6. 并发场景下的合并策略

当你的列表可能被多个线程访问或修改时,简单的addAll或Stream操作就可能抛出ConcurrentModificationException。这时需要采用线程安全的策略。

6.1 使用synchronized或显式锁

最直接的方法是对合并操作加锁。

List<String> sharedList = Collections.synchronizedList(new ArrayList<>()); List<String> batchData = getDataFromSomewhere(); synchronized(sharedList) { sharedList.addAll(batchData); }

使用Collections.synchronizedList包装的列表,其每个方法都是同步的。但在进行复合操作(如迭代并添加)时,必须在外部对列表对象本身进行同步,如上例所示。

6.2 使用CopyOnWriteArrayList

java.util.concurrent.CopyOnWriteArrayList是专为高并发读、低并发写场景设计的线程安全列表。其核心思想是:任何修改操作(如add,set,addAll)都会在底层创建一个新的数组副本,修改在这个副本上进行,完成后再原子性地替换旧的数组引用。

CopyOnWriteArrayList<String> cowList = new CopyOnWriteArrayList<>(Arrays.asList("A", "B")); List<String> newData = Arrays.asList("C", "D"); // addAll操作是线程安全的 cowList.addAll(newData);

为什么选择CopyOnWriteArrayList

  • 优点:读操作(get,iterator)完全无锁,性能极高,且迭代器不会抛出ConcurrentModificationException(因为它遍历的是创建迭代器时刻的数组快照)。
  • 缺点:写操作(尤其是addAll)的成本极高,因为需要复制整个底层数组。仅适用于读多写少,且写入数据量不大的场景。
  • 合并场景适用性:如果你有一个需要被频繁遍历读取的共享列表,偶尔需要合并进一批新数据,并且能接受合并操作(写)的较高延迟,那么CopyOnWriteArrayList是一个好选择。切记,不要用它来存储频繁变化或巨大的列表。

6.3 并发环境下的Stream合并

即使在并发环境下,你也可以安全地使用Stream API来合并两个已经存在的列表,前提是你在合并过程中不修改源列表。

List<String> list1 = getSafeList1(); // 假设是线程安全或不可变的列表 List<String> list2 = getSafeList2(); List<String> merged = Stream.concat(list1.stream(), list2.stream()) .collect(Collectors.toList());

关键在于list1list2的来源。如果它们来自一个线程安全的容器(如从ConcurrentHashMap的值集合获取),或者是在合并操作开始前就已经确定的快照,那么合并过程本身是线程安全的。Stream的收集器(如Collectors.toList)内部使用的集合(如ArrayList)并非线程安全,但整个收集过程发生在单个线程中(除非使用并行流),因此没有问题。如果使用并行流(.parallelStream()),则需要使用线程安全的收集器,如Collectors.toConcurrentList()

7. 性能对比与选型指南

了解了这么多方法,我们通过一个简单的维度对比来帮助决策。

方法描述优点缺点适用场景
List.addAll()在原列表或新列表末尾追加原生支持,性能高(批量拷贝),使用简单修改原集合(除非新建),需注意NPE,功能单一最通用的简单合并场景,性能敏感且无需额外处理
Collections.addAll()向集合添加多个元素可添加数组或零散元素,代码有时更清晰对于合并两个集合,性能通常不如addAll合并小集合或集合与数组混合的场景
**Stream API (concat) **连接两个流后收集声明式编程,易于组合中间操作(过滤、去重、映射),不修改源数据有一定性能开销,对于极简单操作稍显繁重需要在合并前后进行复杂数据处理的场景,追求代码现代感和可读性
CopyOnWriteArrayList.addAll()线程安全列表的追加读操作无锁性能极高,迭代安全写操作(含addAll)性能极差,内存消耗大高并发读、低频批量写的共享列表合并场景
第三方库 (Guava)使用Iterables.concat提供惰性视图,功能丰富,API设计优雅引入额外依赖项目已使用该库,或需要其提供的特定高级功能

选型决策流程建议

  1. 是否需要线程安全?
    • 是 -> 考虑CopyOnWriteArrayList(读多写少)或使用锁/同步块保护synchronizedListaddAll操作。
    • 否 -> 进入第2步。
  2. 合并前后是否需要过滤、去重、映射等复杂操作?
    • 是 ->首选Stream API。代码清晰且功能强大。
    • 否 -> 进入第3步。
  3. 是否介意修改原集合?是否需要保留原集合不变?
    • 需要保留原集合 -> 创建新集合,然后使用addAll或Stream API。
    • 可以修改原集合 -> 直接使用addAll
  4. 数据量级和性能要求?
    • 数据量极大(数十万以上),且仅做简单合并 -> 使用new ArrayList<>(initialCapacity)预分配大小后addAll,性能最优。
    • 数据量一般 -> 以上方法性能差异不大,优先考虑代码清晰度和可维护性。

8. 实战案例与避坑指南

让我们通过几个真实开发中可能遇到的案例,来综合运用上述知识。

8.1 案例一:合并分页查询结果

假设你需要从两个不同的服务分页查询用户订单,然后合并成一个列表进行展示。

// 模拟服务调用 PageResult<List<Order>> pageResult1 = orderService.getOrdersByPage(page1, size); PageResult<List<Order>> pageResult2 = orderService.getOrdersByPage(page2, size); List<Order> ordersFromService1 = pageResult1.getData(); List<Order> ordersFromService2 = pageResult2.getData(); // 合并,并需要按订单创建时间倒序排列 List<Order> allOrders = Stream.concat( ordersFromService1.stream(), ordersFromService2.stream() ) .sorted(Comparator.comparing(Order::getCreateTime).reversed()) // 合并后排序 .collect(Collectors.toList());

避坑点:这里排序是在内存中进行的全量排序。如果两个分页结果本身已经有序,更高效的合并方式是使用**归并排序(Merge Sort)**的思想,但Java标准库没有直接提供。如果数据量很大,合并后排序可能成为性能瓶颈。此时可以考虑让服务端返回已排序的数据,或者使用PriorityQueue进行多路归并。

8.2 案例二:合并并去重(基于业务键)

合并两个商品列表,根据商品ID去重,并优先保留第一个列表中商品的价格。

List<Product> list1 = ...; // 来自缓存 List<Product> list2 = ...; // 来自数据库 Map<Long, Product> productMap = new LinkedHashMap<>(); // 用LinkedHashMap保持某种顺序 // 先放入list1的商品 for (Product p : list1) { productMap.putIfAbsent(p.getId(), p); } // list2的商品只会放入id不重复的 for (Product p : list2) { productMap.putIfAbsent(p.getId(), p); // putIfAbsent只在key不存在时放入 } List<Product> mergedAndDistinctList = new ArrayList<>(productMap.values());

避坑点:使用putIfAbsent实现了“优先保留先出现者”的逻辑。如果需要更复杂的合并策略(如价格取平均值、库存相加),需要在putIfAbsent或后续处理中实现自定义的合并函数。使用Stream API的toMap收集器配合合并函数(merge function)是更函数式的写法。

8.3 案例三:在Web请求中合并线程局部数据

在拦截器或过滤器中,你可能需要将本次请求的附加数据合并到一个线程安全的上下文列表中。

public class RequestContextHolder { // 使用ThreadLocal存储属于当前线程的列表 private static final ThreadLocal<CopyOnWriteArrayList<String>> REQUEST_ATTRIBUTES = ThreadLocal.withInitial(CopyOnWriteArrayList::new); public static void addAttribute(String attr) { REQUEST_ATTRIBUTES.get().add(attr); } public static List<String> getAllAttributes() { return new ArrayList<>(REQUEST_ATTRIBUTES.get()); // 返回一个快照,避免外部修改 } // 合并一批属性 public static void addAllAttributes(List<String> attrs) { if (attrs != null && !attrs.isEmpty()) { REQUEST_ATTRIBUTES.get().addAll(attrs); } } }

避坑点

  1. 这里内部使用CopyOnWriteArrayList是因为每个线程独立一份,写操作不频繁且数据量小,同时能保证线程内迭代的安全。
  2. getAllAttributes()返回的是一个新的ArrayList副本,这是一个好习惯,防止调用者修改ThreadLocal中的原始数据,造成不可预知的影响。
  3. 务必在请求结束时(如通过Filter的finally块)调用REQUEST_ATTRIBUTES.remove()清理ThreadLocal,防止内存泄漏。

9. 总结与最佳实践建议

经过对各种方法的拆解和场景分析,我们可以提炼出一些关于Java中合并List的最佳实践:

  1. 明确意图与影响:首先要问自己,合并操作是否需要修改原列表?是否需要保持原列表不变?明确这一点能帮你决定是就地修改还是创建新集合。
  2. 优先考虑Stream API(Java 8+):对于大多数现代Java项目,Stream API应该是首选。它不仅代码简洁,而且通过组合filterdistinctsortedmap等操作,能轻松应对复杂的合并逻辑,极大地提升了代码的表达能力和可维护性。性能问题在绝大多数业务场景下都不是瓶颈。
  3. 简单场景用addAll:如果仅仅是两个列表的简单拼接,且对原列表的修改无影响,那么ArrayListaddAll是最直接、最高效的选择。记得处理null值。
  4. 并发场景审慎选择:区分“读多写少”和“写多”的场景。CopyOnWriteArrayList适用于前者,而后者可能需要考虑ConcurrentLinkedDeque或其他并发容器,或者使用细粒度锁。
  5. 关注对象引用与深拷贝:始终记住集合中存储的是引用。如果合并后不希望原集合中对象的修改影响到新集合(或反之),你需要进行深拷贝,这通常意味着要遍历集合并创建新对象。Apache Commons Lang的SerializationUtils.clone()(要求对象实现Serializable)或手动复制构造都是可选方案,但深拷贝本身是一个复杂话题。
  6. 性能测试是最终标准:当你在关键路径(如高频调用的接口、大数据量处理)上对性能有极致要求时,不要盲目相信经验。编写基准测试(使用JMH),用真实的数据规模和硬件环境来验证不同方法的性能表现。有时候,一个预分配了正确大小的ArrayList加上addAll,可能就是最快的。

最后,合并List这个操作,就像木匠手中的榫卯,看似简单,但不同的连接方式决定了结构的牢固、美观与效率。理解每种方法背后的原理和代价,根据实际情况灵活选用,才能写出既稳健又优雅的代码。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询