都说 Java 业务开发就是写 CRUD、处理集合,这话不夸张。我接手过的很多代码里,最脏最乱的部分往往不是复杂的业务逻辑,而是到处裸写的 for 循环、层层嵌套的 if 判空,以及一想到就头疼的“集合为空怎么处理”。CollectionUtils就是为解决这类问题而生的常见工具类,在老牌的 Apache Commons 库、Spring 框架自有工具类里都有同名实现。它把集合判空、过滤、合并、求交集并集差集这类高频操作收敛成一行调用,能把业务代码里的样板代码压掉不少,也让空指针问题从源头少一大半。
这篇东西适合谁看?刚开始写 Java 没多久、老是纠结集合怎么判空才稳妥的新人,或者已经在项目里用过CollectionUtils但没细究过底层差异的开发者。我会从不同库的CollectionUtils差异讲起,逐个拆解高频方法,再把实操中踩过的坑和排查思路一并列出来。内容不会有多高深,重在直接可抄。
1. 先搞清楚:你项目里用的到底是哪个 CollectionUtils
1.1 三大家族同名不同命
CollectionUtils这名字太大众了,导致很多人在 import 的时候都闹过乌龙。我见过最典型的一次,是某同事在代码里同时用到了 Spring 和 Apache 的工具类,IDE 自动 import 的时候选错了包,结果编译报错找了大半天。所以第一步必须把各家的实现认清楚。
第一类是Apache Commons Collections里的org.apache.commons.collections4.CollectionUtils。这是最正统、功能最全的一个,提供了几十个静态方法,从判空、集合运算到类型转换都有,是很多老项目的标配。
第二类是Spring 框架自带的org.springframework.util.CollectionUtils。它的方法数量少很多,核心就是isEmpty、isEmpty的配套判断、containsInstance、findValueOfType等,主打轻量。如果在 Spring 项目里只是单纯做个判空,我通常建议直接用这个,不需要额外引包,零成本。
第三类是Hutool这类国产工具集里的集合工具。Hutool 叫CollUtil而不是CollectionUtils,但定位一样,方法非常全,还额外提供了很多针对中文业务场景的增强,比如map的快捷操作、分组、枚举转换等,深受很多互联网项目青睐。
除了这三类,Guava 也值得提一下。Guava 不像其他库那样弄一个万能CollectionUtils,而是拆成了Lists、Sets、Maps等具体工具类,每个类的方法设计更贴近容器本身。
这三类工具类的核心差异,我用一个表格直观对比一下:
| 维度 | Apache Commons Collections | Spring 工具类 | Hutool CollUtil |
|---|---|---|---|
| 典型包名 | org.apache.commons.collections4 | org.springframework.util | cn.hutool.core.collection |
| 判空方法 | isEmpty、isNotEmpty | isEmpty | isEmpty、isNotEmpty |
| 集合运算 | union、intersection、subtract等 | 无 | union、intersection、disjunction等 |
| 类型安全 | 泛型支持较好 | 较弱 | 泛型支持较好 |
| 额外依赖 | 需要引入 commons-collections4 | 无需额外引入(Spring 项目) | 需要引入 hutool-all 或单独模块 |
1.2 我的选型建议
选哪个不完全是技术问题,还要看项目现状。我一般按这个逻辑来。
如果项目里已经用了 Spring,并且只用判空、判断包含这类基础操作,那完全不需要新增任何依赖,直接用 Spring 自带的CollectionUtils.isEmpty(collection)就好。一旦需要做交集、并集、差集、过滤这类更复杂的操作,Spring 的版本满足不了,就得引 Apache 的依赖。
Apache Commons Collections 的功能最全,但不建议无脑全引。它的类膨胀比较严重,有些方法在 Java 8 之后其实已经被 Stream API 取代了。比如过滤集合,在 Java 8 以前要用CollectionUtils.filter,现在一个stream().filter()就搞定,代码可读性更好。我的经验是,如果项目是基于 Java 8 以上的现代风格,Apache 的 CollectionUtils 主要用在它独有的集合运算方法上,过滤和遍历交给 Stream。
Hutool 则适合那些整体已经引入 Hutool 的项目。它的CollUtil在集合分组、枚举转换、Map 快捷操作上确实比 Apache 顺手,特别是做业务报表的时候,CollUtil.groupBy这类方法能省很多循环代码。
还有一个容易被忽略的点:团队的熟悉度。工具类本质是约定,如果团队里大多数人只熟悉 Spring 的CollectionUtils,你贸然引入 Apache 的写法,其他人 review 代码时可能第一眼反应不过来。工具类的使用讲究一致性,同一个项目里不要混用两套同名工具,这是我踩过坑之后定下的规矩。
2. 高频方法逐个拆解:从判空到安全的集合操作
2.1 判空为什么是第一个要解决的问题
写业务代码的时候,集合判空是所有集合操作里出现频率最高的场景。很多人最开始写的是if (list != null && list.size() > 0),后来有同学告诉我可以简写成if (list != null && !list.isEmpty()),再后来用上工具类变成if (CollectionUtils.isEmpty(list))。
这行代码看起来只是变短了,但关键在于它把“集合为空”这个判断语义统一了。CollectionUtils.isEmpty(list)内部实现一般是这样:
public static boolean isEmpty(Collection<?> coll) { return coll == null || coll.isEmpty(); }很明显,这个判断把null和空集合两种情况都包进去了。在业务代码里,绝大多数场景下我们不关心一个集合到底是null还是空集合,我们关心的是“里面有没有元素”。如果每个地方都写list != null && list.size() > 0,一方面啰嗦,另一方面很容易漏掉null的判断,第一行已经判了非空,第三行可能就忘了。
isNotEmpty则直接返回反逻辑,让代码在语义上更自然。比如:
if (CollectionUtils.isNotEmpty(orderIds)) { // 只有 orderIds 非空时才需要查数据库 }这种写法比if (orderIds != null && !orderIds.isEmpty())顺眼得多,也基本杜绝了因漏判null产生的 NPE。
不过我要提醒一点,工具类的判空方法判断的只是集合本身是否为 null 或空,它不会管集合里的元素是否为 null。比如一个List里放了三个null,isNotEmpty返回的是 true,因为你确实有元素,只是元素是 null。很多时候这没问题,但如果你准备遍历集合取元素做后续操作,那循环里还是要做元素级别的 null 判断。这是很多人踩过的一个认知误区,我把它放在后面的坑里详细讲。
2.2 安全地往集合里加东西:addAll 的隐蔽用法
往集合里加元素,最普通的写法是list.addAll(anotherList),这没什么问题。但有一种场景很烦:目标集合是null,你没法直接调用addAll,必须先判断并初始化。
Apache Commons 的CollectionUtils.addAll针对这个场景提供了一个很巧妙的重载:
CollectionUtils.addAll(coll, newElements);它的实现逻辑大致是先判断coll是否为 null,为 null 就根据给定集合的类型新建一个实例,再执行addAll。实际使用中,它可以避免不少“要不要提前初始化集合”的纠结。
但老实说,这个方法的实际使用率现在已经不高了,因为 Java 8 以后很多人直接这样写:
List<Order> orders = Optional.ofNullable(sourceList).orElseGet(ArrayList::new);两种方式没有绝对优劣,工具类的优势是语义直白,一看就知道是在安全地追加元素,可读性更好。说到底,选择哪种方式不重要,重要的是在团队内保持一致,别一个项目里有的地方用CollectionUtils.addAll,有的地方用Optional,有的地方又手动判空。
2.3 判指定的类型是否存在:让 Spring 版本发光的方法
Spring 的CollectionUtils里有一个很实用的方法经常被忽略,就是containsInstance(collection, element)和findValueOfType(collection, type)。
containsInstance做的事是判断集合里是否存在某个元素实例,它的特殊之处在于底层用的是==而不是equals。这在判断“集合里有没有这个对象本身”而不是“有没有内容相同的对象”时有奇效。比如判断集合里是否已经包含某个用户对象引用,而不是判断有没有 userId 相同的用户,用containsInstance就非常合适。
findValueOfType更实用,它可以在一个混合类型的集合里找出指定类型的元素。比如某个接口接受到的是一个List<Object>,里面可能混着Integer、String、自定义对象,你想把里面的String取出来,不用自己写循环判断instanceof,直接一个findValueOfType(list, String.class)就搞定了,找不到返回 null。
这个方法我在处理一些混乱的 JSON 解析结果时用过很多次,算是 Spring 工具类里少有的“进阶”功能,值得专门记一下。
3. 集合运算入门:交集、并集、差集与不可变集合
3.1 用 CollectionUtils 计算集合运算
业务里经常遇到这样的需求:用户选了三个筛选条件,每个条件对应用户 ID 集合,你要计算同时满足三个条件的用户集合——这不就是三个集合的交集吗?以前很多人写嵌套循环,性能差不说,代码还很难看。用CollectionUtils的intersection就清爽多了。
Apache Commons Collections 的集合运算方法主要有这几个:
| 方法名 | 作用 | 传入 null 时的行为 |
|---|---|---|
union(Collection a, Collection b) | 并集 | 默认按空集合处理,不抛 NPE |
intersection(Collection a, Collection b) | 交集 | 默认按空集合处理,不抛 NPE |
subtract(Collection a, Collection b) | 差集(a 减去 b 的元素) | 默认按空集合处理,不抛 NPE |
disjunction(Collection a, Collection b) | 对称差集(只属于其中一个集合的元素) | 默认按空集合处理,不抛 NPE |
intersection的底层实现其实很质朴,先是遍历较小的集合,再用contains判断是否在另一个集合里,最后生成一个新的ArrayList。具体来说,Apache 的实现里会先比较两个集合的 size,通过contains做逐一判断,结果放回新集合。这个过程中不会修改原集合,也不会产生并发问题。
union的语义上需要注意一下: Apache 的union会把两个集合里的所有元素都放进去,保留了重复元素。比如集合 a 里有 [A, A, B],集合 b 里有 [A, C],那么union的结果里有 A, A, B, A, C,也就是 A 出现了三次。这和数学意义上的并集不太一样,数学上的并集是不重复的。所以如果你想要的是“去重后的并集”,光用union是不够的,通常要先Set化或者用HashSet包装一下。
subtract也有这个特点:它只移除与 b 集合元素数量匹配的部分,而不是移除全部匹配元素。比如 a 中有两个 A,b 中有一个 A,subtract之后 a 中还剩一个 A。如果你希望的是“只要 b 里有 A,就把 A 全删掉”,得先b去重再做subtract,或者直接用removeAll。
这些细节如果不查文档,几乎不可能一次写对,所以我在代码评审时但凡看到有人用集合运算,都会提醒他注意“重复元素”的语义。
3.2 返回的是新集合吗?原集合会不会被修改
我在带人的时候经常被问到一个问题:“用了intersection之后,原来的两个集合还在吗?”答案是肯定的,这些运算方法都不会修改原集合,而是返回一个新的集合对象。这是 Apache 里一个设计上的稳妥点:集合运算方法是“纯函数”,不产生副作用。
但要注意,新集合里放的元素仍然和原集合里的元素是同一个引用,工具类只做集合层面的操作,不会深拷贝元素。如果你修改了新集合里某个对象的属性,原集合里对应的对象也会跟着变,因为它们是同一个对象。这个特性绝大多数情况下不是问题,但遇到需要隔离操作时就得自己处理。
另外,新集合的具体类型是不确定的。Apache 的intersection内部实现通常是返回ArrayList,但union在某些版本里返回的可能是别的类型。所以绝对不要依赖返回值的具体实现类做强制类型转换,老老实实把它们当Collection或List用就好。我见过有一个项目里有人这么写:
ArrayList<String> result = (ArrayList<String>) CollectionUtils.union(a, b);结果某次升级依赖之后,这个类的具体实现变了,线上直接抛ClassCastException。这类问题很隐蔽,因为它不是每次都会触发,只在容器类型变化时才会冒出来。
3.3 为什么我推荐用 Set 做去重型集合运算
如果集合运算发生在内存里,并且数据量不小,那我个人更推荐直接在HashSet上进行运算,或者用 Java 8 的 Stream API。因为 Apache 的intersection实现里用了contains,如果传入的集合是一个ArrayList,contains的时间复杂度是 O(n),整个交集就是 O(n*m)。当两个集合都是上万级别的列表时,这个耗时就会比较尴尬。
换成HashSet之后,contains是 O(1),总复杂度就降到 O(n+m),性能好很多。而且Set天然去重,正好解决上面说的重复元素问题。
我之前处理过一个标签系统,需要根据用户的标签取交集筛选人群。一开始直接拿ArrayList调CollectionUtils.intersection,数据量到了十万级,接口耗时飙升到几秒。后来我把两个列表先转成HashSet,再调同样的方法,耗时降到了几十毫秒,效果立竿见影。这个优化很简单,但收益巨大。核心思路是两个大集合求交集时,无论如何都先考虑让至少一个集合变为Set。
如果是 Java 8 及以上,Stream的写法更灵活:
Set<String> result = setA.stream().filter(setB::contains).collect(Collectors.toSet());上面的写法同样要求setA和setB是Set,如果还是List,可以把setB::contains保留,但内部的复杂度仍然是 O(n*m)。所以本质要求是:在大数据量集合运算时,先转Set,用什么 API 都是其次。
4. 实操过程中遇到的几个坑
4.1 空指针不是消失了,而是被工具类屏蔽了
我前面说工具类能避免空指针,这里要细化一下:工具类最多帮你判断集合本身是否为 null,但它在方法内部的处理策略各不相同,如果你以为任何情况下传 null 都没问题,那你可能会被另一种 NPE 偷袭。
比如 Apache 的CollectionUtils.intersection传入两个 null,它内部会按空集合处理,不会抛异常。但是如果你传的是一个自定义的Collection实现类,这个实现类的iterator()方法本身就抛 NPE,那工具类也救不了你,因为它在遍历时调用了iterator()。这要求我们传入的集合必须是可靠的实现类,比如ArrayList、HashSet,不要随便传一个奇奇怪怪的匿名内部类。
Spring 的CollectionUtils在这一点上更保守些。CollectionUtils.isEmpty传 null 是安全的,但findValueOfType在集合为 null 时会返回 null,并不抛异常,这个行为也需要心里有数。
另外一个常见的坑是:工具类方法参数声明的是Collection<?>,你可以传入Map吗?不行,Map不是Collection的子接口,不能直接传。很多新手会把map传给CollectionUtils.isEmpty,编译直接报错。想判断Map是否为空,Apache 有单独的MapUtils.isEmpty,Spring 的则是CollectionUtils.isEmpty(map)对Map做了一个重载,它把Map也当作“容器”来判空。如果项目里用的是 Spring 的CollectionUtils,判空Map可以直接一行搞定;如果是 Apache,要用MapUtils,别搞混。
4.2 引用同一集合副本的陷阱
CollectionUtils.intersection会返回新集合,这个是安全的。但有些方法不一定。比如 Spring 的某些对集合操作的封装方法,或者 Apache 里collate这类排序合并方法,内部实现会依赖ArrayList,但返回的仍然只是一个普通的Collection包装视图。
实际业务里最常见的坑是:你用CollectionUtils.union或者intersection得到了一个新集合,之后你对这个新集合做增删操作,结果发现原集合变了。这可能吗?
在 Apache 的实现里通常不可能,因为它返回的是新集合。但如果你是用Collections.unmodifiableCollection包装过的集合,或者你用了一些返回视图的工具方法,那就可能“牵一发动全身”。比如:
List<String> view = CollectionUtils.unmodifiableList(originalList);在早期的版本里,unmodifiableList返回的就是一个直接引用原集合的包装视图,你往originalList里加元素,view里也能看到。反向操作则被禁止。这种事情本身不是 bug,是设计,但如果你没意识到就很容易在代码里写出“修改了某个视图,导致数据异常”的诡异问题。
所以我给自己定了一个规矩:凡是工具类返回的集合,一律默认当成“新的可修改集合”或“不可修改视图”来看待,具体是哪一种,以方法文档为准,不靠猜。如果拿不准,就复制一份用到业务里,老实用new ArrayList<>(result)包一层,虽然多了一点点内存开销,但可以彻底脱离对底层的依赖。
4.3 泛型擦除带来的类型安全错觉
工具类方法大多是泛型方法,比如public static <T> Collection<T> union(Collection<? extends T> a, Collection<? extends T> b)。泛型会在编译期帮你做类型检查,但由于 Java 的泛型擦除,运行时你仍然可能拿到一个“看似安全”但类型不值当的集合。
举个例子,有人用CollectionUtils.intersection得到了一个Collection,然后强行把它转成List<Order>,再遍历取值。编译期可能通过(因为泛型擦除后是原生类型),但运行时如果集合里混入了其他类型的元素,取出后做强制转换就会抛ClassCastException。
这种情况多数出现在上游接口返回的是原始类型或没有清洗过的数据时。工具类不是类型安全的多面手,它只能保证集合层面操作的类型匹配,不能保证集合元素的实际运行类型。所以如果你的接口数据来源不可信,最好在入参时就用List<Order>或List<String>这种强类型来接,而不是List或者List<Object>。
4.4 性能陷阱:在循环里使用集合运算方法
我知道有人喜欢这样写:
for (Tag tag : tags) { found = CollectionUtils.intersection(tag.getParentIds(), allIds); }如果循环次数不多、集合元素也少,这样没什么问题。但当tags数量大、getParentIds()返回的都是大列表时,交集运算在循环体内不断创建新集合,会造成大量的内存分配和 GC 压力,接口性能不吃紧才怪。
最优做法是把集合运算放在循环外,预先算好结果,然后把结果作为常量传进去。如果确实需要循环内计算,那就用HashSet先把allIds处理成 Set,循环里直接用set.contains()判断,连集合运算都不需要。很多时候,不是工具类慢,是使用方法不对。把这个顿悟分享给所有人:集合运算的返回值不要重复创建,能提出来就提出来。
5. 常见问题排查与速查表
5.1 典型问题的排查思路
我整理一下实操中遇到比较多的CollectionUtils相关问题,包括现象、原因和解决思路,方便大家直接对号入座。
| 现象 | 可能原因 | 排查思路 |
|---|---|---|
import报错 / 方法不存在 | 引用的库不对,比如想在 Apache 里找 Spring 的方法 | 确认项目引入的是commons-collections4,还是 Spring 自带的spring-core |
| 判空后仍然 NPE | 集合本身不为空,但集合里的元素为 null | 排查是否是元素级别的 null,循环内做单元素判空 |
| 交集/并集计算出来的结果不对 | 对重复元素的期望与工具类的语义不一致 | 查阅文档确认是数学语义还是保留重复的列表语义 |
ClassCastException | 依赖了返回集合的具体类型,或者对泛型擦除集合做强制转换 | 避免直接强转返回集合,用泛型接收并复制 |
| 运算结果影响原集合 | 返回的是视图而不是副本 | 确认方法文档,必要时new ArrayList<>(result)隔离 |
| 数据量大时耗时严重 | contains在大列表上 O(n),整体 O(n*m) | 先转HashSet再做运算,或者直接在 Set 上操作 |
这里最值得强调的一点是问题出现后先别急着改代码,而是先确认你用的到底是哪个库的CollectionUtils。我见过有人在 Spring 项目里调CollectionUtils.intersection,编译不过就开始改代码逻辑,结果折腾半天发现 Spring 的版本根本没有这个方法,需要换 Apache 版。先看 import 和依赖,能省下大量排查时间。
5.2 方法速查表
提示:以下是 Apache Commons Collections 4.x 的常见方法,Spring 项目请对照
org.springframework.util.CollectionUtils去查对应方法。
| 方法 | 作用 | 备注 |
|---|---|---|
isEmpty(Collection) | 判断集合是否为 null 或空 | 所有集合工具类的入门方法 |
isNotEmpty(Collection) | 判断集合非空 | 比手动判空代码可读性好 |
addAll(Collection, Iterable) | 安全地批量添加元素 | 目标集合为 null 时自动初始化 |
containsAny(Collection, Collection) | 判断是否包含任意一个元素 | 比containsAll更常用于业务过滤 |
containsAll(Collection, Collection) | 判断是否包含全部元素 | 类似Set.containsAll |
union(Collection, Collection) | 并集(保留重复) | 注意不是数学意义上的去重并集 |
intersection(Collection, Collection) | 交集 | 结果保留两边重复元素的合并 |
subtract(Collection, Collection) | 差集 | 移除数量按 b 中出现次数计 |
disjunction(Collection, Collection) | 对称差集 | 两边元素不同时出现的那部分 |
collate(Collection, Collection) | 合并两个有序集合 | 返回有序的合并结果 |
filter(Collection, Predicate) | 过滤集合元素 | Java 8 后用 Stream 替代也可 |
transform(Collection, Transformer) | 转换集合元素 | 会修改原集合,注意副作用 |
removeAll(Collection, Collection) | 批量移除元素 | 注意与 subtract 区分 |
isEqualCollection(Collection, Collection) | 判断两个集合是否元素完全相等 | 不考虑顺序,但统计次数 |
select(Collection, Predicate) | 返回满足条件的新集合 | 返回新集合,不修改原集合 |
containsAny是一个很实用的方法,它用来判断“两个集合是否有交集元素”,底层其实就是循环加 contains,但省了你自己写双重循环。做权限校验、标签匹配这类“满足任一即可”的业务时,这个方法是隐藏神器。
isEqualCollection也不常被人提起,但它特别好用,判断两个集合里的元素是否完全一致,顺序无关,而且相等次数一致。如果你在写单测,想断言两个集合内容相同但不在乎顺序,用它就非常省事。
5.3 关于 Java 8 Stream 的替代思路
现代 Java 项目里,很多人已经习惯用 Stream 处理集合。对CollectionUtils里的很多方法,Stream 也有对应的替代写法。这里简单给一个“何时用工具类、何时用 Stream”的判断逻辑。
如果操作简单、方法现成,比如判空、集合运算,用CollectionUtils可以让代码更紧凑,而且它在 null 处理上更“宽容”,对老代码兼容更好。如果操作复杂,需要链式处理多个步骤,比如“过滤 -> 去重 -> 映射 -> 收集”,那 Stream 的可读性更胜一筹。还有一个取舍点是:CollectionUtils的方法名是动词,表达的是“我要对集合做什么”;Stream 的写法是链式描述,表达的是“数据怎么一步步变化”。两者不是替代关系,而是互补。
比如“从 list 里找出所有状态为有效的 ID”这个需求,用工具类可能是这样:
List<String> validIds = new ArrayList<>(); CollectionUtils.select(orders, order -> order.isValid(), validIds);用 Stream 是:
List<String> validIds = orders.stream() .filter(Order::isValid) .map(Order::getId) .collect(Collectors.toList());两种写法没有绝对优劣,但团队写代码讲究一致性,一般选一种顺手的主导风格。我个人推荐在核心业务里多用 Stream,因为它的每一步都很明确,代码评审时容易抓住性能瓶颈;在兼容性要求高、或不想引入过多 Stream 链式语法的老项目里,CollectionUtils依然是最稳的选择。
写在最后
CollectionUtils看着是个不起眼的工具类,但用好了的确能让代码干净不少。我在实际项目中最大的体会是,工具类最大的价值不是省那几行代码,而是统一了集合操作的语义,减少了人在处理 null、空集合、重复元素这些边界时犯错的机会。真正的高手不会纠结于“哪个工具类最牛”,而是清楚自己的项目依赖、团队习惯和 Java 版本之后,选一个最顺手的组合,然后保持风格一致地写下去。
最后再分享一个小技巧:如果你在一个老项目里看到了特别旧的 Apachecommons.collections2.x 系列版本,尽早把它升级到 4.x。2.x 到 3.x 的包名变化我记不清所有的坑,但 4.x 对泛型支持好很多,而且修复了不少潜在的类型安全问题。工具类升级的收益不像框架升级那么显眼,但能少踩很多隐蔽 bug。集合操作写多了你会发现,最贵的不是代码本身,是排错花掉的时间。