
排序这件事我写过不少代码也掉进过不少坑。Java 里自带的那几个排序方法看起来也就是一行Collections.sort()或者Arrays.sort()但真到生产环境里总会碰到“为什么结果不对”“为什么这段代码一调就慢”“为什么线上偶发 NPE”这类问题。这篇文章就是把 Java 排序从 API 用法、内核算法、Comparator 规则、并发排序“坑”到排查思路完整梳理一遍。适合谁看刚接触 Java 集合和数组排序的新手能从这里把最基本的 API 和核心思想弄明白写过两三年业务代码但一直没深究排序原理的同学也能在 TimSort 稳定性、Stream 排序性能、Comparator 规则这些方面找到补漏的点。目标是让你读完以后不只是会用sort()而是遇到任何排序需求都能心里有数写完就能知道自己到底在做一件什么样的事。1. 先回答最基础的问题Java 里到底有几种“排序”可用1.1 见到的大致可以分这么几类日常开发接触的 Java 排序表面上确实就那几样Arrays.sort()针对数组分原始类型数组和对象数组两种重载。Collections.sort()针对List本质是用List.sort()然后调用内部存储的排序实现。List.sort()Java 8 以后集合自身的排序入口。Stream.sorted()不修改原集合而是返回一个新的排好序的流结果。这几类 API 对应的是两套核心算法对象排序走 TimSort原始类型数组排序走双轴快速排序Dual-Pivot Quicksort。对Java 不是在所有场景都用一个排序算法而是会根据数据类型和规模切换策略。这也就是为什么Arrays.sort(int[])和Arrays.sort(Object[])的性能表现在大数据量下会完全不同。我先亮出结论后面慢慢展开如果你的目标是对象数组、ListJava 默认给你的是稳定排序 TimSort时间复杂度是 O(n log n)最好能达到 O(n)也就是对基本有序的数据几乎就是扫一遍如果你的目标是int[]、long[]、double[]这类原始类型数组默认给你的是不稳定排序双轴快排平均 O(n log n)。搞清楚这个差异的意义在于有些场景你必须自己保证“排序前后相同元素的位置不变”那就不能用快排去实现或者你要能接受排序结果的顺序变化。1.2 真的只有这么多吗内核里还有不少文章往 JDK 源码里扒一层你会发现Arrays.sort对Object[]实际上会做一次判断如果数组规模非常小阈值大致是 32 或 47不同 JDK 版本有浮动它根本不会走完整的 TimSort而是用一个插入排序Java 7 以后准确说是ComparisonTimSort的countRunAndMakeAscending先找天然有序段。这个细节特别容易被忽略但真实影响是小数组排序时你观察到的性能曲线并不是普通快排还是归并那种预期而是插入排序在起作用。插入排序对小数组是杀手锏因为它没有额外空间开销比较次数少缓存友好度极高。另外还有两类算法容易被忽略Arrays.parallelSort()Java 8 加入的并行排序对于大数组会拆成多个子任务用 Fork/Join 并行归并。ComparableTimSort和TimSort内部的那套“合并”路径这也是很多排序性能问题的根源。我个人经验是大部分业务系统真正能感知到排序性能瓶颈的不是算法本身而是比较器Comparator里写了什么。如果你在比较器里做数据库查询、网络调用或者复杂的字符串格式化就算把 TimSort 换成其他什么高级算法也没救。排序的比较器会被反复调用非常多遍O(n log n) 的调用次数是个很惊人的数字。2. 为什么我劝你一定要懂 TimSort 和 快排2.1 双轴快速排序做了什么好多人以为Arrays.sort(int[])里的快排就是教科书那版“找基准、左右分区、递归”。实际上 JDK 7 开始用的是双轴快速排序也就是一次分区设两个基准值把数组分成三段小于低位基准、介于两个基准之间、大于高位基准。这样做的好处是分区粒度更细一次分区可以同时确定两个基准的最终位置相对普通单轴快排在很多数据分布下能减少递归层数和元素交换次数。不过别把它想得太神秘。双轴快排的本质仍然是快排那套思路选基准、划分区域、递归排序。真正需要你记住的点是快排是不稳定的。也就是说两个值相同的元素排序前后的相对位置可能会发生变化。为什么不稳定因为分区交换操作会把元素跨区域搬动比较值相等的那两个元素原本挨在一起分区之后可能被放入同一侧但彼此之间插进了其他元素。这个变位是不可控的。那为什么原始类型排序能接受不稳定因为int的 1 和另一个int的 1 根本没区别不需要保持位置信息。但如果你排序的是对象对象里可能有其他字段两个id相同的对象实际上并不是完全等价的这种场景就要求稳定排序。这也是 Java 对Object[]默认走 TimSort 的根本原因。2.2 TimSort理论上是为了真实世界数据而生TimSort 最早是 Tim Peters 在 2002 年为 Python 实现的排序算法后来 Java SE 7 也拿它作为对象排序的默认算法。它结合了归并排序和插入排序的思想核心擅长处理“部分有序”的真实数据。简单描述它的流程从左到右遍历数组找出一个个天然存在的有序段run。如果某个 run 太短就强行用二分插入排序把它扩充到最小长度一般取 16 或者 32JDK 里是MIN_MERGE 32。然后用一个栈记录 run 的起始位置和长度按一定规则合并相邻的 run。整个数组最终合并成一个更大的有序数组所以说它本质上是归并排序的优化版。它和普通归并排序最大的不同是归并排序认为数据是无序的永远从中间对半切TimSort 会先扫描一遍已有的有序段充分利用数据中本来就存在的顺序。比如你排序[1, 2, 3, 4, 9, 8, 7]TimSort 会识别出[1, 2, 3, 4, 9]是天然升序[8, 7]是需要单独处理的降序段然后做一次很高效的归并而不是傻傻地把数组切成两半再各自排序。它的最坏时间复杂度仍然是 O(n log n)但最好可以达到 O(n)。对业务数据这种“基本上有点顺序但又不完全对”的情况实际表现往往比普通快排更稳。而且它是稳定排序对象排序默认选它是非常合理的设计决策。2.3 自己的数据结构排序为什么会不一样我们谈谈稳定性你可能遇到过这种场景一个ListOrder订单里有个字段是“状态”你按状态排序希望相同状态的订单保持原来的时间顺序。前提是之前这个列表已经按时间排过序了。这时如果你用Arrays.sort或者Stream.sorted结果能不能保住“按时间排过的顺序”答案取决于你是否用了稳定排序。Java 里Collections.sort、List.sort、Stream.sorted对对象排序都是稳定排序因为它们内部都是稳定的 TimSort而Arrays.sort只对Object[]稳定对原始类型数组则不稳定。所以如果使用同一个比较器对象排序结果里“相同状态仍按时间先后”是成立的原始类型数组里不保证。这个稳定性在业务代码里有非常实际的应用场景。最常见的“按多字段排序”就是依赖稳定性来实现的先按次要字段排序比如时间。再按主要字段排序比如状态。只要第二步用的是稳定排序时间顺序就会被保留在相同状态组内部。这方法比写一个复杂的多条件 Comparator 省事得多也更容易理解。前提是你必须确认第二步排序是稳定的。用Stream.sorted(Comparator.comparing(Order::getStatus))没问题但如果你用某些数据库的ORDER BY实现或者用并行流里的某种非稳定算法结果可能就不对了。3. 手把手排几次基础用法与常见场景3.1 数组排序先来最基本的int[] numbers {5, 2, 9, 1, 7}; Arrays.sort(numbers); // numbers 现在是 {1, 2, 5, 7, 9}对象数组String[] names {banana, apple, pear}; Arrays.sort(names); // 按 String 的 compareTo 自然顺序排序结果是 {apple, banana, pear}如果你想控制对象数组的排序规则要传入ComparatorArrays.sort(names, (a, b) - b.compareTo(a)); // 按字典序降序这里有个注意点我见过不少人在Arrays.sort里想当然地写Collections.reverseOrder()是可以的但别忘记Arrays.sort对原始类型数组的重载是没法传比较器的。你只能先转成包装类Integer[]或者手动排序。这就会带来装箱成本后面性能部分我会细说。3.2 集合排序最经典的方式ListString list new ArrayList(List.of(b, a, c)); Collections.sort(list);Java 8 以后推荐直接用List.sortlist.sort(null); // 传 null 表示使用元素自身的 Comparable 自然顺序 list.sort(String::compareTo); // 等价于自然排序要是想按自定义规则排序一整个 List就用 Comparatorusers.sort(Comparator.comparing(User::getAge));这些 API 都是原地排序意思是它会直接修改你传入的List不返回新集合。你如果还想着用返回值接一下ListInteger sorted list.sort(...); // 编译不通过不用怀疑sort方法的返回类型是void。这个和 Scala 或者其他语言不一样习惯了就好。如果不希望原数据被修改就得复制一份再排序或者用 Stream。3.3 使用流Stream排序流式排序最大的优势是不修改原集合ListString sortedList list.stream() .sorted() .toList(); // Java 16 或 collect(Collectors.toList())自定义规则ListUser sortedUsers users.stream() .sorted(Comparator.comparing(User::getAge).reversed()) .collect(Collectors.toList());还有一个容易被忽略的点Stream.sorted()是必须有状态才能实现的中间操作它要把所有元素先收集到一个数组里排序后再吐出来。所以流排序对无限流不友好你不可能给一个Stream.iterate(0, i - i1)无限流套一个无限循环层的sorted()然后期望它输出排序结果这是逻辑上做不到的。实际使用中如果对很大的数据量用流排序内存开销也挺明显因为有额外的数组和减少的分组阶段。不过这些开销通常比不上先收集到 List 再 sort 的写法因为 JDK 在流排序内部有优化对象数组排序的最低内存开销其实已经控制得不错。4. 创建自己的排序规则Comparator 和 Comparable 详解4.1 选择 Comparable 还是 Comparator这俩概念看起来简单但还是很多人搞混。Comparable是“类自己和自己比较”它定义在类内部一个类只能有一个自然排序逻辑。比如String实现了ComparableString所以字符串可以直接排序。Comparator是“外部定义一个比较器去比较两个对象”可以定义无数个用于不同的排序场景。比如按名字排序、按年龄排序、按创建时间排序可以写三个独立 Comparator。实际经验除非这个类的“自然顺序”非常明显且稳定否则优先用 Comparator。比如User类如果业务里大多数列表都按id排序那让User implements ComparableUser按 id 排还算合理但如果你今天按 id、明天按年龄、后天按注册时间那就别把 Comparable 绑定死了以后想改都很麻烦改了之后其他所有依赖自然排序的地方全受影响这种隐性耦合很难排查。4.2 常见写法汇总Java 8 提供了强大的 Comparator 静态方法这是现代 Java 排序的主流写法// 1. 最简单按 age 升序 ComparatorUser byAge Comparator.comparing(User::getAge); // 2. 降序 ComparatorUser byAgeDesc Comparator.comparing(User::getAge).reversed(); // 3. 按字符串字段排序 ComparatorUser byName Comparator.comparing(User::getName); // 4. 要求忽略大小写 ComparatorUser byNameIgnoreCase Comparator.comparing(User::getName, String.CASE_INSENSITIVE_ORDER); // 5. 按 int 型字段排序避免自动装箱 ComparatorUser byAgeInt Comparator.comparingInt(User::getAge);这里我特别想强调第五个点。很多人用Comparator.comparing(User::getAge)时没意识到getAge返回int但comparing要求返回的是Comparable所以 JDK 会自动把int装箱成Integer。每个比较动作都要多创建 2 个Integer对象参与比较的两个元素各一个。排序 n 个元素比较次数可能是n * log(n)级别装箱产生的临时对象数相当可观。所以只要字段是原始类型int、long、double就尽量用Comparator.comparingIntComparator.comparingLongComparator.comparingDouble这类方法直接操作原始类型不装箱性能和内存都好很多。这对涉及大数据量排序时差异明显我在一次对几十万条数据排序时对比过用错方法组合装箱版本大概慢 20%-30%而且 GC 压力明显增加。4.3 多个条件排序的实现多条件排序有两种主流实现。第一种是链式调用users.sort(Comparator .comparing(User::getStatus) .thenComparing(User::getCreateTime) .thenComparing(User::getId));这个语义非常清楚先按状态排状态相同的按创建时间排再相同的按 id 排。第二种是利用稳定排序的“倒序法”users.sort(Comparator.comparing(User::getId)); users.sort(Comparator.comparing(User::getCreateTime)); users.sort(Comparator.comparing(User::getStatus));因为List.sort是稳定排序所以最后一个排序是主排序前面排的是次要字段的优先级。这个方法写起来比较啰嗦但它背后反映的稳定性思路值得你理解尤其是你面对一批老代码没法把所有比较条件一次性组装成链式 Comparator 的时候。写多条件排序时有一个坑必须提醒复用同一个 Comparator 可能导致比较逻辑自我矛盾。比如ComparatorUser c Comparator .comparing(User::getStatus) .thenComparing(User::getCreateTime); users.sort(c); ListUser users2 ...; users2.sort(c.reversed());看起来 c 反转后应该所有条件都反转但.reversed()确实会把整个比较器反转包括所有thenComparing条件。这没问题。真正有问题的是你把一个thenComparing链拆了以后又手动拼装很容易漏掉某一层。我的建议是多条件排序就老老实实用链式写法别自己拼。比较器还有一致性规则要遵守对于任意两个元素 a、bcompare(a,b)和compare(b,a)必须符号相反才行。如果反了排序算法内部会乱掉轻则个别顺序不对重则抛IllegalArgumentException: Comparison method violates its general contract!。这个异常我后面在常见问题里详细讲。5. 排序的性能提升、常见杂症以及解决方案5.1 原始类型排序 vs 包装类型装箱一个非常典型又非常隐蔽的性能问题就是你对ListInteger排序或者对一个Integer[]排序和int[]排序之间的差距。为了直观我们只说结论int[]排序内存连续排序过程完全基于原始值比较快。Integer[]排序数组里存的是引用每个 Integer 是一个独立对象排序过程中比较的是对象里的 value而且数组的元素访问附带引用跳转CPU 缓存命中率大幅降低。ListInteger排序底层是Integer[]和上面基本一样但如果 List 是 LinkedList那Collections.sort会先把链表转成数组再排序最后再装回去这里又多一笔开销。实际测试中int[]排序 100 万随机整数用时可能只有Integer[]排序的 1/3 左右。所以如果你的场景非常在意性能尽量用原始类型数组。业务代码里倒不需要过度优化但如果你在做数据处理、批量任务、中间件开发这个优化点就非常值钱。还有一个常见做法Arrays.sort对原始类型和包装类型的排序结果在某些数据分布下表现差距也大。因为原始类型走的是双轴快排包装类型走的是 TimSort本身就是不同的算法。快排对特殊构造的恶意数据可能退化到 O(n^2)比如大量重复元素时早期 JDK 版本的快排表现就不太好后来 JDK 加入了针对重复元素的优化比如三向切分思路但 TimSort 在重复元素情况下通常更稳。5.2 多线程情况下无脑排序的意外这里说的“多线程排序”不是让你自己 new 多个线程去排序而是指下面两种情况情况一并发修改。你用一个线程在ArrayList里 sort另一个线程同时在往这个ArrayList里 add 或 remove。由于List.sort内部会多次操作底层数组过程中如果数组被并发修改极可能出现ArrayIndexOutOfBoundsException或者更隐蔽的元素错乱、排序结果不完整、甚至死循环个别版本。本质原因是没有加锁底层数组被多个线程同时读写。解决方法是ListUser snapshot; synchronized (list) { snapshot new ArrayList(list); } snapshot.sort(...);或者直接用CopyOnWriteArrayList自己快照后排序再整体替换。一句话排序必须先持有数据的一致视图再动手排。情况二并行流排序。list.parallelStream().sorted(...)会使用 Fork/Join 公共线程池去并行执行部分任务。并行流排序内部并不是所有阶段都并行它的排序实现最终还是调用了稳定的Arrays.sort或者说沿用串行排序路径并行体现在对于多个切分块做并行归并。这本来没什么问题但如果你在比较器里访问了线程不安全的共享状态比如一个 HashMap并发执行时就会引入脏数据。更麻烦的是公共线程池是全局的如果业务里很多地方用 parallelStream 排序会影响其他不相关的任务。我的建议很直接大多数业务场景不要用并行流排序。数据量不够大时并行没有任何优势线程池切换、任务切分、归并合并的开销反而更大数据量大时你需要的是更明确的优化手段而不是让公共线程池背锅。5.3 排序带来的不必要的 GC 压力排序生成的临时对象主要来自这几处比较器自动装箱。前面提到过Comparator.comparing(User::getAge)会装箱每比较一次创 2 个 Integer。比较器中做字符串拼接或其他对象创建。users.sort(Comparator.comparing(u - u.getFirstName() u.getLastName()));这段代码看着没问题但拼接会为每次比较创建新字符串。一百万个对象排序比较次数可能上千万临时字符串对象会大量产生GC 压力立刻上来了。你以为是排序本身慢其实是比较器内部不断生产垃圾对象。优化方案是给User类加一个预计算字段或者用Comparator.comparing(User::getFullName)前提是getFullName提前缓存好。TimSort 合并时需要的临时数组。TimSort 会申请一个长度不超过原数组一半的临时数组实际是 minRun 相关对象引用数组的开销相对可控。但如果你的数组巨大比如排序一个 1000 万对象的 List临时数组占的内存就是几十 MB。parallelSort会更夸张因为多个子任务都要自己的临时数组。要减少内存压力可以考虑能不排序就不排序。比如只是取最大/最小 N 个用PriorityQueue维护一个小顶堆比全量排序省太多。使用Arrays.sort而不是先toArray再Collections.sort减少一次数组拷贝。在流式大数据场景里尽量让sorted()放在 filter 之后而不是之前。先过滤再排序排序的数据量小很多差别非常明显。6. 排查和体验实践几个真实问题的复盘和解决6.1 第一类随机的 NPE 冒出来有回跑一个批处理日志逻辑是对一批用户按“分组编码”排序。第一次跑没事第二次跑突然就抛NullPointerException了。查下来发现有个用户的“分组编码”字段是 null。而这个 null 只在特定数据出现时才会触发所以看起来是“偶发”问题。原因分析Comparator.comparing(User::getGroupCode)生成的比较器在内部调用Comparators的NaturalOrderComparator时会调用c1.compareTo(c2)。如果c1是 null直接 NPE如果c2是 null要看具体的 compareTo 实现String的 compareTo 也会 NPE。解决方法方法一把 null 当成一个固定值。常见做法是Comparator.comparing(User::getGroupCode, Comparator.nullsFirst(String::compareTo))null 排在最前面或者nullsLast排最后。方法二排序前先把 null 统一替换成空字符串或者特殊占位符。方法三数据源头就不允许这个字段为 null加校验。经验教训写排序之前先想清楚所有字段取值的边界。空字符串也是一种“有效值”它和 null 是两回事比较行为完全不一样。有意思的是空字符串排序位置很靠前因为空字符串的字典序比大部分可见字符都小。如果业务上希望空字符串排最后你得写自定义比较器ComparatorString emptyLast Comparator .comparing((String s) - s null || s.isEmpty()) .thenComparing(Comparator.nullsLast(String::compareTo));这个比较器的逻辑是先比较“是否为空”false 排在 true 前面第二层比较再按字符串内容排。6.2 第二类结果看起来跟预期不符有次一个同事说“我明明按创建时间倒序排了结果还是有几条顺序不对。”查代码发现他写的是list.stream().sorted((a, b) - b.getCreateTime().compareTo(a.getCreateTime()));这个写法逻辑上没问题但有时候会出问题如果getCreateTime()返回的是java.util.Date它实现 Comparable 没问题如果返回的是字符串形式的2023-01-02 12:00:00字符串比较按字典序对所有格式固定的时间字符串其实是能排对的。可如果日期格式里月、日没有补零比如2023-1-2 12:00:00字典序结果和真实时间顺序就完全脱节了。这种问题最隐蔽因为大多数日期是正确的只有个位数月份和日期的数据排错。另一个常见坑是Comparator.comparing(User::getAge).reversed()这个链式“只有第一个条件被反转了”。你写list.sort(Comparator .comparing(User::getStatus) .thenComparing(User::getAge) .reversed());这里的 reversed 是把整个 Comparator 链反转还是只反转最后一个条件答案是它把整个链反转。.list.sort(Comparator .comparing(User::getStatus) .thenComparing(User::getAge) .reversed());语义等价于ComparatorUser c Comparator.comparing(User::getStatus) .thenComparing(User::getAge); list.sort(c.reversed());也就是状态和年龄两个条件全部反转为降序。如果你想状态按升序年龄按降序正确写法是list.sort(Comparator .comparing(User::getStatus) .thenComparing(Comparator.comparing(User::getAge).reversed()));这个细节我问过不少人回答错的大概占一半。主要问题是你得明确.reversed()到底修饰了哪个 Comparator。还有IllegalArgumentException: Comparison method violates its general contract!这个经典问题。它出现的原因是 Comparator 不一致也就是存在a b同时b a或者compare(a,b) 0且compare(b,a) 0但二者不相等让 TimSort 在归并时检测到了反序。常见导致这种情况的原因比较器里用了随机数比如Math.random()这简直是灾难。比较器里依赖了哈希值或可变状态状态一变结果就变。用减号比较 int 导致溢出比如return a - b;当 a 是Integer.MAX_VALUE、b 是负数时会溢出结果符号反了。修复方式用Integer.compare(a, b)或者Comparator.comparingInt。这一点对新手尤其重要我见过无数return o1.getX() - o2.getX()的写法必须第一时间纠正。6.3 前车之鉴与可查思路遇到排序问题我总结了一套比较高效的排查顺序先确认数据“准不准”排序前打印前几十条元素看看字段真实值长什么样。很多时候问题根本不在于排序写法而是数据本身有脏数据比如 null、空串、前后空格、全角半角差异。再确认比较器一致性把 Comparator 抽出来单独写个单元测试随机生成大量数据断言compare(a,b)和compare(b,a)符号相反且compare(a,a) 0。这是最快速的“比较器合法性检查”。确认目标排序是升序还是降序自从我习惯了英文思维方向这东西已经不是大问题但团队里新手经常在.reversed()上栽坑。为了避免这种问题我通常把 Comparator 的命名写得很具体BY_AGE_ASC、BY_CREATED_AT_DESC。命名清晰比什么都强。确认数据量级和性能特征如果排序比较慢不要急着换算法先用jvisualvm或async-profiler看看排序期间 CPU 花在哪里。大多数情况都是比较器方法执行了太多额外操作。7. 排序业务的通用原则与最后几条小技巧7.1 建议的排序开发流程我自己写排序相关代码的一套流程按这个走基本不会有大问题第一步明确定义排序键和比较规则。先不要急着写代码用自然语言把规则说清楚。比如“按状态升序状态相同的按创建时间降序再相同的按 id 升序null 的状态排最后”。描述清楚之后再把它翻译成 Comparator被误解的概率会小很多。第二步选用正确的排序入口。数组用Arrays.sortList 用list.sort不改原数据用stream.sorted。需要稳定排序就明确走稳定路径。第三步写一个合法性单元测试。不管看起来多简单都值得跑一遍。尤其是带 nullable 字段的自定义比较器用包含 null、空串、重复值、极值的集合去测。第四步如果排序是性能热点做基准测试。用JMH跑一遍比较不同写法的吞吐差异。通常比较器自动装箱、比较器内做冗余计算这两个问题是立竿见影的。第五步线上监控。日志里偶尔会有异常堆栈这往往是脏数据试探到了比较器的边界。遇到一次就补一个测试用例把脏数据固化下来。7.2 容易被忽略的小细节一些小细节很零碎但我觉得都值得记下来Collections.sort和list.sort在 LinkedList 上的表现很不一样。Collections.sort会把链表转成数组、排序后再写回链表的 next 指针list.sort在 LinkedList 里实际上也是类似的转数组处理不是链表原地归并。所以不要因为用的是链表就觉得排序不会产生数组拷贝。Arrays.sort对对象数组的 TimSort 需要额外数组对原始类型的双轴快排基本不要额外内存。如果你对内存极端敏感优先用原始类型数组。Stream.sorted()不会修改原始集合但它会生成新的支持顺序的流实现。用Collectors.toUnmodifiableList()或者toList()时注意这不会影响排序本身只是结果不可变。比较器里避免使用 lambda 捕获可变外部变量。比如int x 1; list.sort((a, b) - a - x);lambda 捕获了一个“非 effectively final”的变量这在 Java 里编译不过但如果是数组引用或者类字段就能编译过。如果另一个线程修改了那个字段排序结果就可能会变而且这种问题极难复现。碰到大 List 排序可以用Collections.sort(list, comparator)和list.sort(comparator)之间选后者少一层静态方法调用。当然这个差异微乎其微但养成习惯也没坏处。数据库里的ORDER BY和内存排序的结果在某些边界条件下表现不同比如 null 默认位置、中文字符的排序规则、大小写处理。如果你把数据库查出来的数据再在 Java 里排一次记得统一两端规则否则很容易发现“数据库和内存排序结果不一致”的诡异问题。对超大对象集合排序时可以考虑在对象上冗余一个“排序缓存字段”。比如一个订单要根据“订单状态优先级创建时间”排序你与其每次比较时算一遍状态映射不如在对象构造或更新时算好一个sortKey排序时直接比较sortKey。这个思路在很多复杂业务场景里非常实用把 O(n log n) 次复杂计算降为 O(n) 次预计算。排序这件事看起来是 JDK 帮你已经做完了的东西但真正在生产环境里细节决定的是稳定性和性能。我个人的体会是先搞清楚自己用到的排序是稳定还是不稳定然后再把比较器当做一个“被调用 N 次并且必须保证前后一致”的纯函数来看待最后才是考虑算法选择和性能优化。把这几层的顺序弄对绝大多数排序问题都能迎刃而解。