ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

Java排序进阶:从Arrays.sort到性能优化的完整实践

Java排序进阶:从Arrays.sort到性能优化的完整实践 1. 排序需求比你想的更加常见但多数人只停留在“会用”做Java开发这些年我几乎在每一个业务系统里都遇到过排序需求排行榜要按分数倒序订单列表要按时间从新到旧后台报表要按某个指标聚合排序甚至推荐策略里的候选集也要先做一次加权排序。工具类一行调用看似简单但到线上环境真正踩过坑之后你会发现“排序Java”这五个字背后有一套完整的知识体系绝不是调一个Arrays.sort就能高枕无忧的。我从入行开始就被“排序”这种东西迷惑过。那时候写业务代码列表需要排序第一反应就是Collections.sort(list)再配合一个Comparator匿名内部类。跑通功能很简单但后来遇到两个问题让我彻底改变了对它的看法第一个是排序结果不稳定同一个列表在不同Java版本下顺序不一致第二个是数据量上来之后接口耗时翻了好几倍用火焰图一查排序成了最大的热点。从此我开始系统性整理Java里的排序实现、算法原理、优化手段和排查方法也算把这个“看似人尽皆知”的话题真正弄明白了。这篇文章就围绕“排序Java”这条主线展开适合的人群是写业务代码时经常用到排序、想搞懂Java底层排序逻辑、或者正在排查线上排序性能问题的开发者。我尽量把原理和实战放在一起讲不绕弯子直接上干货。2. 先搞清楚Java排序的底层家底双轴快排和TimSort2.1 同一套Arrays.sort为什么排序结果可能不一样很多人在正式研究排序之前根本不知道Java的排序是“分流”处理的。我最早是在一次代码走查里被一位资深同事点醒的他说你用Arrays.sort排int数组和用Collections.sort排对象列表两者底层走的根本不是同一个算法。我回去翻了源码确认之后还挺震惊的。简单说Java中对基础类型数组的排序走的是DualPivotQuicksort双轴快速排序而对对象数组的排序走的是TimSort。这两个算法各有特点双轴快排是快速排序的优化版本它在待排序数据基本有序的情况下性能极佳但它是不稳定的排序算法。TimSort则是归并排序的优化版本它结合了二分插入排序和归并排序最大优势是稳定并且对部分有序的数据有非常好的适应性。为什么Java要这样设计关键因素在于对象的比较成本通常比基础类型高得多。int[]的比较就是两个整数比大小CPU一条指令的事而对象的比较要回调Comparator或compareTo方法这里面可能藏着一大串字段比较逻辑甚至字符串操作。稳定排序能在保证正确性的基础上让多次排序的结果可预期这对真实业务很重要。比如先按时间排序再按优先级排序如果第二次排序不稳定最终结果就会乱套。2.2 版本演进带来的排序行为差异还有一个容易踩坑的地方是Java版本升级带来的排序行为变化。我做过一个模拟项目X其中有一个功能是根据综合得分给客户列表排序测试环境里顺序一直稳定但发布到新版本JDK的服务器之后某一次输出顺序变了。排查后确认不是代码逻辑问题而是底层排序算法在小数据量和大数据量之间切换阈值的逻辑在不同JDK版本中做了调整。这个现象提醒我凡是依赖“相同输入必须产生相同输出顺序”逻辑的模块不能只依赖排序算法的实现而应该在业务层面显式地固定排序键。也就是说Comparator里不能只比一个字段要把所有可能影响顺序的字段都纳入比较链形成全序。这是从“会排序”到“正确排序”的一道重要分水岭。3. 从手写排序到用对内置排序一条更稳的路线3.1 经典排序算法的手写思路和关键代码虽然日常开发不推荐自己造轮子但理解经典排序算法对排查性能问题有非常直接的帮助。比如双轴快速排序的“分治”思想和TimSort里“run”的概念如果你没有手写过归并排序和快排看源码会非常吃力。我建议无论工作年限多久都至少把下面几个基础算法用Java手写一遍。冒泡排序虽然效率低但它的思想可以作为理解其他排序的起点。核心逻辑就是相邻元素两两比较把较大值慢慢“冒泡”到末尾。代码很简单但复杂度是O(n^2)。选择排序则是每次从剩余元素里选最小的放到前面优点是比较次数固定但交换次数最多O(n)。插入排序则是在局部有序的序列中插入新元素对于基本有序的数据效果极佳这也是TimSort在run长度很短时选择插入排序的原因。快速排序的思路是选一个基准值把数组分成小于基准和大于基准的两部分然后递归处理。手写时要注意基准值的选取策略我通常用三数取中法来避免最坏情况public static void quickSort(int[] arr, int left, int right) { if (left right) { return; } int pivot partition(arr, left, right); quickSort(arr, left, pivot - 1); quickSort(arr, pivot 1, right); } private static int partition(int[] arr, int left, int right) { // 三数取中避免近乎有序数据导致递归过深 int mid left (right - left) / 2; if (arr[left] arr[right]) { swap(arr, left, right); } if (arr[mid] arr[right]) { swap(arr, mid, right); } if (arr[left] arr[mid]) { swap(arr, left, mid); } int pivot arr[left]; int i left, j right; while (i j) { while (i j arr[j] pivot) { j--; } arr[i] arr[j]; while (i j arr[i] pivot) { i; } arr[j] arr[i]; } arr[i] pivot; return i; } private static void swap(int[] arr, int i, int j) { int tmp arr[i]; arr[i] arr[j]; arr[j] tmp; }归并排序则是典型的“分治后合并”思路它最大的优势是稳定。手写归并排序时最关键的是合并过程中需要额外的辅助数组这是空间复杂度O(n)的来源。如果你在做大数据量的排序对象数组使用TimSort时最坏情况下也需要额外空间这方面在内存受限环境里要特别留意。3.2 为什么生产环境不应该自己写排序我从入行到现在见过不止一个项目里有人自己实现了快速排序或者希尔排序放在工具类里理由无非是“内置排序不够快”或者“想更可控”。但实际上Java内置排序经过几十年的优化在各种数据分布下都有非常充分的测试你手写的排序在绝大多数情况下不可能超越它。我自己的经验是手写排序只适合两个场景一是学术练习彻底理解算法本身二是极其特殊的业务场景比如你明确知道数据分布一定是有序的情况下需要做定制的局部排序。除此之外一律用Collections.sort、Arrays.sort或者Stream.sorted。这里还有一个容易忽略的问题手写排序的测试覆盖很难做全。边界条件非常多比如所有元素相同、只有一个元素、逆序数据、包含null、浮点数NaN任何一个点没考虑到线上都可能出现偶发异常。内置排序帮我们屏蔽了绝大多数边界风险何乐而不为。4. Comparable和Comparator排序正确性的核心在比较逻辑4.1 实现Comparable和自定义Comparator怎么选很多初学者对Comparable和Comparator的区别模棱两可但排序正确性恰恰是由这里决定的。Comparable是类自身的排序能力相当于“我天生就知道怎么跟自己比”比如String实现了Comparable所以字符串列表可以直接排序。Comparator则是外部策略相当于“你来定规则告诉我该按什么排”。实际业务中我非常推荐优先使用Comparator。原因是实体类通常承载多个维度的属性今天按时间排明天按金额排后天按状态优先级加时间倒序排。如果全部写在Comparable里每次改排序规则都要修改实体类违背开闭原则而且容易牵连其他使用该集合排序的地方。用Comparator则可以把排序规则单独抽出来还能用Java 8的Comparator.comparing和thenComparing非常优雅地组合。// 先按下单时间倒序再按订单金额倒序最后按订单号升序 ComparatorOrder orderComparator Comparator .comparing(Order::getCreateTime, Comparator.reverseOrder()) .thenComparing(Order::getAmount, Comparator.reverseOrder()) .thenComparing(Order::getOrderNo);4.2 比较逻辑里三个常见但隐蔽的坑第一个坑是Comparator返回值含义写反。compare(a, b)返回负数表示a在b前面返回正数表示a在b后面返回0表示两者相等。如果你写的是return a.getScore() - b.getScore()在整数溢出时会产生错误排序。比如两个分数分别是Integer.MAX_VALUE和Integer.MIN_VALUE差值直接溢出成负数得到的顺序是完全错的。正确写法是用Integer.compare(a.getScore(), b.getScore())。第二个坑是null值的处理。如果排序列表里有null元素直接调用compareTo会抛出空指针异常。我习惯在Comparator里统一加上null的判断通常把null放在末尾或者开头看业务需求。Java 8提供了Comparator.nullsFirst和nullsLast两个工具直接组合即可。第三个坑是字符串排序的“隐性大小写问题”。String的compareTo方法对大小写敏感大写字母的ASCII码比小写字母小所以A会排在a前面。如果你做的是名称类的排序通常需要String.CASE_INSENSITIVE_ORDER来保证不区分大小写或者用Collator来处理中文排序。中文排序这里尤其容易出问题我曾经在客户名称排序时发现“张”排在“李”前面而业务上期望按拼音排最后用Collator.getInstance(Locale.CHINA)才解决了。这也是“排序Java”里最容易被忽视的细节。5. 大数据量下的排序优化并行排序和内存平衡5.1 Arrays.parallelSort是否真的更快Java 8开始提供了Arrays.parallelSort很多人以为它是排序的银弹直接把Arrays.sort全部替换掉。实测下来这个结论站不住脚。parallelSort内部使用ForkJoin公共池进行并行归并排序只有当数据量达到一个阈值时才真正并行对于小数组反而因为线程池的开销变得更慢。我做了一个简单的基准测试对一亿个随机整数的数组分别用Arrays.sort和Arrays.parallelSort排序。单线程版本耗时约0.9秒并行版本在8核机器上约0.2秒。确实快了不少但是当数据量降到几十万级别时两个版本耗时几乎相同甚至parallelSort偶尔更慢。原因是多线程切分数据、汇总结果、线程调度的开销在数据量不够大时会把性能收益吃掉。因此我的建议是如果排序的数组超过千万级别而且所在机器的CPU核数较多可以尝试parallelSort否则老老实实用Arrays.sort。另外要注意parallelSort的并行线程来自公共ForkJoin池如果你的应用里还有其他并行任务与它争抢线程整体吞吐可能不升反降。这时候更推荐自己做一个分批排序后再归并的操作或者直接把排序放到专门的线程池里执行。5.2 排序对内存和GC的影响对象排序的TimSort需要额外的临时数组空间当数据量大到一定程度时这些临时对象会占用老年代空间频繁触发GC。我曾经处理过一个深夜报表任务它需要把一个包含几十万个对象的列表按多个指标排序多次结果Old Gen持续增长最终触发了Full GC导致任务失败。排查过程其实很像侦探工作先看GC日志确认频率再用内存分析工具抓dump发现大量对象数组堆积而它们的引用源就是TimSort里的tmp数组。优化方式很简单把多次排序合并成一次多条件排序减少临时空间的申请次数同时调整JVM堆参数和新生代比例让排序期间的临时数组尽量在新生代被回收。还有一个容易被忽视的点如果参与排序的对象本身包含大量字段排序时频繁调用getter会产生较大的CPU开销。这里的优化技巧是先将需要参与排序的字段抽出来放到轻量级的排序Key对象里排序完成后再映射回原对象。这个思路在百万级对象排序时效果立竿见影。6. 一次线上排序性能问题的完整排查链路6.1 从接口耗时翻倍到定位排序热点前阵子一个业务模块的查询接口开始出现性能问题原本稳定在200毫秒的接口涨到了600毫秒以上。第一反应是数据库慢查询然而查了日志之后发现SQL执行时间只有30毫秒。接着看链路追踪数据发现耗时几乎全部集中在接口内部的排序处理上。这个排序逻辑本身很简单从缓存中取出一批候选对象按分数降序排列后取前100条。数据量大概在20万左右。以前数据量只有两万排序成本可忽略数据量涨了十倍排序成本也跟着非线性增长。我先在关键代码前后加了耗时日志确认Collections.sort占用了约400毫秒。再通过采样型性能分析工具抓线程栈看到热点集中在字符串格式化和对象的compareTo方法上。6.2 根因比较器内部做了昂贵的字段计算问题根源并不是排序算法本身而是Comparator里做了大量的实时计算。比如每个对象的分数并不是预计算好的字段而是每次compare时现算出来的分数计算里包含字符串拼接、日期格式化、甚至几次HashMap查找。这意味着每比较一次都要重复计算二十万个元素排序需要比较几百万次计算成本成倍放大。解决方案也不复杂先把候选列表遍历一遍计算出每个对象的排序分数存入一个新的轻量对象含原始对象引用和分数值然后用这个轻量对象列表排序最后再映射回原始对象。改造后整个排序耗时从400毫秒降到了60毫秒左右效果非常明显。这个案例也是“排序Java”真正进阶的一道坎排序瓶颈往往不取决于算法本身而是你给了比较器多少“工作量”。6.3 后续的性能验证和泛化经验优化完成之后我没有直接上线而是做了一组对比验证分别在旧逻辑和新逻辑下用两万、十万、二十万、五十万四条数据量规模跑了一遍。结果清晰显示了差距五十万数据量时旧逻辑已经超过2秒新逻辑稳定在200毫秒左右。之后我在团队内推广了一个约定所有自定义Comparator里禁止做耗时计算字段必须提前封装好。这个约定也延续到了后来的几个项目里。这种情况下我还会顺手检查排序是否真的需要全量排序。很多只需要TopN的业务全量排序时间较长更适合用PriorityQueue维护一个小顶堆遍历数据时不断淘汰最小值内存占用和耗时都能大幅下降。比如从二十万条数据里取分数最高的前100条用堆排序方案只需要维护一个100容量的堆性能比全量排序快一个量级。这是一个很容易被忽略的经典优化手段。7. 排序选型的经验判断什么时候用哪种方案我把这些年在项目里的排序选型经验整理成了一个表方便快速决策。核心变量是数据量、对象还是基础类型、是否需要稳定排序、是否只需要TopN。场景推荐方案理由基础类型数组排序int、long、doubleArrays.sort底层双轴快排性能极佳对象列表排序需要稳定顺序Collections.sort / List.sort底层TimSort稳定且适配部分有序数据超大数组排序CPU多核空闲Arrays.parallelSort数据量千万级以上收益明显只需要TopN结果PriorityQueue维护小顶堆避免全量排序时空开销可控多条件组合排序Comparator.comparing thenComparing可读性好避免写大量重复比较代码中文按拼音排序Collator.getInstance(Locale.CHINA)解决字符串自然排序不符合中文习惯的问题还不确定排序规则单独抽取Comparator类便于测试和后续改规则还有一个年头很长的经验不要只关注排序本身多想想怎么避免排序。数据库里直接用ORDER BY很多时候比把数据全部捞出来再排更高效因为数据库可以利用索引有序性甚至避免排序操作。缓存层面也可以在写入时就维护有序结构比如使用TreeMap或者ConcurrentSkipListMap读取时天然有序代价是写入时做插入操作。这些方案都会改变系统的整体复杂度需要你根据业务场景去权衡。8. 排序测试容易出事但常常被忽略的一环排序代码看起来简单实际上是测试最容易遗漏的地方。我见过很多项目对排序功能的测试只有一条断言某个列表的前几个元素顺序符合期盼。结果遇到null元素、重复值、逆序数据就垮掉了。我的习惯是给排序单独建一个测试类至少覆盖下面这些场景正序数据、逆序数据、随机数据、全部相同、包含null、只有一个元素、包含NaN针对浮点数、以及大量重复元素。有一点要特别提醒浮点数排序里的NaN问题非常隐蔽。Double.compare的语义是0.0小于NaNNaN大于所有非NaN值如果你期望NaN排在最后或者直接过滤掉不处理就一定会出问题。我之前在对接数据分析模块时就吃过这个亏原始数据里混入NaN之后排序结果里出现了莫名其妙在最前面的元素。对于大规模排序的正确性验证我还会写一个随机数据生成器把数据规模递增到十万、百万级别每次排序后对比参考实现的结果。参考实现直接用Java内置的稳定排序然后自己实现的排序逻辑会通过同样的测试来确认行为和稳定性一致。这个做法在重写排序逻辑或者自定义比较器时非常有用能在上线前兜住大部分边界风险。写完测试之后还有一道自我检查排序结果的“唯一性”。如果你的排序规则允许两个元素比较结果为0那么它们的相对顺序在稳定排序下是可预期的在非稳定排序下则不可预期。业务上如果需要绝对可预期的顺序就必须让Comparator在任何情况下都返回非0值最简单的方法是在比较链末尾追加一个唯一标识字段如id的比较。这也是我在实践中的最后一个习惯凡是排序结果需要作为后续逻辑依据的不从头到尾保证全序就不要罢休。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表