1. sort()这个名字有多“轻”,坑就有多深:写在前面的实话
先交代一个背景。这些年在项目里给各种集合排过序,从最开始写业务代码时顺手调一个Collections.sort(),到后来被线上反馈“排序结果不对”“性能很慢”,再到被大佬指着代码说“你连sort()是稳定排序都不清楚,就敢直接用在分页场景”……我才慢慢意识到,sort()在Java里看起来只是一个再普通不过的方法名,但它背后牵扯到的底层算法选型、对象比较规则、并发安全、甚至数据库排序规则的一致性,能直接影响一个系统的正确性和吞吐量。
网上关于sort()的教程,绝大多数停留在“怎么用”的层面:Arrays.sort()怎么传参、Collections.sort()怎么写Comparator、lambda怎么简化。但很多实战问题恰恰出在“为什么这样用就对了、那样用就错了”的层面。这篇内容不打算从头讲Java语法,而是想以一个实际做过排序功能、踩过排序坑、看过排序源码的人的角度,把sort()的用法、原理、性能差异、常见陷阱和排查思路完整梳理一遍。
适合谁看?刚开始写Java、对排序只停留在“能跑就行”的人;已经写了几年业务代码、被排序相关Bug折磨过的开发;以及需要在项目中做自定义排序、又要保证性能和稳定性的技术负责人。这篇内容覆盖从基础API到源码原理,从单线程到并发场景,尽量让不同基础的读者都能拿到对自己有用的东西。
需要先说清楚的是,这篇内容里的核心代码示例基于Java 8及以后版本,部分内容涉及Java 11、17的更新行为,如果你还在用Java 6或更老版本,个别细节会有出入,我会在对应位置标注。
2. Arrays.sort和Collections.sort:同一张脸,两套“性格”
先说一个很多人忽略的事实:Arrays.sort()和Collections.sort()看起来是两套API,但Collections.sort()内部其实是对List转成数组后调用了Arrays.sort()。所以,真正决定排序行为的底层逻辑,全都集中在Arrays.sort()的重载方法里。
2.1 基本类型的排序:快排的“改良版”
对int[]、double[]、char[]这类基本类型数组,Java调用的是DualPivotQuicksort——双轴快排。这个名字听起来唬人,核心思路可以理解为:普通快排每次选一个基准值,把数组分成两半;双轴快排每次选两个基准值,把数组分成三段,左边都比基准1小,中间介于两个基准之间,右边都比基准2大。分段越多,每次递归处理的区间越小,整体比较次数也随之减少,所以效率比单轴快排更高。
这个算法的时间复杂度平均是O(n log n),最坏情况下是O(n²)。Java对这个最坏情况做了防御——如果递归深度过深,会切换成堆排序兜底,保证任何输入都不会退化到平方级复杂度。这一点是很务实的处理,不是所有语言的排序实现都这么保守。
2.2 对象类型的排序:TimSort的“归并+二分插入”混血
对Object[]、Integer[]、String[]这类引用类型数组,Arrays.sort()用的是ComparableTimSort,也就是大名鼎鼎的TimSort算法。这个算法最早是Tim Peters为Python写的排序算法,后来被Java移植过来。
TimSort的思路非常有意思:它先扫描数组中已经天然有序的子序列(称为run),把这些run记录下来,然后用归并的方式把run合并。这个过程有两个核心优势:
- 利用了数据原有的有序性。如果数组本身就接近有序,TimSort能跑出接近O(n)的复杂度,而不是傻傻地重新比较一遍。
- 归并排序保证稳定性,相等元素的相对位置不会改变。这一点在按多个字段排序时至关重要。
分治法的另一个好处是空间消耗上有取舍:如果数组规模小,直接走二分插入排序;如果规模大了,会申请一个临时数组做归并,空间复杂度是O(n)。用空间换稳定性和最坏情况时间复杂度,Trade-off很清晰。
2.3 为什么基本类型不能用TimSort
这是一个经典面试题,也是理解设计思路的一扇窗:为什么int[]不用归并排序?因为基本类型没有“相等元素的相对顺序”这个概念——两个值为5的int,哪个在前哪个在后,对结果毫无影响。既然不需要稳定性,那就没必要付出额外空间的代价,快排更合适。
对象就不同了。两个User对象的age字段相同,但一个是张三、一个是李四,在只按年龄排序时,如果希望张三永远排在李四前面(比如按某个ID字段的先后顺序追加排序),就必须用稳定排序。所以Java刻意对基本类型和引用类型采用了两套算法。
2.4 小规模数组的“暗门”
还有一个细节值得注意:Arrays.sort()在数组长度小于某个阈值时,会直接改用插入排序。Java源码里的阈值是47。为什么?因为递归调用也是有开销的——方法栈的入栈出栈、数组切分的计算、循环中的边界判断,这些成本在小数组上可能比插入排序本身还高。插入排序在数据量小的时候,常数因子极低,反而更划算。
如果你在写代码时遇到“数组只有5个元素,但性能瓶颈竟然出现在sort上”,基本可以排除排序算法本身的效率问题,去查排序被调用的次数是不是失控了。
3. Comparator自定义排序:从Comparator.comparing到thenComparing的完整套路
Arrays.sort()处理的是“对象自己实现了Comparable”的情况,也就是自然排序。但真实业务里,几乎没有哪个对象天生就能满足所有排序需求——同一个User列表,今天要按年龄排,明天要按注册时间排,后天要按姓名拼音排。这时候就需要Comparator接力。
3.1 Comparator.comparing的演进逻辑
Java 8之前,自定义排序是一件很啰嗦的事:
Collections.sort(userList, new Comparator<User>() { @Override public int compare(User u1, User u2) { return u1.getAge().compareTo(u2.getAge()); } });Java 8之后,一行搞定:
userList.sort(Comparator.comparing(User::getAge));这行代码背后的逻辑是:comparing方法接收一个Function函数——从User中提取一个Comparable字段,然后返回一个Comparator,用提取出来的字段值做比较。如果提取出来的字段恰好还需要加工(比如把字符串转成日期再比较),可以加一个第二个参数:
userList.sort(Comparator.comparing(u -> parseDate(u.getCreateTime())));3.2 多字段排序的标准写法
按年龄排,年龄相同再按ID排,这是最高频的需求。正确写法是:
userList.sort(Comparator.comparing(User::getAge).thenComparing(User::getId));注意thenComparing是挂在Comparator上的,而不是在comparing之后另起一行。很多新手会写成:
Comparator.comparing(User::getAge); Comparator.comparing(User::getId);这两行代码没有任何联系,前一个Comparator直接被丢弃了,最后list只会按ID排序。这类Bug的隐蔽性在于:代码不会报错,逻辑也“看似写了两遍”,但结果完全不符合预期。
3.3 倒序的三种写法,别搞混
倒序有三种常见写法,效果相同但使用场景有差异:
// 写法一:字段维度倒序 userList.sort(Comparator.comparing(User::getAge).reversed()); // 写法二:提取字段后调用reversed userList.sort(Comparator.comparing(User::getAge, Comparator.reverseOrder())); // 写法三:比较器维度倒序 userList.sort((u1, u2) -> u2.getAge().compareTo(u1.getAge()));写法一和写法二基本等价,区别在于:如果getAge()返回的是一个自己定义的、没有实现Comparable的类型,写法一直接编译报错,写法二可以通过传入一个自定义的Comparator来完成逆序。写法三是最底层的手动实现,适合需要做特殊处理的情况(比如字段为null时的容错)。日常开发优先用写法一,简洁可读;遇到非Comparable字段再考虑写法二。
3.4 null值处理:排序里最容易翻车的地方
真实业务数据里,字段为null太常见了。如果getAge()返回的是Integer但某个对象是null,直接比较会抛出NullPointerException。解决方案是使用Comparator.nullsFirst()或nullsLast():
userList.sort(Comparator.comparing(User::getAge, Comparator.nullsLast(Comparator.naturalOrder())));这个写法理解起来稍微绕一点:comparing的第二个参数接收一个“处理null的Comparator”。naturalOrder()表示常规升序,nullsLast()表示把null值放在最后。如果想降序且null排最后:
userList.sort(Comparator.comparing(User::getAge, Comparator.nullsLast(Comparator.reverseOrder())));实测下来,这个问题在导入Excel、对接第三方接口等场景中高频出现。对方返回的数据不可能保证每个字段都非空,没提前处理排序逻辑,线上直接挂掉的情况我见过不止一次。
3.5 Comparator内部细节:每次比较都“提取字段”吗
很多人在性能调优时会问一个问题:Comparator里getAge()到底会被调用多少次?答案是:在Java 8的默认实现中,compare方法执行时会先调用u1.getAge()和u2.getAge(),也就是每次比较都会提取两次字段。对于简单的getter来说开销极小,可以忽略;但如果提取逻辑很重(比如解析JSON字符串、访问远程接口),就值得考虑先预处理了。
一个典型的优化思路:先把原始列表转换为一个包含排序字段的新列表,对字段提取结果做缓存,再排序。这样提取操作只执行一次。
4. 排序稳定性、性能与分页:那些只可意会不可言传的坑
4.1 稳定排序到底意味着什么
稳定排序的定义是:如果两个元素在原始序列中相对顺序是A在B前,且排序字段相等,排序后A仍然在B前。这个特性看似数学味很浓,但在业务里直接影响结果正确性。
例子:一个商品列表,原始顺序是“按上架时间倒序”,现在要按价格升序排列。如果排序不稳定,价格相同的商品顺序可能被打乱,用户看到的价格相同但商品顺序和上架时间完全无关,这就是运营事故。
用TimSort的另一个受益场景是数据库分页配合内存排序。数据库排序只能保证单字段有序,如果查询结果集返回后被Java再次排序,稳定性能保证“前一页”和“后一页”的边界不重复不遗漏。
4.2 大数据量排序:别在内存里sort一个100万级的List
这是很多架构师会提醒的一点:Collections.sort()虽然性能不错,但它的本质是整表加载到JVM内存排序。100万条数据,每条占用几百字节,排序本身的时间不是主要矛盾,内存占用才是。
一个更务实的方案是:能用数据库ORDER BY解决的问题,就别带回来排序;必须带回来排序的,优先在SQL里完成排序和分页,Java侧只负责展示。如果数据量真的到了千万级,目标就不是优化sort(),而是重新设计存储和查询方案了。
4.3 并行排序:parallelSort和sort的性能对比
Java 8为Arrays.sort()增加了一个parallelSort()方法。它会利用ForkJoinPool.commonPool()把数组拆分成子任务并行排序,最后再合并。实测下来,在数组规模几十万以下时,并行版本反而更慢——因为任务拆分、线程调度、结果合并的开销大于并行带来的收益。
我的经验阈值是:数组元素在100万以下,直接用Arrays.sort();超过100万,且运行的机器是多核CPU,parallelSort()才有明显优势。另外注意一点:parallelSort()的并行度受ForkJoinPool的并行度控制,如果应用里已经大量使用parallelStream(),公共池可能被占用,此时parallelSort()的实际并行度会受影响。
4.4 排序的比较次数和运行时间不完全成正比
如果你曾经好奇“为什么同样是100万条数据,有时候排序快有时候慢”,除了硬件波动,更关键的原因是数据分布。TimSort对接近有序的数据有天然优势,如果数据本身就已经基本排好序,它的复杂度接近O(n);如果数据完全逆序,它会退化成较差的场景。所以,不要拿一次排序的耗时来评估系统的排序能力,多测几组不同分布的数据才有参考意义。
5. Collection.sort废弃?别被网上说法带偏
搜sort()相关内容时,常看到“Java 8之后Collections.sort()被废弃”之类的说法,这是不准确的。Collections.sort()在Java 8及之后版本中仍然存在,并没有被标记@Deprecated。它被误解的原因可能是:List接口在Java 8增加了默认方法List.sort(),官方推荐优先使用list.sort()而不是Collections.sort(list)。但这两个方法做的事情完全一样,最终都是对内部数组调用Arrays.sort()。
实际开发中,我自己通常用list.sort(comparator),因为语义更直接、代码更短。Collections.sort()在阅读老项目代码时还会频繁遇到,知道它和list.sort()等价就够了。
5.1 关于sort()线程安全的一个大误解
很多资料说“sort()不是线程安全的,需要使用Collections.synchronizedList()”。这句话需要拆开理解。
sort()本身是修改集合内容的操作,如果有其他线程同时遍历或修改这个集合,确实会出问题。但线程安全不是一个方法级别的问题,而是整个并发访问设计的问题。使用synchronizedList包装之后,sort()方法内部的遍历和交换操作会持有锁,能够保证多个线程同时调用sort()时不会互相踩踏。
不过要注意:synchronizedList只保证单个方法的原子性,如果你做的是“读取集合->判断条件->调用sort()->再次读取”这种复合操作,仍然需要外部加锁。从Java 8开始,List.sort()默认方法内部调用的是toArray()、Arrays.sort()、ListIterator.set(),这些操作在synchronizedList上也谈不上完全原子,最稳妥的方式是:如果你要对一个大列表排序,而其他线程可能同时修改这个列表,优先采用副本策略——拷贝一份再排序,或者干脆用不可变集合+CopyOnWriteArrayList方案。
5.2 自定义类型的Comparable:把排序规则“内聚”进对象
前面提过Comparator是外部排序方案,Comparable则是对象自身实现排序规则。一个类同时实现Comparable并重写compareTo方法,在Java世界里有很多约定。但实际业务中,我建议谨慎使用Comparable来承载复杂的业务排序逻辑。
理由很简单:业务排序规则变化太快。今天按年龄,明天按注册时间,后天按消费金额,如果每次变化都在实体类里改compareTo(),这个类会越来越臃肿,而且改动影响面太大。更合理的做法是:
Comparable只用来实现“最自然的、最稳定的排序规则”,比如String按字典序、Integer按数值序,LocalDate按时间序。- 所有会变化的业务排序全部用
Comparator外部定义,通过Comparator.comparing().thenComparing()组合。
这个原则能避免一个很尴尬的场景:A同事在User里写了按年龄升序,B同事在另一个模块按年龄降序排序,结果还不能直接调用sort(),必须一遍遍传Comparator。如果哪天出现了compareTo和Comparator冲突的Bug,大概率就是有人在compareTo里塞了不合适的业务规则。
5.3 一个关于equals和compareTo的一致性约定
Java文档对compareTo有一个强约束:“强烈建议compareTo方法的返回值与equals方法保持一致”,也就是当compareTo返回0时,equals应该返回true。
这个约束的出发点是:当对象被放入TreeSet、TreeMap这类基于比较器而非equals的集合时,如果compareTo与equals不一致,会出现明明equals为true却存不进Set、或者remove删不掉元素的问题。我曾在项目中遇到过:User类重写了equals(比较ID和name),但compareTo只比较了ID。结果两个User对象的ID相同但name不同,equals返回false,但TreeSet认为它们是同一个对象,直接拒绝添加后一个。这种Bug并不罕见,排查起来要靠“打印集合size、用contains逐一验证”才能发现。
5.4 HashMap与sort组合使用的正确姿势
如果你要对Map按value排序,常见做法是把entrySet转成List再排序,再装入LinkedHashMap。这一步本身很简单,但有一个细节容易被忽略:HashMap不保证顺序,如果你在排序前后都是以HashMap保存,排序结果会被“吃掉”。
正确示范:
Map<String, Integer> scoreMap = new HashMap<>(); // 填充数据... List<Map.Entry<String, Integer>> entryList = new ArrayList<>(scoreMap.entrySet()); entryList.sort(Map.Entry.comparingByValue(Comparator.reverseOrder())); LinkedHashMap<String, Integer> sortedMap = new LinkedHashMap<>(); for (Map.Entry<String, Integer> e : entryList) { sortedMap.put(e.getKey(), e.getValue()); }LinkedHashMap会按插入顺序遍历,这样排序结果才能保持住。如果直接sort完还放回HashMap,排序白做。
5.5 int[]、Integer[]、List 排序的区别
很多人会混淆三种数据结构的排序API,尤其容易在int[]和List<Integer>之间切换时报编译错误。
| 数据结构 | 排序API | 注意点 |
|---|---|---|
int[] | Arrays.sort(arr) | 只能升序,无Comparator版本 |
Integer[] | Arrays.sort(arr, Comparator) | 可以用Comparator实现降序 |
List<Integer> | list.sort(Comparator) | 最灵活,推荐使用 |
Arrays.sort()对int[]没有提供接收Comparator的重载,是因为基本类型无法与泛型兼容。想对int[]降序排列,需要先转成Integer[]再排序,或者用stream:
int[] arr = {3, 1, 2}; int[] sortedDesc = Arrays.stream(arr) .boxed() .sorted(Comparator.reverseOrder()) .mapToInt(Integer::intValue) .toArray();这里还有一个坑:Arrays.sort()不支持直接传入List<Integer>,必须调用Collections.sort(list)或list.sort()。如果项目里出现了同时使用Arrays.sort和Collections.sort的混乱状况,建议统一成list.sort()风格,降低心智负担。
6. 排序结果不对时的完整排查链路:一个真实案例
这一章分享一个我自己线上遇到并完整排查过的排序Bug,整个链路走下来,比单纯读API文档有用得多。
6.1 问题现象
运营反馈后台系统里的用户列表,按“最近登录时间”倒序排列时,排列结果不对。具体表现为:大部分数据正确,但偶尔有几条数据明明最近登录过,却排到了最后面。出问题的用户数量不大,靠人工核对才看出来。
6.2 第一步:复现并缩小范围
我先从接口入参开始检查,发现后端代码长这样:
List<User> userList = userService.queryUsers(...); userList.sort(Comparator.comparing(User::getLastLoginTime).reversed()); return userList;看起来没有任何问题,时间字段是LocalDateTime,实现了Comparable。我尝试用同样的查询条件在测试环境复现,但始终无法复现——因为测试环境的登录时间很少会有空值,而线上有部分用户从来没有登录过,lastLoginTime为null。
6.3 第二步:检查数据库排序和内存排序的一致性
接着怀疑是数据库查询顺序和内存排序不一致。但查完SQL之后发现,查询本身没有ORDER BY,完全依赖Java侧排序。到这里,null这个嫌疑越来越大了。
实际验证:将getLastLoginTime()为null的数据挑出来,发现这些数据全部被Comparator.comparing(...).reversed()排到了最后。这其实符合Comparator的默认行为——遇到null会直接抛NullPointerException,但为什么没抛?原因在于代码里用了LocalDateTime对象引用,而comparing内部调用compareTo时,null会被尝试解引用,按道理必然抛异常。
进一步排查发现,系统使用的ORM框架在返回User对象时,lastLoginTime字段为null的数据,在序列化过程中被设置成了数据库的“默认时间”,比如1970-01-01 00:00:00,而不是null。所以排序时它们计算出来的“最近登录时间”非常早,自然排到了后面。
6.4 第三步:确认根因并修复
根因清楚了:不是排序代码的问题,而是数据源头污染。把lastLoginTime为null的数据,在ORM映射时设置了默认值,这个默认值混入了真实登录时间序列。
修复分两层:
- 数据层:不再把null映射成默认值,保持null原样返回;
- 代码层:排序时显式处理null,使用
Comparator.nullsLast(),保证未来即使出现null也不会抛出异常或产生歧义。
修复后的代码:
userList.sort(Comparator.comparing(User::getLastLoginTime, Comparator.nullsLast(Comparator.reverseOrder())));这里nullsLast(Comparator.reverseOrder())的含义是:非null值按时间倒序排列,null值统一放在最后。
6.5 这次踩坑教会我的三件事
第一,排序问题的排查顺序应该是:数据本身是否正确(是否掺入了脏数据)-> 比较规则是否符合预期(null、默认值)-> 算法稳定性是否满足要求。很多人一上来就怀疑算法或并发,反而浪费时间。
第二,reversed()的位置非常容易写错。Comparator.comparing(...).reversed()是对整个比较器做逆转;如果只想逆转某一个字段的排序方向,必须写在对应字段的比较器上。
第三,线上数据永远比测试数据“脏”。null、默认值、空字符串、历史遗留格式,都要在排序前统一梳理清楚。
7. 再补充几个实际项目中不太起眼但很实用的sort细节
前面基本把主干讲透了,这里再补充几个我实际用过的、文档里不太强调的小技巧。
7.1 字符串排序的“数字问题”
String的默认比较是字典序,"10"排在"2"前面,因为第一个字符'1'比'2'小。如果你对一个商品编号列表做排序,发现"item10"排在"item2"前面,这是正常的。但如果期望按“数值大小”排,需要自己包装:
list.sort(Comparator.comparing(s -> new BigInteger(s.replaceAll("\\D", ""))));这种场景在版本号排序、楼层编号排序、文件名称排序里经常出现。租约编号1,2,3,...,10按字典序排出来的结果永远是1,10,2,3,...,9,不处理就是Bug。
7.2 排序前先做防御性拷贝
如果一个List是从缓存里拿出来的共享对象,直接对它调用sort()会改变缓存里的内容,后续其他请求拿到的就是已排序的数据,可能引发诡异问题。稳妥做法是:
List<User> sortedList = new ArrayList<>(sharedList); sortedList.sort(comparator);这个习惯可以帮你规避大量由“共享对象被意外修改”引起的并发类Bug。即使是Collections.unmodifiableList包裹之后也不能直接排序,而是必须先new ArrayList<>(...)拷贝。
7.3 排序性能优化:优先比较成本低的字段
自定义比较器时,字段的选择也会影响性能。比如:比较两个对象时,优先比较int类型字段,其次long,再其次String,最后才是复杂对象。虽然Java的getter调用不慢,但排序的比较次数是O(n log n),每次比较多一微秒,100万条数据就是几秒的差距。反过来,如果先比较String,再比较int,命中的概率和成本都不一样。
7.4 关于sort()和Stream sorted()的选择
Java 8之后,很多人习惯用stream().sorted()。这个写法本身没错,但它背后的行为与List.sort()并不完全相同:
stream().sorted()会生成一个新的流,不会修改原集合;List.sort()原地修改。- 流上的
sorted()排序对象也是TimSort(在Stream内部),复杂度一致。 stream().sorted().collect(Collectors.toList())会额外拷贝一次数据,原集合大时要注意内存。
日常写代码时,如果你不打算保留原始顺序,直接list.sort()更快,也更省内存;如果你需要保留原列表,那stream().sorted()更自然。两条路本质都是同一个排序内核,没必要迷信其中一个。
7.5 自定义Comparator与equals的“优先级”问题
还有一个容易忽略的地方:很多场景下我们用Comparator比较两个对象,但业务上“是否是同一个对象”仍然由equals决定。比如在去重场景中,distinct()用的是equals,而排序用的Comparator,两套逻辑不应该混为一谈。如果一个比较器认为两个对象“相等”(返回0),但equals返回false,那么排序结果在某些算法下可能导致一个对象被“丢弃”。TimSort不是Set,它不会主动去重,但在一些合并操作、二分插入逻辑中,返回0的元素会被认为“不应该插入”,从而微妙地改变结果。所以,Comparator返回0一定意味着这两个对象在当前排序维度下等价,而不是“可以互相替代”。
8. 从sort()想到的:看似基础的功能,为什么总是藏坑
写到这里,回头再看sort()这个函数,它几乎浓缩了Java设计哲学里最典型的几个特点:底层算法的精细选择、接口与实现分离、默认行为与自定义行为的边界、并发环境下的种种约束。
很多人觉得排序是“高级语言里最简单的一个API”,但恰恰因为简单,大家才不会去读源码,不会去深究稳定性意味着什么,不会去想null值会不会让线上崩溃,不会去判断自己该用原始数组还是List接口。真正遇到问题时,才发现自己对这个函数的理解只是冰山一角。
从实用角度出发,我给还在纠结排序的开发者几个清晰建议:
- 能不开箱即用就别过度设计:如果数据规模不大、字段简单、不会变化,直接用
Comparator.comparing()就能解决,没必要引第三方排序库或自研算法。 - 排序前梳理数据边界:null、空值、默认值、类型转换、脏数据是绝大多数排序Bug的根源,先把数据清洗做掉。
- 排序后立即验证:在测试环境构造边界数据(全空、倒序、已排序、随机打乱),确认结果符合预期。只测一组正序数据是远远不够的。
- 大列表排序要走“先查数、再排序、再分页”的流程:内存排序不是不能用,但要有意识地控制数据量上限。
- 版本升级后回归排序行为:Java版本升级时,
Arrays.sort()的实现有可能微调,虽然算法行为在绝大多数情况下保持一致,但性能表现和边界行为可能会有差异。线上系统升级JDK时,把涉及排序的模块单独回归一遍,不算浪费时间。
还有一点,我每次教团队里的新人时都会强调:不要只背API,要去读源码。不用读完整本JDK源码,只读Arrays.sort()和TimSort这两个类就够了。你会看到那些看似神秘的处理——小于47走插入排序、大于某个阈值走归并、递归深度过深切堆排序——其实每一行都有据可循,每一个常量的选取都来自经验和实测。
最后分享一个我自己的习惯。我写排序代码时,一定会加注释,说明这个排序规则的业务来源,以及为什么这样排序是正确的。比如:“这里按lastLoginTime倒序,null代表从未登录,排最后”。这种注释在三个月后的排查中价值极大,有时候能省下半天时间。毕竟,排序代码的正确性往往不是靠逻辑推出来的,而是靠“对业务边界的理解是否足够完整”决定的。sort()只是执行排序的那个动作,真正决定排序结果对不对的,是你在调用它之前想清楚了什么。