合并多个集合数据在Java里没有唯一的标准答案,因为List允许重复且关心顺序,Set天生去重,Map还需要处理键冲突。最常用的起点是Collection接口定义的 addAll 方法,它可以把一个集合中的所有元素追加到当前集合中。比如先创建一个可变的ArrayList,再调用addAll传入另一个集合,就能快速完成合并。

用addAll方法合并集合
如果只处理两个可变集合,addAll 是最直接的方案。对于List来说,addAll会按照参数集合的迭代顺序把元素追加到末尾;对于Set来说,addAll同样会尝试加入每个元素,但重复元素会被当前Set的相等性判断自动忽略。这个过程会修改调用addAll的那个集合对象,不会修改传入的参数集合。
下面的代码演示了ArrayList合并两个列表的过程。需要注意,合并后的列表会保留所有元素,即使两个列表里都有同样的字符串也不会去重。
List<String> first = new ArrayList<>();
first.add("a");
first.add("b");
List<String> second = List.of("b", "c");
first.addAll(second);
System.out.println(first); // [a, b, b, c]
System.out.println(second); // [b, c],原集合不受影响
如果原始集合还要在后续逻辑中继续使用,就不能直接在它上面调用addAll,否则会污染原始数据。应先生成一个新集合,再把所有元素合并进去。另一个容易忽略的问题是,List.of 创建的列表是不可变的,不能作为接收方调用addAll,否则会抛出UnsupportedOperationException。
addAll本身没有线程安全保证。如果多个线程同时向同一个ArrayList写入,会导致数据错乱甚至索引越界。并发场景下可以使用 Collections.synchronizedList 包装列表,或者选择 CopyOnWriteArrayList 这种适合读多写少的并发集合。不过也要注意,CopyOnWriteArrayList每次addAll都会复制底层数组,数据量大时成本较高。
借助构造器和辅助方法一次合并多个集合
ArrayList、LinkedList、HashSet、TreeSet等集合都提供了接收一个Collection参数的构造器。这样可以在创建新集合时就把一个已有集合的元素直接复制进去,再通过addAll追加后续集合。相比直接在原集合上操作,这种做法更安全,也不会修改任何一个输入集合。
如果合并的集合数量不固定,可以编写一个泛型辅助方法,用可变参数接收多个List,再统一装入新列表。这个方案适合集合数量较多、但每个集合规模不大的情况。
@SafeVarargs
public static <T> List<T> mergeLists(List<T>... lists) {
List<T> result = new ArrayList<>();
for (List<T> list : lists) {
result.addAll(list);
}
return result;
}
List<String> merged = mergeLists(
List.of("a"),
List.of("b", "c"),
List.of("c", "d")
);
System.out.println(merged); // [a, b, c, c, d]
对于要求去重的合并,可以直接把目标集合换成Set。HashSet合并速度很快,但不会保留插入顺序;LinkedHashSet则兼顾去重和顺序。需要排序时,TreeSet会根据自然顺序或比较器排序,但元素类型必须实现Comparable或者传入Comparator。
List<String> first = List.of("apple", "banana");
List<String> second = List.of("banana", "cherry");
Set<String> hashSet = new HashSet<>(first);
hashSet.addAll(second);
System.out.println(hashSet); // 元素包含 apple、banana、cherry,顺序不保证
Set<String> linkedSet = new LinkedHashSet<>(first);
linkedSet.addAll(second);
System.out.println(linkedSet); // [apple, banana, cherry]
构造器方案的优势是语义清晰,能一眼看出生成了新集合。但它的缺点是必须预先知道使用哪种具体集合类型。如果业务上只依赖List接口,使用new ArrayList作为默认实现通常足够;如果希望保证去重且有序,LinkedHashSet是更合理的选择。
使用Stream API与flatMap声明式合并
当集合来源很多、可能出现在数组、Map的值或者对象属性中时,Stream的 flatMap 能大幅减少手工循环。把多个集合放进一个外层Stream,再用flatMap把每个集合打平为元素流,最后收集为List,整个合并过程天然不会修改原始集合。
这种写法的另一个好处是可以方便地衔接去重、过滤、排序等中间操作。比如合并所有用户订单列表后,只需要调用distinct就可以去掉重复订单号。代码仍然保持链式结构,可读性比嵌套循环更好。
List<String> first = List.of("a", "b");
List<String> second = List.of("b", "c");
List<String> third = List.of("d");
List<String> combined = Stream.of(first, second, third)
.flatMap(List::stream)
.collect(Collectors.toList());
System.out.println(combined); // [a, b, b, c, d]
List<String> distinct = combined.stream()
.distinct()
.collect(Collectors.toList());
System.out.println(distinct); // [a, b, c, d]
如果数据量很大,还可以调用 parallel() 开启并行流。但并行并不总是更快,尤其是集合元素很少时,线程调度开销反而会拖慢执行。另外,flatMap合并后如果要保持整体顺序,应该使用有序流和普通的Collectors.toList,并行流对顺序的控制会更复杂。
使用Stream时还要注意空集合和null值。如果外层Stream里某一个集合引用为null,flatMap阶段会直接抛NullPointerException。可以在传参前过滤null,或者用 Optional.ofNullable 返回空流,但不要把null集合混入正常数据流中。
处理重复元素与自定义对象去重
List合并不会自动去重,因此同一个元素可能在结果列表里出现多次。对于字符串、Integer等包装类型,可以直接使用 distinct() 或LinkedHashSet去重。但对于自定义对象,去重行为依赖equals和hashCode的实现,如果这两个方法没有被正确重写,HashSet和distinct都会把内容相同的两个对象看作不同元素。
假设有一个订单对象,只有订单号相同就应视为同一订单,那么必须基于订单号重写equals和hashCode。否则,默认的Object实现按对象内存地址判断相等性,合并出的Set里会出现重复订单号。
public class Order {
private String orderNo;
private int amount;
public Order(String orderNo, int amount) {
this.orderNo = orderNo;
this.amount = amount;
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (!(obj instanceof Order)) return false;
Order other = (Order) obj;
return orderNo.equals(other.orderNo);
}
@Override
public int hashCode() {
return orderNo.hashCode();
}
// toString、getter等方法省略
}
List<Order> first = List.of(new Order("A100", 20));
List<Order> second = List.of(new Order("A100", 30), new Order("B200", 15));
Set<Order> mergedOrders = new LinkedHashSet<>(first);
mergedOrders.addAll(second);
// 结果只保留一个订单号 A100,另一个相同订单号不会被重复加入
如果业务上不想重写equals,又希望按某个字段去重,可以用Stream配合TreeSet和自定义比较器,或者先用toMap处理。比如按订单号收集时指定重复键保留第一个或最后一个值,这种场景已经接近Map合并的逻辑。
合并Map数据的思路
Map的合并比List和Set更复杂,因为不仅涉及键值对,还涉及键冲突时的处理策略。最简单的方式是把一个Map的全部条目通过 putAll 写入另一个Map中。当两个Map有相同键时,后写入的值会覆盖先前的值。这种覆盖式行为很适合配置信息合并,但不适合需要累加数值或拼接数据的场景。
如果要对相同键的值进行合并,可以使用Java 8提供的 merge 方法。该方法接收键、新值和BiFunction,当键不存在时直接放入,键存在时由BiFunction决定合并结果。下面的例子演示了把两个商品库存Map相加的过程。
Map<String, Integer> inventory1 = new HashMap<>();
inventory1.put("apple", 10);
inventory1.put("banana", 5);
Map<String, Integer> inventory2 = new HashMap<>();
inventory2.put("banana", 8);
inventory2.put("cherry", 12);
Map<String, Integer> total = new HashMap<>(inventory1);
inventory2.forEach((key, value) -> total.merge(key, value, Integer::sum));
System.out.println(total); // {apple=10, banana=13, cherry=12}
除了merge,还可以使用Stream的concat配合Collectors.toMap。但toMap遇到重复键时默认会抛IllegalStateException,需要显式传入合并函数。整体来看,合并Map时一定要先明确业务规则:相同键应该覆盖、累加、取最大值,还是保留一个列表。规则不同,写法差异很大。
不可变集合与线程安全合并
合并生成的结果集合有时需要发布给多个线程读取,这时最好返回不可变集合,防止外部代码误修改导致数据不一致。Collectors.toUnmodifiableList() 和 List.copyOf 都可以生成不可变列表,但两者都拒绝null元素。如果待合并集合可能包含null,需要先过滤或改用Collections.unmodifiableList。
不可变集合并不意味着线程不安全,相反,它可以安全地在多线程间共享。但若合并过程中使用了并行流,需要注意收集器是否保持不可变性。下面的代码分别展示了Stream合并后生成不可变列表的写法。
List<String> first = List.of("a", "b");
List<String> second = List.of("c");
List<String> immutable = Stream.concat(first.stream(), second.stream())
.collect(Collectors.toUnmodifiableList());
List<String> copy = List.copyOf(immutable);
// immutable.add("d"); // 会抛 UnsupportedOperationException
System.out.println(copy); // [a, b, c]
如果合并后的集合需要在多线程环境中频繁写入,不要直接使用不可变集合,而应该选用并发容器。比如合并黑名单数据时,可以创建CopyOnWriteArrayList,写入时复制、读取不加锁,适合读多写少的场景。如果读写都频繁,建议使用ConcurrentHashMap或同步包装器,并根据压力测试调整策略。
综合来看,Java合并集合并没有固定套路。只合并两个可变List时用addAll最直观;集合来源较多且不想修改原数据时,优先选Stream的flatMap;需要去重保序时使用LinkedHashSet;合并Map时必须先定义键冲突规则;对外发布的结果集合应尽量不可变化。掌握这些选择依据,再遇到集合合并需求时就能快速选出合适的实现方式。