1. 开发十年我为什么还在天天用 Collections 工具类先讲个真实场景。上个月我接手一个报表系统里面有一段代码用了三个 else if 去一个 List 里找最大值和最小值。我当时没忍住直接在代码评审群里发了一句你们知道 Collections 有个 max 和 min 方法吗后来这段代码被改成了两行。就是这种小事情几乎每个项目里都能翻出来一大堆——不是没学过是真正用的时候想不起来。Collections 这个工具类估计每位写过 Java 的人都知道它是 java.util 包下面专门操作集合Collection 和 Map 等的静态工具类。它不存数据不搞实现只负责把各种常用的集合操作封装成现成方法。从 JDK 1.2 集合框架诞生那天起就有了几十年过去依然在频繁使用。但知道和用得好完全是两码事。我这几年在几个项目里归纳过大多数开发者对 Collections 的使用停留在 sort 和 shuffle 这两个方法上。至于 singletonList、unmodifiableList、synchronizedMap 这些很多人甚至不知道它们存在。这不怪谁Java 的 API 体系确实庞大但 Collections 里的方法覆盖面太广了——排序、查找、打乱、反转、频率统计、批量填充、线程安全包装、不可变包装、空集合创建、单元素集合创建、类型安全包装每类方法都对应实际开发中一个具体的痛点。这篇文章我打算把 Collections 工具类里我认为最实用、最高频、最容易被面试官问住的方法全部过一遍。每讲一个方法不只是告诉你它能干什么还会把为什么需要它底层原理是什么什么场景下用它的哪种变体这层窗户纸捅破。涉及踩坑的地方我会直接照着代码说——这些东西大多数是网上教程不会告诉你的。如果你是正在准备 Java 面试的人那这篇文章更得看。让我直接说吧面试官问 Collections 和 Collection 的区别以及 Collections 的常用方法几乎是 Java 方向的基础必考题。但很多人只知道一个Collections 是操作集合的工具类就没了。真正能拿高分的回答恰恰是能不能讲清楚 emptyList 和 singletonList 的区别、unmodifiableList 的视图机制、SynchronizedCollection 为什么不包迭代器——这些细节才是加分项。下面我按功能域把方法拆开讲尽量把一个方法的前因后果都展开配合我实际用过的项目和代码示例。2. 排序、反转、打乱与查找操作顺序类方法的使用逻辑2.1 sort 方法背后的排序策略以及为什么对象排序必须用 Comparable 或 Comparator先说最基础的。Collections.sort(ListT list)方法接收一个 List把它原地升序排序。请注意原地这个词——它不会返回一个新的排序后的 List而是直接改变传入的 List 列表中的元素顺序。这一点和 Java 8 新增list.sort()、stream().sorted()的返回值逻辑不一样很多人在这里被坑过。这个方法表面看只是排序底层其实大有文章。在 JDK 7 之前Collections.sort底层用的是归并排序到了 JDK 7 之后底层实现换成了 TimSort。TimSort 不是 Java 自己发明的它 2002 年出现在 Python 里作者是 Tim Peters后来被广泛移植。TimSort 可以理解为归并排序 插入排序的混合体它能够充分利用数据中已经有序的片段因此对于大部分部分有序的数据它的实际性能非常出色时间复杂度最坏 O(n log n)空间复杂度 O(n)。它还有一个很关键的特性排序是稳定的。什么叫稳定就是两个相等元素排序后不会交换相对顺序。这在按多字段排序的时候非常重要比如你先按年龄排再按姓名排如果年龄相同但你希望姓名的相对顺序还是原来的顺序就必须用稳定排序。再往下说Collections.sort方法有两层重载一个是只接收 list另一个是接收 list 和 Comparator。如果你传的元素没有实现 Comparable 接口比如你自己写的一个没有实现 Comparable 的普通类直接调用只传 list 的那个方法会抛出ClassCastException。这也是为什么大凡有点经验的 Java 开发都会给实体类实现 Comparable或者更推荐用 Comparator 的方式传一个排序策略进去。Comparator 的好处是解耦排序逻辑和实体类本身一个实体类可以配合多个 Comparator 实现不同的排序方式。我说一个实际工作中的建议能用list.sort或者Collections.sort的时候尽量别用 Stream 的 sorted 排序。不是因为 Stream 不好而是 Stream 的 sorted 会生成一个新的 List如果你的业务里后续还要拿着原 List 做别的操作很容易出现我以为排过序了的错觉。集合操作的副作用原地修改在这里其实是一种优势——代码的可读性更直白别人看到这一行就知道原列表势必要被重排。2.2 binarySearch 二分查找为什么返回值有可能是负数如果说 sort 是 Collections 里出场率最高的方法那Collections.binarySearch就是面试最爱考实战最多人用错的方法。binarySearch(List? extends Comparable? super T list, T key)方法的功能很直白在一个有序升序List中查找目标元素的位置返回目标所在的索引下标。找不到的时候它不会直接返回 -1而是返回-插入点- 1。比如一个 [1, 3, 5, 7] 的 List我查 4那么返回的是 -3。因为如果我要插入 4它应该插入在索引 2 的位置即 4 应该插在 5 之前所以返回值是-(2)-1 -3。你把它反过来理解就行实际插入点 -(返回值) - 1。这个地方面试官最喜欢追问为什么找不到时不返回 -1 而是这个负数公式标准答案是这样的返回值设计既保证了找不到的信息传递同时保留了如果把它插进去应该插在哪一位置的信息。一句话用一个返回值传递了两条信息。这种设计在 JDK 源码里不止一处Arrays.binarySearch 也是同样的套路。还有一个高频踩坑点binarySearch 要求传入的 List 必须是升序的。如果数据不是有序的它的结果是不确定的甚至可能返回一个正确的假象——也就是碰巧找到了某个位置但可能不是唯一位。我在项目里就见过有人在一个 HashMap keySet 转出来的 List 上直接做 binarySearch数据量小的时候没出什么问题数据一多就开始出现查不到的诡异现象。另外注意当你查找的是自定义对象时必须调用传 Comparator 参数的重载版本比如int index Collections.binarySearch(userList, new User(张三), Comparator.comparing(User::getName));这个版本要求你的 List 必须是根据同一个 Comparator 排好序的否则同样会出现不可预期的结果。所以我的习惯是排序用的 Comparator 和二分查找用的 Comparator务必定义成同一个静态常量绝不写两遍。2.3 reverse、shuffle、rotate、swap别小看这几个不起眼的小方法讲完排序和搜索紧接着就是几个操作元素顺序的方法。虽然它们看起来很小但在实际业务里各有各的用武之地。Collections.reverse(List? list)原地反转列表。这个是我经常在白板代码里看到但实际项目里用得反而不多的方法。它的典型场景在于某些数据源只支持正序返回但页面需要倒序展示你又不想改动数据源那一层就可以在返回之前 reverse 一下。注意它的时间复杂度和实现方式——JDK 源码里 reverse 是通过一个很经典的前后对称交换two-pointer swap实现的也就是首尾交换直到中间不需要额外的空间。Collections.shuffle(List? list)随机打乱列表。这个方法底层用的是 Fisher-Yates 洗牌算法也叫 Knuth 洗牌。Fisher-Yates 的核心逻辑是从最后一个元素开始随机选一个位置和当前位置交换然后往前移动一位继续操作。这个算法保证了每种排列出现的概率完全相等而不是像很多人随手写的每次从 List 里随机取一个元素放到新 List那样不均匀且 O(n^2)。shuffle 在业务里最常见的场景就是问卷题目的选项乱序、抽奖活动的奖品池打乱、题库抽题的随机化。如果你发现系统里的随机命中率分布不均匀多半不是 Random 的问题而是你自己写的随机算法不够均匀。这里我建议直接用shuffle而不是自己实现。Collections.rotate(List? list, int distance)循环移位。这个方法稍微冷门一点我很少见人在项目中主动用它但它其实特别适合做日历控件里周几对齐或者列表轮播的偏移这类需求。比如你有一个 [1,2,3,4,5]调用rotate(list, 2)之后就变成了 [4,5,1,2,3]——每个元素向右移动 2 位超出末尾的绕回开头。distance 为负数则是向左移。表面上看这个方法很简单但有意思的是JDK 源码里它是通过调用两次 reverse 的效率最高的方案先将整个 list 反转再反转前半段和后半段。这种三次反转的思路在很多算法题里也有用到。Collections.swap(List? list, int i, int j)交换两个位置的元素。单独用它的情况比较少但它在sort等底层工具实现中会频繁被调用。如果你自己写排序算法比如快速排序、冒泡排序的手写实现这是面试八股热题可以直接用Collections.swap省去手写交换的临时变量代码代码也更简洁。3. 求最小最大、频率统计、批量填充这一类方法在数据处理里是真高频3.1 max、min 方法的两种用法以及一个易被忽略的对象比较约束Collections.max(Collection? extends T coll)和Collections.min是一对对称的方法其作用是在集合里取最大/最小的元素。初次见到它们的人容易跟 Stream 的max/min搞混但底层逻辑是一样的都需要元素具备可比较性。默认情况下它使用元素的自然顺序Comparable来比较大小。如果你的元素类型没有实现 Comparable那么就得用重载版本Collections.max(list, Comparator.comparing(User::getAge)); Collections.min(list, Comparator.comparing(User::getAge));这里有个很小的细节但面试官喜欢考max和min方法的参数类型是Collection? extends T不限于 ListSet、Queue 也可以。另外要注意传入的集合不能为空否则会抛NoSuchElementException。为什么不是返回 null因为 null 本身是可以被当作元素的合法值的用一个 sentinel value 来表示 没有元素 比返回 null 更能避免歧义。这就是设计原则里不要把 null 当业务返回值的一个典型例子。日常开发中这两个方法的出场率不算特别高因为很多场景是取最大/最小并且还想拿到对应的那条记录那一般就是先排序再 get(0) 的方式或者用 Stream 配合 Comparator 来拿。但如果只是我就想拿一个数字最大值比如一堆订单金额里取最高那个直接用Collections.max一行代码搞定比 stream 更直观也比手写循环省不少事。3.2 frequency 和 disjoint两个面试容易知道、实战容易忘的冷门利器Collections.frequency(Collection? c, Object o)这个方法用来统计集合中某个元素出现的次数。比如int count Collections.frequency(wordList, Java);这一行代码直接告诉你 wordList 中Java出现了几次。可能有人觉得它和 list.contains 循环区别不大但它写起来更短而且它是直接对 Collection 做完成遍历的不需要额外的 map 统计结构。在业务里我有一个常用场景校验某份试卷里有没有重复的题号。我用一个 List 存所有题号然后遍历每个题号调用frequency任何一个题号的出现次数大于 1就说明重复了。虽然时间复杂度是 O(n^2)但题量通常很小代码干净且好维护。如果数据多了再考虑用 Map 优化。Collections.disjoint(Collection? c1, Collection? c2)这个方法判断两个集合是否没有交集返回 true 说明两者没有任何共同元素。与之相对的如果你想判断是否有交集就是!disjoint(c1, c2)。这两个方法确实是很多人在没看到之前都不知道 JDK 还提供这种现成的轮子。事实上它们背后的实现很朴素遍历较小集合去较大集合中contains。所以它还比较适合做权限校验比如判断用户的角色集合和接口要求的角色集合是否有交集。!Collections.disjoint(userRoles, requiredRoles)一行就能判断是否有权限。3.3 fill、replaceAll、copy批量修改集合数据的效率与边界Collections.fill(List? super T list, T obj)可以把 List 中的所有元素替换成同一个对象引用。这个方法在什么场景下用我印象比较深的一个项目里我需要给一个约定长度的 List 初始化默认值。假如我定义了一个长度为 10 的 List里面每个位置都要填上 new ArrayList() 这种初始值那我就可以Collections.fill(list, new ArrayList())。注意它替换的是引用所以传同一个对象进去List 里每个位置的引用都指向同一个对象。如果你用它在外部循环外创建了一个对象然后反复 fill那你得到的是一个所有元素引用同一个对象实例的列表。修改其中一个其他全部跟着变。这个细节没法在方法签名上看出来完全靠经验。Collections.replaceAll(ListT list, T oldVal, T newVal)将 List 中所有等于 oldVal 的元素替换为 newVal。它和Collections.fill的区别是fill 是全部替换replaceAll 是定向替换。注意方法签名上它跟 List 接口自带的 replaceAllJDK 8 新增接收 UnaryOperator同名但参数不同——前者是Collections.replaceAll(list, oldVal, newVal)后者是list.replaceAll(x - x * 2)这种写法。这个同名但不同语义的 API在实际团队协作中是很容易看岔的。Collections.copy(List? super T dest, List? extends T src)把 src 列表里的所有元素复制到 dest 列表中。这个方法有一个特别反直觉的坑它要求 dest 的长度不能小于 src 的长度仅仅容量够都不行。比如ListString src Arrays.asList(a, b, c); ListString dest new ArrayList(10); // 容量是10但 size 是 0 Collections.copy(dest, src); // 抛 IndexOutOfBoundsException这个坑我踩过一次。new ArrayList(10) 只是给内部数组预分配了 10 个容量空间但 List 的 size() 仍然是 0。而Collections.copy的实现是逐一调用dest.set(i, src.get(i))如果 size 小于 src 的 sizeset 就会越界。正确做法是先给 dest 填满元素比如用Arrays.asList(new String[src.size()])或者new ArrayList(src)直接拷贝构造一个新列表。说实话现代开发里 Collection.copy 的使用频率已经非常低了毕竟new ArrayList(src)既简单又不会踩这种坑但面试时偶尔会作为你是否真正了解 API 边界的问题出现只要知道这个坑就能答到点上。3.4 addAll 的几个细节用一次你就回不了手写循环的老路Collections.addAll(Collection? super T c, T... elements)向一个 Collection 批量添加多个元素。它的使用频率在我这里可以排进 Collections 前三ListString list new ArrayList(); Collections.addAll(list, a, b, c);这个方法的优势在于不需要先创建Arrays.asList再往集合里放一行代码完成创建临时数组 遍历添加到集合。它在源码里特意做了优化如果传入的是 ArrayList会直接扩容一次然后逐个 add而不会为每个元素都发生一次可能的扩容操作。这一点在日常对性能不是很敏感但面试问Collections.addAll 和手写 for 循环 add 有什么区别时如果回答能说到它会在源码层面针对特定集合做一次性能优化那观感完全不一样。换一个角度来看Collections.addAll接受的是可变参数T... elements所以你可以直接把一个数组传给它而不需要先转成 ListString[] arr {x, y, z}; Collections.addAll(list, arr); // 直接传数组也可以这与Arrays.asList(arr)有一个重要差异asList返回的是固定长度的视图不能 add/remove而Collections.addAll是把元素真正复制添加到目标集合里目标集合可以是任意长度可变的 Collection。搞清楚这个区别很多List 为什么不能 add的初级问题就都解决了。4. 空集合、单元素集合与不可变集合防御式编程里最容易踩坑的一组 API4.1 emptyList、emptyMap、emptySet为什么返回的是一个共享实例在写代码时如果一个方法没有数据可返回我建议你返回Collections.emptyList()而不是返回 null更不要返回一个 new ArrayList()。先说你大概率碰到过的空指针问题上游方法返回了一个 null下游拿到 List 后直接遍历for (String s : list)直接 NPE。UPDATE可能是新接手这套代码的人想优雅地解决于是每个人都加了一层 if null 判断代码里到处是这种防御判断。但更好的办法是约定所有返回集合的方法都不允许返回 null如果没有数据就返回 emptyList()。而Collections.emptyList()在这件事上还有个更微妙的设计它返回的是一个共享的单例对象整个 JVM 里所有调用 emptyList() 的地方拿到的都是同一个实例。JDK 源码里就是这么写的public static final T ListT emptyList() { return (ListT) EMPTY_LIST; }EMPTY_LIST 是一个静态常量。由于空列表本身就没有内容它天然是线程安全的无论多少个线程读它都不会出问题也不需要保存数据。这样一个不可变的空对象当然可以全局共享省内存又省创建开销。但这里也有一个很经典的坑Collections.emptyList()返回的 List 是不可变的你要是对它调用add()不会正常加进去而是直接抛UnsupportedOperationException。我见过不少人在单元测试里写出这样的代码ListString list Collections.emptyList(); // 中间模拟往 list 里加几个数据 list.add(test);一跑就报错然后一脸懵。应对方式就两条如果你只是一个临时空集合来占位用 emptyList如果你后面还要往里面加东西请用new ArrayList()。这个区别值得刻在脑门上。4.2 singletonList、singletonMap、singletonSet创建单元素集合的巧妙用处Collections.singletonList(T o)创建一个只包含一个元素的不可变 List。你可能会想我直接 new ArrayList() 然后 add 一个元素不就行了为什么要用它理由很实际。第一是性能singletonList 底层只是用一个临时数组保存了那个元素不做扩容相关的事内存占用极小。第二是语义它清晰地表达了我这里的集合永远只有一个元素这个意图。第三是配合不可变它返回的 List 同样是不可变的安全可靠。那 singletonList 的场景是什么呢有一个比较常见的调用一个接收 Collection 参数的方法但当前只传一个值进去。比如某个批量删除接口deleteByIds(ListLong ids)那你可以直接deleteByIds(Collections.singletonList(userId));这个方法还有一个冷门变体很有意思Collections.singletonMap(key, value)在同一次调用中构建一个只有一组键值对的不可变 Map。一些三方 SDK 的查询方法参数是 Map 类型但实际只需要传一两个参数你用 singletonMap 写起来会比 new HashMap put 简洁得多。还有个细节大家可能没注意singletonList 实现的get(int index)方法对传入 0 是安全的但传其它值会越界。这种集合大小确定的不可变性让它在并发场景中也完全线程安全不用考虑分配新内存和结构调整所以底层一些缓存框架中也会用到它。4.3 unmodifiableXXX 系列不要被不可变这个词骗了这里要划重点因为大量面试者乃至从业者都在这上面栽过跟头。Collections.unmodifiableList(List? extends T list)、unmodifiableSet、unmodifiableMap这三个方法可以把一个可变集合包装成只读视图。包装之后调用方不能通过这个视图去修改集合一旦尝试 add/remove/put/clear都会抛UnsupportedOperationException。但是它包装的是视图不是副本。假如你有两个引用一个是原始 list一个是 unmodifiableList 包装后的只读 list那么当原始 list 发生修改比如你通过原始引用 add 了一个新元素那个只读 list 也会跟着变。因为底层它存的还是同一个 List 对象的引用。ListString original new ArrayList(); original.add(a); ListString readonly Collections.unmodifiableList(original); System.out.println(readonly); // [a] original.add(b); System.out.println(readonly); // [a, b]视图跟着变化这个机制是很多项目安全防护的内鬼来源。你以为把内部集合 unmodifiable 之后传给外部就安全了实际上只要持有原始引用的地方还能改数据依然会被改动。真正的不可变集合应该拷贝一份副本或者使用 JDK 9 之后提供的List.of(...)构造全新的不可变集合。不过在 JDK 8 时代unmodifiable 加副本拷贝是主要方案ListString safeCopy Collections.unmodifiableList(new ArrayList(original));这样外部只能读 safeCopy原列表再变也影响不到它。所以在对外暴露数据时我个人的习惯是如果允许外部读但绝不能写优先考虑防御性拷贝 unmodifiable 包装的组合。5. 线程安全包装与类型安全校验同步包装器和 checked 系列5.1 synchronizedXXX为什么它不包装迭代器Collection 框架里的常规容器比如 ArrayList、HashMap、HashSet都不是线程安全的。在过去没有CopyOnWriteArrayList、ConcurrentHashMap这些并发容器作为默认选择的时候Java 提供了Collections.synchronizedList/synchronizedMap/synchronizedSet用装饰器模式给任意集合加了一把全局锁。使用方式很简单ListString syncList Collections.synchronizedList(new ArrayList()); MapString, String syncMap Collections.synchronizedMap(new HashMap());之后你在这个列表上的 add、remove、get、size 等单个方法都会被加锁保证在任意时刻只有一个线程执行这些操作。这样比在调用方外部到处写 synchronized 块更集中和规范。但它有一个非常重要的短板迭代器不是线程安全的。你如果在一个线程边遍历这个 synchronizedList另一个线程在往里面 add就有可能导致ConcurrentModificationException。为什么呢因为 synchronizedList 包装的集合它内部类的 iterator 方法并没有加锁这是它在源码层面顾不上的地方——它只能保证单个方法调用的原子性无法保证调用方先遍历完整个集合的这段时间内没有其他线程修改它。所以官方 Javadoc 也明确要求当你遍历一个同步包装集合时必须手动在 iterator 外加 synchronized 锁。ListString syncList Collections.synchronizedList(new ArrayList()); synchronized (syncList) { IteratorString it syncList.iterator(); while (it.hasNext()) { // safe } }我在公司带新人时发现很多人以为 synchronizedList 就是线程安全的 List于是在多线程请求处理场景中直接拿它做消息队列。结果线上偶发 ConcurrentModificationException查了半天才发现是因为迭代遍历没有加锁。这里我强烈建议如果只是需要并发读写、对一致性要求没那么苛刻优先考虑CopyOnWriteArrayList和ConcurrentHashMap它们在设计上补足了树的同步包装类的一些短板该用直接用。但某些场景比如要维护插入顺序的线程安全列表且写多读少synchronizedList 仍然值得选。5.2 checkedXXX 类型安全检查在企业级防御中几乎是隐藏功臣Collections.checkedList/checkedMap/checkedSet/checkedCollection这一组方法普通业务开发中不太会主动使用但在一些需要处理不可信输入的老旧系统里它们的价值非常高。它们的作用是在运行时对添加进去的元素做类型检查。比如ListString checked Collections.checkedList(new ArrayList(), String.class);之后如果你在这列表里 add 一个非 String 对象会在 add 时立刻抛ClassCastException。大家可能会疑惑Java 的泛型不是已经保证类型安全吗没错泛型在编译期可以挡住绝大多数类型错误但在两个场景下泛型无能为力绕过泛型你定义了一个ListString但这个列表被赋值给一个原始类型 Listraw type再往里面放乱七八糟的对象编译器不会报错。这种现象在老代码或反射场景中很常见而且等它运行时才在别的地方 ClassCastException排查的成本非常高。反射添加通过反射拿到的集合对象也可以是原始类型绕过了泛型编译期检查。我之前维护过一个老旧的规则触发系统消息体由 JSON 解析解析之后的字段类型很不可控。我当时用 checkedList 包装了核心的规则数据列表一旦有脏数据进来立刻在写入阶段就报错而不是等到几个小时后数据落到数据库里才出问题。这就是 checked 系列最大的价值尽早失败fail-fast把类型错误暴露在最容易发现的位置。5.3 从源码缓存思想看 empty 单例与 unmodifiable 包装的性能设计顺带讲一个贯穿整个 Collections 工具类源码的思想性能优化优先考虑复用其次才是减少操作次数。emptyList 共享单例、singletonList 共享存储、unmodifiableList 只增加一层装饰器不复制数据这些都是复用思想的体现。与之对应synchronizedList 则是尽量用最少代理代码完成加锁逻辑checkedList 尽量在原有方法上只加一次类型检查。理解了这些设计你在回答 Collections.emptyList() 是单例吗 这种问题时会自然带上因为空集合不需要保存状态所以可以安全共享的推导过程而不是死记答案。面试官在这种细节上觉得你有源码功底那这道题就过了。6. Collections 工具类与其它集合 API 之间的关系和边界6.1 Collections 和 Collection、List、Map 之间的关系这个基础问题面试春招时几乎必问。简单说Collection 是 Java 集合框架中最上层的接口之一它定义了集合类的基本行为比如 add、remove、size、iterator。List、Set 都是它的子接口。而 Collections 不是接口也不是集合的实现类它是一个纯工具类utility class里面全部是静态方法用来操作 Collection 及其实现类。它俩的关系可以类比成 Arrays 之于数组、Objects 之于 Object。从纯代码风格上看Collections.sort(list)与list.sort(comparator)是可以互换的。在 JDK 8 之后List 接口增加了默认方法 sort 和 replaceAllArrays 类也增加了一个静态方法Arrays.sort可以直接对数组排序——它们背后的实现逻辑不完全相同但目标都是排序。因此现在写代码我更推荐的风格是如果你持有的是一个 List 对象直接用 List 接口的方法list.sort如果你持有的是 Collection 但需要工具方法比如给 Set 排序转 List再用 Collections 的静态方法。方向对了代码就会自然一些。6.2 从 Collections 到 Stream API操作集合的两种思维模式Java 8 之前的集合操作大多数是命令式的告诉程序先做什么再做什么。举例去重ListString list new ArrayList(new HashSet(oldList));如果用了 Stream你可能会写成ListString list oldList.stream().distinct().collect(Collectors.toList());这两种写法没有绝对的优劣但思想不同。Collections 工具类更多是直接对现有集合做原地修改一般不会产生新的集合copy/empty 等少数除外而 Stream 更强调产生新的流、再收集成新集合不修改原集合。在追求性能和低内存占用的代码里原地修改反而更好。比如一个几百万数据的列表要排序Collections.sort直接复用原数组不产生新的 List 对象而stream().sorted()会先拷贝数据到新数组进行排序最后返回一个新 List内存开销翻倍。所以如果只是在做内部数据处理建议优先考虑 Collections 的直接方法如果要走声明式过滤-转换-收集的编程风格才建议使用 Stream。这不是谁替代谁而是各自在合适的场景下发亮。6.3 结合 JDK 9 起的 List.of 与 Set.of不可变集合的演进JDK 9 开始Java 在接口中直接提供了List.of(...)、Set.of(...)、Map.of(...)这些静态工厂方法用来创建不可变集合。这些 API 比 Collections 的 unmodifiable 系列在语义上更彻底它们创建的集合本身就是不可变结构不依赖某个可变的原始集合作为数据源。比如ListString list List.of(a, b, c); SetString set Set.of(x, y, z); MapString, Integer map Map.of(k1, 1, k2, 2);它们和 Collections.emptyList()、singletonList() 有重叠的用途但更通用。需要注意List.of 不允许传入 null 元素并且任何修改操作都会抛 UnsupportedOperationException。所以如果你在旧系统里维护的是 JDK 8 代码Collections 还是不可变集合操作的主力而新项目里直接拥抱 List.of 是完全没有问题的。我个人的做法是JDK 8 项目中遇到空集合返回就Collections.emptyList()JDK 9 新项目中就直接List.of()。它们其实并不冲突。7. 面试必问考点清单与实战避坑复盘7.1 高频面试问题把上面所有内容浓缩成面试角度我整理了一份清单每个问题背后的考察点也写出来了供自测Collections 和 Collection 有什么区别考察基础概念Collection 是接口Collections 是工具类。Collections.sort 的排序算法是什么为什么不用快速排序考察 JDK 源码理解和排序稳定性。JDK 7 至今默认使用 TimSort它是稳定排序对象排序需要稳定性而基础类型排序可以用双轴快速排序因为基础类型没有相对顺序一说。binarySearch 找不到元素时返回什么考察返回-插入点-1的设计思想和用法。emptyList() 和 singletonList() 返回的列表可以 add 吗考察不可变集合的语义。unmodifiableList 是真正的不可变集合吗考察视图与副本的区别。synchronizedList 的迭代安全吗考察线程安全边界。checkedList 解决了什么问题考察泛型擦除和 raw type 场景下的类型安全问题。Collections.copy 在什么情况下会抛 IndexOutOfBoundsException考察对目标 List size 与 capacity 区分的理解。max/min 在什么情况下抛 NoSuchElementException考察空集合的处理逻辑。之后大家可以根据这份清单自我检测。每一项如果你都能说出方法签名 底层实现 适用场景 坑点那么这一块就算彻底打通了。7.2 我在真实项目中踩过的三个 Collections 相关的坑最后作为收尾我把三个亲身踩过的坑放在这里属于比较典型又容易被忽略的类型。第一个坑就是我在前面提过的Collections.copy的 size 问题。当时我要把一个配置列表复制一份用于后续规则计算初始化目标列表时偷懒用了new ArrayList(source.size())以为容量有了就万事大吉。结果运行时就抛了 IndexOutOfBoundsException。排查后才发现new ArrayList(n)只是预分配内部数组空间不改变 size。这个坑的教训就是不要混淆 capacity 和 size任何依赖 set(index) 的操作列表的真实 size 必须够大。第二个坑unmodifiableList 的视图穿透问题。我给一个内部接口返回了Collections.unmodifiableList(internalList)本以为调用方拿不到改写能力后来在一次排查数据异常时发现 internalList 被别处修改了所有读取到的数据也跟着变化。当时第一反应是有人反射改了不可变集合后来一步步跟踪发现纯粹是原始引用上的 add 操作导致的。从此我给外部调用方的数据都会做防御性副本拷贝。第三个坑synchronizedList 的迭代器问题。一个定时任务线程从 synchronizedList 中读取所有元素另一个请求线程在往同一列表追加任务。偶尔会有 ConcurrentModificationException。当时我查了 ConcurrentHashMap 和 CopyOnWriteArrayList 都不太合适需要维持 FIFO 顺序最终改写为遍历前在 synchronized(syncList) 块内拷贝出新列表然后对快照做遍历。这也引出一个经验同步包装类适合简单锁粒度场景一旦涉及多步复合操作比如先判断再操作、先遍历再修改就必须自己在外围加锁不能指望工具类全包。7.3 一张图理清 Collections 常用方法的选择逻辑我知道读者里很大一部分人拿这份内容是要应付面试或者马上要在项目里用的人光靠记忆方法名不靠谱我更建议你有一个选择逻辑——拿到一个需求能想到 Collections 里确实有现成方法这就足够了。简单归纳一下要排序 → sort要反序 → reverse注意与 Comparator.reversed() 的区别要随机 → shuffle要循环移位 → rotate要查最大最小 → max/min要二分查找 → binarySearch先排序要统计出现次数 → frequency要判断两个集合有无交集 → disjoint要批量填充/替换 → fill/replaceAll要批量添加 → addAll要空集合/单元素集合 → emptyXxx/singletonXxx要只读视图 → unmodifiableXxx要线程安全包装 → synchronizedXxx要运行时类型检查 → checkedXxx要复制 → addAll 或 new ArrayList(src)不要用 copy 除非你已经非常清楚 size 的坑我之前带过一个刚转 Java 的同事他把这张表打印出来贴在工位上过了大概两周日常开发里基本不用再翻 API 文档了写代码速度提升明显。工具类这种东西就是知道有什么 - 知道为什么 - 用得顺手的过程没有特别高级的门槛。关键是别让它只停留在面试题里实际项目里见到了相关需求就多用几次自然记住了。