说实话,java.util.Collections里的shuffle和sort,我用了很多年,但真正读懂它们是在一次性能排查之后。当时线上有个调度任务,天天超时,最后定位到是对一个上千万元素的LinkedList做了排序,程序在设计上就没选对数据结构。从那以后我才把眼光从“用完就行”挪到“为什么这么实现”上。这篇文章就把这两个方法从源码到算法、从实战到面试考点一次讲透。
1. 这两个方法能干什么,为什么值得深挖
1.1 一个洗牌、一个排序,List 上的两把刀
shuffle()的作用是把List里的元素顺序随机打乱,sort()的作用是按自然顺序或者自定义比较规则把元素排列整齐。两者都定义在java.util.Collections工具类里,走的是静态方法路线,只接收List系列接口。
虽然名字和用法看起来都很简单,但它们干的事儿都不小:shuffle背后是经典的 Fisher-Yates 洗牌算法,时间复杂度 O(n),是生成随机排列的工程标准;sort背后在 Java 8 之后对象数组走的是 TimSort,一种稳定、自适应、最坏 O(n log n) 的混合排序。这两个方法不只是“给面试准备的 API”,实际业务里抽奖打乱、按钮随机排序、排行榜、数据预处理到处都用得上。
1.2 工具类背后的设计套路
Collections里几乎全是静态方法,sort、shuffle、reverse、rotate、binarySearch、frequency等,本质上做的都是“把通用算法从具体数据结构里抽出来”这件事。算法只需要依赖List接口的随机访问能力(get、set),不关心底层是ArrayList、LinkedList还是Vector。
这种思想和设计模式里的策略模式有相似之处:数据结构的实现是变化的,算法是通用的,两者通过接口解耦。我们自己写工具类时也可以照搬这套思路——方法参数尽量面向接口,内部再针对具体实现做优化分支。后面看shuffle源码时会发现,JDK 自己也是这么干的。
2. shuffle 方法拆解:源码、算法与使用场景
2.1 翻开源码看 Fisher-Yates 洗牌
直接上源码(JDK 8+):
public static void shuffle(List<?> list, Random rnd) { int size = list.size(); if (size < SHUFFLE_THRESHOLD || list instanceof RandomAccess) { for (int i = size; i > 1; i--) swap(list, i - 1, rnd.nextInt(i)); } else { Object[] arr = list.toArray(); for (int i = size; i > 1; i--) swap(arr, i - 1, rnd.nextInt(i)); ListIterator it = list.listIterator(); for (int i = 0; i < arr.length; i++) { it.next(); it.set(arr[i]); } } }代码很短,但信息量很大。SHUFFLE_THRESHOLD的值是 5。当列表小于 5 个元素,或者list实现了RandomAccess接口时,直接在原列表上做交换;否则先把所有元素拷贝到对象数组里,洗完再一次性写回列表。之所以要区分,是因为LinkedList的get(index)不是 O(1),而是在链表里逐个遍历。如果直接在它上面循环swap(list, i - 1, rnd.nextInt(i)),每次取值都是 O(n),整个洗牌直接退化到 O(n²),数据一大会非常难受。
这一段设计很值得学:RandomAccess是个空接口,纯粹当标记用,ArrayList实现了它,LinkedList没有。JDK 在底层就是用这种标记接口判断“这个 List 能不能高效随机访问”,然后走不同的分支。我们在写自己的通用算法时,面对可能有多种实现的数据结构,也应该先问一句“它支持高效随机访问吗”,再做分支取舍。
换个重载,不传Random的方法内部会 new 一个默认的Random实例:
public static void shuffle(List<?> list) { Random rnd = r; if (rnd == null) { r = rnd = new Random(); } shuffle(list, rnd); }2.2 为什么从后往前洗,数学上才能保证均匀
先看核心循环:
for (int i = size; i > 1; i--) swap(list, i - 1, rnd.nextInt(i));rnd.nextInt(i)返回 [0, i-1] 区间内的随机整数。所以第i-1个位置会和[0, i-1]中任意一个位置交换。等到下一轮,i减一,刚才固定下来的位置就不再参与后续交换。这就是 Fisher-Yates 算法(也叫 Knuth Shuffle)的标准形态。
理解它为什么均匀,可以从最后一个位置开始想:第一轮,最后一个位置等概率地和0..size-1中的任何一个位置交换,所以最后一个位置被安排成任意元素的概率都是1/n。第二轮,排在倒数第二的位置从剩余n-1个元素里等概率选一个,概率是(n-1)/n * 1/(n-1) = 1/n。依此类推,每一个位置上出现每一个原始元素的概率都正好是1/n,所有排列出现的概率是1/n!。
换个思路也能证明:这个算法生成的排列总数是n * (n-1) * (n-2) * ... * 1 = n!,而且树形结构里每个叶子对应的路径权重完全一样。
我见过有人图省事,用“循环从 0 到 n-1,随机交换两个位置”的方式洗牌。这种 naive 实现会产生大量重复排列,某些排列出现的概率明显高于另一些,也就是有偏的。如果做抽奖这类对公平性要求高的场景,这种偏差会造成实打实的脏数据。Fisher-Yates 的“从后往前逐个固定”是正确做法,别再凭直觉乱写了。
2.3 shuffle 实际用到哪些地方
- 游戏发牌:斗地主、德州扑克、卡牌对战,整副牌一次
shuffle,然后按顺序发牌。 - 数据预处理:机器学习训练前打乱数据集顺序,避免模型学到样本间的顺序偏差。
- 抽奖与点名:名单打乱后按顺序取,公平且逻辑简单。
- A/B 测试分组:用户列表洗牌后按比例切分到实验组和对照组。
- 随机排列生成:算法题或者蒙特卡洛模拟里用来产生随机序列。
一个特别有用的细节:shuffle是原地修改list,不会返回新列表。如果你不想动原来的数据,得先new ArrayList<>(original)拷贝一份再洗。这地方真的很多人踩坑,尤其是从函数式语言切到 Java 的同事,总以为会返回一个新集合。
3. sort 方法拆解:底层排序、比较器与稳定性
3.1 Collections.sort 和 List.sort 到底是什么关系
Java 8 之后,List接口增加了默认方法sort:
default void sort(Comparator<? super E> c) { Object[] a = this.toArray(); Arrays.sort(a, (Comparator) c); ListIterator<E> i = this.listIterator(); for (Object e : a) { i.next(); i.set((E) e); } }而Collections.sort的实现只有一行:
public static <T> void sort(List<T> list, Comparator<? super T> c) { list.sort(c); }也就是说,Collections.sort(list)最终调用的还是list.sort()。ArrayList重写了sort方法,直接调Arrays.sort对内部数组排序,少了一次“拷贝到新数组再写回”的开销:
@Override public void sort(Comparator<? super E> c) { final int expectedModCount = modCount; Arrays.sort((E[]) elementData, 0, size, c); modCount = expectedModCount; }注意这里连modCount都帮你修复了,原因是不希望排序过程中修改了元素却被fail-fast机制误判为并发修改。
所以实际工作里用Collections.sort还是list.sort,效果等价;区别只是list.sort更“面向对象”一点,Collections.sort更“工具类”一点。JDK 官方保留两套 API 是为了兼容老代码,新代码直接用list.sort就行。
3.2 对象排序为什么用 TimSort,基本类型为什么用双轴快排
到了Arrays.sort这层,Java 8 之后有一个重要分流:
- 基本类型数组排序走的是
DualPivotQuickSort(双轴快速排序) - 对象数组排序走的是
TimSort
TimSort 是 Tim Peter 在 Python 里率先使用的排序算法,后来被 Java 引入。它的核心思路是“利用数据中天然存在的有序片段”。算法会先扫描数组,把一段连续递增或严格递减的区间识别成一个 run,短 run 用二分插入排序扩展成长 run,然后按规则把这些 run 合并。整个过程是稳定的,最好情况可以达到 O(n)(数据本身有序),最坏情况 O(n log n),还保证稳定性。
那为什么对象排序不用快排?因为稳定性。快排是不稳定的,等值元素在排序后可能颠倒相对顺序。对于引用类型的对象,用户通常希望相同 key 的对象保持原来的先后顺序,比如先按时间排序,再按优先级排序时,相同优先级的时间顺序不能被搞乱。归并排序这一族天生稳定,能保证这个性质。
基本类型排序为什么无所谓?因为int和int之间没有“身份”区别,两个 5 谁前谁后一点不重要,所以用更快的双轴快排,极端情况下比稳定归并省内存、省拷贝。
这个知识点几乎是 Java 面试必问的一个小点,核心就一句话:对象排序要求稳定,所以走 TimSort;基本类型不要求稳定,所以走双轴快排。
3.3 Comparable 和 Comparator 怎么选
先说结论:
- Comparable是让类“自己说自己怎么比”。比如商品类实现
Comparable<Product>,定义默认的价格比较顺序。这种排序规则是类型的固有属性,写进类里是合理的。 - Comparator是“外部定义一套比较规则”。比如同一个商品类,这次按价格排,下次按销量排,再下次按上架时间排。这些规则不应该写死在商品类里,而是每次调用时单独传。
什么时候用哪个?规则几乎不变、是该对象的核心属性时用Comparable;规则多变、按场景切换时用Comparator。从设计层面讲,Comparator灵活得多,也符合开闭原则——给已有类扩展排序方式不需要修改类本身。
Java 8 之后Comparator还有一堆链式方法,用起来非常顺手:
users.sort(Comparator.comparing(User::getAge) .thenComparing(User::getName));这个组合“按年龄升序,年龄相同按姓名升序”的写法只花了一行。
3.4 从大到小排序的几种写法
先说最常用的几种:
// 方式一:reverseOrder,直接反转自然顺序 Collections.sort(list, Collections.reverseOrder()); // 方式二:Collections.reverseOrder(Comparator),反转自定义比较器 list.sort(Collections.reverseOrder(Comparator.comparing(User::getAge))); // 方式三:Comparator 自带 reversed() list.sort(Comparator.comparing(User::getAge).reversed()); // 方式四:lambda 里直接用 compare 方法 list.sort((a, b) -> Integer.compare(b.getAge(), a.getAge()));有一个坑必须提:别在 Comparator 里写(a, b) -> b - a。虽然看起来没问题,但当a是负数很大、b是正数很大的时候,减法结果会溢出,变成错误的正数,整个排序结果直接错乱。正确的 int 比较永远用Integer.compare(a, b),或者用包装类型自带的compareTo。这个坑我见过不止一次落到线上,别觉得“怎么会这么巧”,数据量一大,什么边界都会碰到。
另外说一句,Collections.reverse(list)是反转顺序,不是排序。想要“从大到小”必须是“先排序,再倒序”,或者直接用上面的比较器写法,先把排序搞定,其他操作不要混在一起。
4. 实操:完整示例与性能参考
4.1 用扑克牌把 shuffle 和 sort 串起来
用一个牌类把两个方法都用上:
class Card implements Comparable<Card> { private final String suit; // 花色 private final int rank; // 点数 Card(String suit, int rank) { this.suit = suit; this.rank = rank; } String getSuit() { return suit; } int getRank() { return rank; } @Override public int compareTo(Card o) { return Integer.compare(this.rank, o.rank); } @Override public String toString() { return suit + rank; } }生成整副牌,洗牌,发牌,然后对玩家的手牌排序:
List<Card> deck = new ArrayList<>(); for (String suit : new String[]{"♠", "♥", "♣", "♦"}) { for (int rank = 1; rank <= 13; rank++) { deck.add(new Card(suit, rank)); } } // 洗牌 Collections.shuffle(deck); // 三个人,每人 5 张 List<Card> player1 = new ArrayList<>(deck.subList(0, 5)); List<Card> player2 = new ArrayList<>(deck.subList(5, 10)); List<Card> player3 = new ArrayList<>(deck.subList(10, 15)); // 对手牌排序 Collections.sort(player1); Collections.sort(player2); Collections.sort(player3); System.out.println(player1);这里两个细节值得注意:
第一,subList返回的是原列表的视图,直接用deck.subList(0, 5)排序会连原列表对应区段一并排序,所以发给玩家前必须new ArrayList<>()拷贝成独立列表。这是subList的经典陷阱,很多线上诡异 Bug 由它引起。
第二,Card实现了Comparable才可以直接Collections.sort。如果没实现,编译阶段不会报错,但运行时会抛ClassCastException: Card cannot be cast to Comparable。这也是常见运行期错误之一。
4.2 自定义对象的复杂排序
再看一个更贴合业务场景的例子:用户对象,有年龄、姓名、注册时间三个字段,需求是先按年龄排序,年龄相同按姓名,再相同按注册时间倒序:
List<User> users = ...; users.sort(Comparator .comparing(User::getAge) .thenComparing(User::getName) .thenComparing(Comparator.comparing(User::getRegisterTime).reversed()));链式比较器可读性好,执行效率和手写多重if差不多,但代码量少一个数量级。实现起来也不复杂:Comparator内部分别比较每一项,thenComparing只有在前面所有项都相等时才继续比较下一项。
4.3 性能参考:100 万条数据跑一次多久
我在自己机器上(8 核 i7,16G 内存,JDK 11)用ArrayList<Integer>跑了简单对比。100 万个Integer:
Collections.shuffle大约 30-50msCollections.sort大约 200-300ms
数据量到 1000 万时,shuffle大概 400-600ms,sort大概 2-3s。数字仅供参考,不同机器差异很大,但量级关系是稳定的:shuffle是 O(n),sort是 O(n log n),数据翻 10 倍时,排序耗时大概翻 20 多倍,而洗牌只翻 10 倍左右。排序对数据量增长更敏感,所以数据量上去后优先考虑是不是可以提前有序、是不是走数据库ORDER BY,而不是把所有数据拉进 JVM 里硬排。
LinkedList上排序虽然没有直接变成 O(n²)(JDK 内部还是先转数组),但多了一次到数组的拷贝和写回,相同数据量下比ArrayList慢不少。工具类是为通用场景设计的,但不代表所有数据结构上都一样快。这个意识比死记源码重要。
5. 面试考点与高频问题
5.1 高频问题速查表
准备面试时,我建议直接把下面这组问题背下来:
| 问题 | 应该答到的核心点 |
|---|---|
Collections.sort()底层用的什么排序? | Java 8 之后对象数组用 TimSort,稳定、O(n log n),基本类型数组用双轴快排 |
shuffle()的算法和复杂度? | Fisher-Yates / Knuth Shuffle,O(n),原地洗牌 |
| 为什么对象排序不用快排? | 快排不稳定,对象排序要求相等元素保持原顺序 |
Collections.sort和list.sort的关系? | Collections.sort内部调list.sort,ArrayList重写后直接调Arrays.sort |
| Comparable 和 Comparator 的区别? | 一个类内实现自然排序,一个外部传比较策略;Comparator 更灵活、不侵入类 |
| 从大到小怎么排? | reverseOrder()、Comparator.reversed()、或者Integer.compare(b, a) |
| shuffle 传固定 Random 种子会怎样? | 洗牌结果可复现,适合单元测试 |
5.2 场景题的答题思路
面试官爱问“有一组对象要排序,你怎么做”。别上来就答 API,分几步讲:
先问两个问题,一是这个列表会不会并发修改,二是排序字段是不是固定的业务规则。并发问题用synchronizedList加外部锁或者CopyOnWriteArrayList;规则多变用Comparator,规则固定考虑实现Comparable。
然后说排序本身。Comparator.comparing(...).thenComparing(...)搞不定的时候,再讲手写Comparator接口。最后补一句稳定性:“如果先按性别排,再按年龄排,重复性别之间的相对顺序要保持,我会选择稳定的排序实现,而 Java 的对象排序默认就是稳定的。”
场景题重点考察的是思维链路,不是背 API。从并发、稳定性、可维护性几个维度都提到,基本就能过关。
5.3 手写排序 vs 直接调库
面试里手写冒泡排序是为了考基本功,但实际开发里永远优先用现成排序。同样是一组数据,冒泡排序 O(n²) 在 10 万数据上可能秒级完成,而 TimSort 是几十毫秒的事。更关键的是,手写排序要处理稳定性、边界、比较器异常一堆问题,调库等于把这些问题都交给 JDK 验证过的代码。能用现成的高质量算法绝不自造轮子,这是工程常识,面试时也应该把这个判断讲出来。
6. 实战踩坑记录:这些问题我全遇到过
6.1 在不可变 List 上排序直接抛异常
List.of()、Collections.emptyList()、Collections.unmodifiableList()返回的列表都是不可修改的,调用sort()或shuffle()会抛UnsupportedOperationException。
但一个容易误判的边界是Arrays.asList()。它返回的是固定大小的列表,底层是数组,不能add、不能remove,但可以set。而sort和shuffle的原地操作本质上只依赖set,所以在Arrays.asList()的结果上跑这两个方法是没问题的。
我的建议是:凡是碰到“不可变集合”这类概念,先分清是“完全不可变”还是“长度固定但元素可变”。前者碰都不能碰,后者可以安全地洗牌和排序。
6.2 Comparator 违反传递性引发的诡异报错
Comparator必须满足传递性:如果a < b且b < c,必须推出a < c。如果比较器逻辑写乱了,数据量不大时可能没事,数据量一大,TimSort 内部合并时会检测到“比较结果自相矛盾”,直接抛:
java.lang.IllegalArgumentException: Comparison method violates its general contract!有一次在业务代码里见过这种报错。起因是有人写了一个“既想按类型优先级、又想按时间、还想把某些状态放到最后”的复杂比较器,几个if分支没处理好,导致compare(a, b)和compare(b, c)组合起来和compare(a, c)矛盾。数据量小时侥幸没触发,一到线上全量数据就炸了。
排查思路:把这个比较器单独抠出来,写一个随机数据的小测试,用Collections.sort跑几万次,很快就能复现。修复原则就一条:比较器必须定义严格全序,任何两个元素比较结果必须一致且满足传递性。
6.3 固定随机种子带来的可复现性
Collections.shuffle(list, new Random(42))会让每次洗牌结果完全一样。这在生产代码里容易被当成 Bug,但在单元测试里恰恰是有用的特性:测试数据打乱后顺序固定,用例可重复。
也有反面教训。一个同事为了“让每次抽奖结果都不同”,用了new Random()但没控制种子,发现重启服务后同一批人抽奖顺序相似,以为 Random 出了问题,其实是因为new Random()的默认种子跟纳秒时间有关,连续多次调用时间间隔太短,种子相近导致序列相似。解决办法很简单:程序启动时创建一个全局Random实例,后续所有随机行为都从它取,或者用ThreadLocalRandom.current()。不要每次调用都new Random()。
6.4 多线程并发下 synchronizedList 的误用
很多人以为Collections.synchronizedList(list)之后,对列表的sort、shuffle就线程安全了。这个理解是错的。synchronizedList只是把单个方法加锁,而sort和shuffle是“读多个元素、写多个元素”的复合操作,整个操作过程中并不是原子的。两个线程同时对同一个synchronizedList调用shuffle,底层 swap 会交错执行,最终列表状态完全不可控。
正确做法是外部加锁:
List<String> list = Collections.synchronizedList(new ArrayList<>()); synchronized (list) { Collections.shuffle(list); }或者干脆把数据拷贝出来,洗完再synchronized放回去。这个坑在“调度系统多线程并发重排任务列表”这类场景里很容易出现,我印象很深。
6.5 大列表上 shuffle 的 LinkedList 陷阱
前文源码里已经说过,JDK 对LinkedList洗牌时会先转Object[],洗完再写回。这个处理保证了功能正常,但额外的 toArray + 遍历写回在数据量大时还是很明显。我实测过 100 万条数据,ArrayList的 shuffle 只要几十毫秒,LinkedList的 shuffle 接近一倍耗时,而且 GC 压力更大,因为中间多产生了一个大数组对象。
业务上如果既要频繁排序洗牌、又要频繁中间插入删除,那就得权衡一下数据结构了。纯从排序场景看,ArrayList始终是更合理的选择。这也就是为什么我建议“能用 ArrayList 就别用 LinkedList,除非你在大量做队头队尾操作”。
写在最后
shuffle和sort看起来就是两行 API,但作为 Java 工程师,把它们背后的随机算法、排序稳定性、比较器设计和数据结构匹配度搞清楚,写出来的代码质量完全不一样。我在实际项目里最直接的感受是:读懂源码之后,遇到LinkedList排序性能差、固定种子导致测试不稳定、比较器违反契约这类问题,基本不用查资料就能定位方向,排查时间从几小时缩短到十几分钟。
最后分享一个小技巧:把Collections.sort和Collections.shuffle当成你检验“是否真的理解 List 家族”的试金石——能讲清为什么Arrays.asList可以排序而List.of不行,为什么对象排序稳定而基本类型排序无所谓,为什么shuffle要检查RandomAccess,你对 Java 集合框架的理解就比绝大多数人扎实了。这套基本功,比记住多少个 API 都值钱。