做Java后端这几年,要说遇到频率最高、看起来最简单但真正做起来一堆坑的需求,“对List
这篇内容不是教你怎么写三行代码解决单字段排序,而是把List<Map>排序这件事从原理到实战、从简单到复杂彻底讲透。无论你是刚接触集合框架的初学者,还是被各种排序需求折磨过的中级开发,我相信里面都有能直接用的东西。我会把完整的代码示例、参数计算逻辑、以及实际踩过的坑全部放出来,你拿到手就能抄作业。
1. 先搞清楚:你到底需要哪种排序
1.1 单字段排序是最基础的需求
很多人第一次遇到List<Map>排序,场景往往是这样:从某个接口或者数据库查出一批用户数据,每条数据是一个Map,里面放了“姓名”“年龄”“注册时间”等字段,现在要按年龄从小到大排。
写法看起来很直接:
List<Map<String, Object>> list = new ArrayList<>(); // 假设list已经填充了数据 Collections.sort(list, new Comparator<Map<String, Object>>() { @Override public int compare(Map<String, Object> m1, Map<String, Object> m2) { Integer age1 = (Integer) m1.get("age"); Integer age2 = (Integer) m2.get("age"); return age1.compareTo(age2); } });这段代码没有语法问题,跑起来也没毛病。但你要是以为这就是全部,那后面遇到的需求会让你措手不及。因为真实世界里,很少有这么“乖巧”的数据。
1.2 多字段与动态排序才是真实世界的需求
我见过太多人栽在多字段排序和动态排序上。举几个真实场景:
- 列表要“先按部门分组,再按部门内的年龄排序”,这叫多字段联合排序。
- 表格每一列的列头都能点击排序,用户点“金额”就按金额排,点“时间”就按时间排,这叫动态字段排序。
- 某个字段可能为空,空值要排在最后,这叫null值处理。
Map里的值类型五花八门,有的是Integer、有的是String、有的是BigDecimal,直接compareTo会炸,这叫类型统一。
这些才是你在实际开发中真正要解决的痛点。下面我把这几个问题逐一拆开,从API原理讲到完整的实现方案。
2. 核心API解析:Comparator才是排序的主角
2.1 为什么是Comparator,而不是让Map自己实现Comparable
初学Java的时候,我们最早学的排序方式大概是:让某个自定义类实现Comparable接口,重写compareTo方法,然后Collections.sort(list)就能排序。这对于单个类、固定字段排序没问题,但放在List<Map>场景里就完全不适用了。
根本原因有两个。
第一,Map接口本身没有实现Comparable,而且你也没办法控制外部传进来的Map类型去加一个compareTo方法。Map是接口,市面上有HashMap、LinkedHashMap、TreeMap等一堆实现类,你不能指望所有实现类都按照同一个规则排序。
第二,Comparable表达的是“这个类自身的天然顺序”,比如Integer从小到大的顺序、String按字典序的顺序。但List<Map>里的排序规则往往是动态变化的——今天按年龄排,明天按工资排,后天还要倒序。这种场景下,排序规则应该从外部传入,而不是固化在类里面。
所以Java的设计是:排序方法接受一个Comparator参数,把比较逻辑抽离出来。Comparator就是一个策略接口,要什么规则就写什么规则,想换就换。我习惯把它理解成一个“裁判”——Collections.sort本身只会排序,但谁排在谁前面,完全由这个裁判说了算。裁判说按年龄比,数据就按年龄排;裁判说按工资比,数据就按工资排;裁判说年龄相同再比工资,数据就乖乖按“年龄+工资”联合排。
2.2 Comparator.comparing与链式调用
Java 8之后,Comparator接口加了一堆默认方法和静态方法,写起来比手写匿名内部类舒服太多了。最常用的就是Comparator.comparing,它接收一个“提取排序键”的函数,自动帮你完成比较:
Collections.sort(list, Comparator.comparing(m -> (Integer) m.get("age")));一行搞定按age排序。但这里有个细节要注意:Comparator.comparing要求提取出来的key是Comparable类型。因为Map<String, Object>里的value类型是Object,编译器不知道这个Object实际是不是Comparable,所以你得强转成Integer、String这种具体类型,否则编译都过不去。
如果还要倒序呢?Comparator提供了reversed()方法:
Collections.sort(list, Comparator.comparing(m -> (Integer) m.get("age")).reversed());这就是链式调用的威力。而且注意,reversed()返回的是一个新的Comparator,不会修改原来的Comparator。
2.3 Collections.sort与List.sort的区别
先说结论:在Java 8及以后,.sort()方法和Collections.sort()的执行效果基本一样,因为它们底层用的都是List内部的sort方法。
具体来说,Collections.sort(list, comparator)的实现是直接调用list.sort(comparator);而List.sort这个默认方法会把list转成数组,用Arrays.sort排完序再装回List。底层排序算法是TimSort,一种稳定的、自适应的归并排序变种,最坏时间复杂度O(n log n)。
所以你看代码的时候,两种写法都行,风格不同而已。我个人的习惯是:如果项目里已经用了Collections.sort,就保持一致;如果是在Java 8的新代码里,直接list.sort(comparator)更简洁,少写一层类名。
3. 从简单到进阶的完整实现
3.1 最基础的按单字段排序
先给一个能直接跑的完整示例,方便你对照:
import java.util.*; public class ListMapSortDemo { public static void main(String[] args) { List<Map<String, Object>> list = new ArrayList<>(); Map<String, Object> m1 = new HashMap<>(); m1.put("name", "张三"); m1.put("age", 25); list.add(m1); Map<String, Object> m2 = new HashMap<>(); m2.put("name", "李四"); m2.put("age", 22); list.add(m2); // 按年龄升序 list.sort(Comparator.comparing(m -> (Integer) m.get("age"))); for (Map<String, Object> m : list) { System.out.println(m.get("name") + " - " + m.get("age")); } } }输出:
李四 - 22 张三 - 25注意这里我用了m -> (Integer) m.get("age"),把Object强转成Integer。这一步非常重要,如果你不转,Comparator.comparing会认为你提取出来的key类型就是Object,而Object没有实现Comparable,编译直接报错。这是新手最容易卡住的地方。
3.2 不要忽略null值:排序前先想清楚规则
真实开发中,数据永远不会有你想的那么干净。年龄可能没填,工资可能没录入,时间字段可能是空字符串。如果不处理null,Integer.compareTo直接抛NullPointerException。
好在Comparator早就考虑到了这一点,提供了两个内置的null处理策略:
Comparator.nullsFirst(comparator):将null值视为最小值,排在最前面。Comparator.nullsLast(comparator):将null值视为最大值,排在最后面。
用法很简单,只要在外面包一层就行:
list.sort(Comparator.nullsLast(Comparator.comparing(m -> (Integer) m.get("age"))));这段代码的意思是:先按age比较,如果age为null,把它放最后。
那如果要求“null排在前面”呢?把nullsLast换成nullsFirst就行:
list.sort(Comparator.nullsFirst(Comparator.comparing(m -> (Integer) m.get("age"))));这里有个容易搞混的点:nullsFirst和nullsLast包装的是“整个比较器”,不是单独作用于某一个字段。也就是说,如果比较器里面还有thenComparing这种链式逻辑,null的处理会影响整个链条。后面讲多字段排序时我会再强调这个坑。
3.3 多字段排序:链式调用thenComparing
需求来了:先按部门排序,同一个部门内部再按年龄排序。这就是典型的多字段联合排序。Comparator提供了thenComparing方法,可以一个接一个地追加比较规则:
list.sort(Comparator .comparing((Map<String, Object> m) -> (String) m.get("dept")) .thenComparing(m -> (Integer) m.get("age")));这段代码先按dept升序排列,如果dept相同,再按age升序排列。链式调用的顺序就是比较的优先级顺序:越在前面的规则,优先级越高。
但有两点必须提醒。
第一,类型推断问题。当你写.comparing(m -> (String) m.get("dept"))再接.thenComparing(m -> (Integer) m.get("age"))时,Lambda的参数类型可能推断不出来,编译报错。解决办法是在第一个Lambda里显式声明参数类型,就像上面代码写的那样:(Map<String, Object> m) -> ...。这样后续的thenComparing就能顺着推断出类型。
第二,nullsFirst/nullsLast放的位置。如果你要处理多个字段都为null的情况,正确写法是把整个比较器链包起来,而不是只包第一个字段:
Comparator<Map<String, Object>> deptThenAge = Comparator .comparing((Map<String, Object> m) -> (String) m.get("dept")) .thenComparing(m -> (Integer) m.get("age")); list.sort(Comparator.nullsLast(deptThenAge));注意这里nullsLast包装的是“整个多字段比较器”。如果你写成Comparator.nullsLast(comparing(...)).thenComparing(...),那么nullsLast只作用于第一个字段,后面追加的字段如果出现null,照样NPE。我一开始就踩过这个坑,排到一半整个列表崩溃,排查了好久才意识到是包装范围的问题。
3.4 动态字段排序:前端传什么字段就按什么排
前端表格组件通常都会在列头提供排序功能,用户点击之后,前端把字段名和排序方向传给后端。后端的任务就是把“字段名+方向”变成一个安全的Comparator。
思路很直接:用Map存字段名到排序键提取函数的映射,然后根据参数动态选择。
public class DynamicSorter { private static final Map<String, Function<Map<String, Object>, Comparable>> KEY_EXTRACTORS = new HashMap<>(); static { KEY_EXTRACTORS.put("name", m -> (String) m.get("name")); KEY_EXTRACTORS.put("age", m -> (Integer) m.get("age")); KEY_EXTRACTORS.put("salary", m -> (BigDecimal) m.get("salary")); } private static final Set<String> ALLOWED_FIELDS = KEY_EXTRACTORS.keySet(); public static Comparator<Map<String, Object>> buildComparator(String field, Boolean asc) { // 白名单校验,防止传入非法字段名 if (field == null || !ALLOWED_FIELDS.contains(field)) { throw new IllegalArgumentException("Unsupported sort field: " + field); } Comparator<Map<String, Object>> comparator = Comparator.comparing(KEY_EXTRACTORS.get(field)); if (Boolean.FALSE.equals(asc)) { comparator = comparator.reversed(); } // null值统一放最后 return Comparator.nullsLast(comparator); } }使用者只需要一行:
list.sort(DynamicSorter.buildComparator("age", true));这几个点值得展开说说。
第一,为什么用白名单校验。因为排序字段直接来自外部请求,如果不校验,攻击者传入一个业务里面不存在的字段名,提取函数链式调用时可能产生意外行为。虽然不至于直接注入什么代码,但良好的习惯是:外部输入一律白名单校验,不匹配就报错或者忽略。
第二,为什么定义提取函数用Function<Map<String, Object>, Comparable>。因为在Comparator.comparing里提取的key必须实现Comparable接口,所以类型上直接约束成Comparable最直观,后面想加字段,往KEY_EXTRACTORS里面塞一个方法就行,扩展成本极低。
第三,升序降序的处理。这里用了Boolean.FALSE.equals(asc),而不是!asc。原因是前端的传参可能是null,如果直接用!asc,null会变成true,可能导致默认行为不符合预期。用Boolean.FALSE.equals(asc)就安全得多——只有明确传了“递减”才走降序,其他情况都默认升序。
3.5 排序字段类型统一:别让Integer和String打架
这是List<Map>排序里最隐蔽的坑。一个Map<String, Object>里的value,光看类型定义是Object,但你永远不知道它底层实际装的是什么。有时候接口返回的年龄是Integer,有时候JSON解析出来就变成了Long,还有时候数据库查询结果集返回的字段类型会变成BigDecimal。
如果两个value类型不一致,比如一个是Integer、一个是Long,直接compareTo会抛ClassCastException,整个排序崩溃。
稳妥的做法:在提取排序key的时候统一进行一次类型转换。比如统一转成Long或者BigDecimal,数值字段都可以这么做:
list.sort(Comparator.comparing(m -> { Object val = m.get("age"); if (val == null) { return 0L; } if (val instanceof Number) { return ((Number) val).longValue(); } return Long.parseLong(val.toString()); }));这段代码能扛住Integer、Long、BigDecimal、甚至字符串“25”这类数据。核心思路是:先把value归一到同一个类型,再交给Comparator去比较。
Number这个接口很实用,Integer、Long、BigDecimal、Double这些数值类型都实现了它,所以只要判断是Number,就可以安全地取数值。对于非数值的字符串类型,再做一次parseLong兜底。这样写虽然比一行Lambda多几行,但换来的是线上稳定性,值。
3.6 扩展:嵌套字段排序(map里有map)
有时候Map的value不是简单类型,而是一个嵌套的Map。比如用户信息的Map里有一个address,address又是个Map,里面放着city字段。要按city排序怎么办?
有两种办法。一种是写一个嵌套的提取函数:
list.sort(Comparator.comparing(m -> { Map<String, Object> address = (Map<String, Object>) m.get("address"); return address == null ? null : (String) address.get("city"); }, Comparator.nullsLast(Comparator.naturalOrder())));这里用到了Comparator.comparing的重载版本:第一个参数提取key,第二个参数指定key的比较器。当提取出来的key可能是null时,这个重载特别有用。你把nullsLast(naturalOrder())作为比较器传进去,提取为null的元素就自动排到最后了。
还有一种办法是在提取函数内部处理完null,返回空字符串:
list.sort(Comparator.comparing(m -> { Map<String, Object> address = (Map<String, Object>) m.get("address"); if (address == null) { return ""; } String city = (String) address.get("city"); return city == null ? "" : city; }));两种都行,个人更推荐第一种。因为第二种用空字符串代替null,会导致“空值”和“真实空字符串”混在一起,排序时看起来一样,其实语义不同。第一种直接把null排到尾部,语义清晰得多。
4. 性能与稳定性:排序不只是compareTo
4.1 稳定排序到底意味着什么
Java 7之后,Collections.sort底层用的是TimSort,这是一种稳定排序算法。稳定排序的意思是:如果两个元素“相等”(即compare返回0),它们在排序前的前后相对顺序,排序后保持不变。
这个特性在实际业务里很重要。举个例子,表格先按“部门”排好了序,然后用户要求再按“年龄”排。如果你用稳定排序,那按年龄排序之后,同年龄的人内部依然保持按部门排好序的状态。稳定排序让“多关键词渐进式排序”成为可能——每次只排一个字段,多次排序叠加,最终的效果等于多字段联合排序。
Java 8的Stream.sorted底层的排序算法也是稳定的。所以无论是list.sort、Collections.sort还是stream.sorted,稳定性的保证是一样的。
4.2 别在Comparator里做重活
写Comparator的时候,天然容易掉进一个性能陷阱:在一次比较里做耗时操作。你每调一次compare,里面的代码就会执行一次。而TimSort在正常规模数据下,比较次数大约是n log n次。1万条数据就是大约13万次比较,10万条数据就是170万次左右。
如果每次比较都要去解析日期字符串:
// 反面教材 list.sort(Comparator.comparing(m -> { SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); try { Date date = sdf.parse((String) m.get("createTime")); return date.getTime(); } catch (ParseException e) { return 0L; } }));那你就相当于跑了上百万次SimpleDateFormat的parse,性能直接爆炸。SimpleDateFormat本身也不是线程安全的,在并行流里还容易出安全问题。
正确的做法是:预先解析,排序时直接比较已经转换好的值。也就是“空间换时间”的思路——先把原始数据转换成排序用的key,存成一个临时对象,排完序再组装回去。简单点的做法是定义一个临时内部类:
class SortableItem { Map<String, Object> data; Long createTimeMillis; Integer age; BigDecimal salary; }先把所有需要排序的字段算好,塞进SortableItem,对List<SortableItem>排序,排完再取出data。这样每次compare只是几个基础类型的字段比较,速度快几个数量级。
这种优化在大数据量(比如几十万条以上)场景下收益非常明显。几千条数据可能感觉不出来,但数据量一上去,差距立刻拉开。
4.3 大数据量下要注意内存占用
List.sort底层会把List转成数组再排序,中间会多一份对象引用数组的开销。几十万条的List,每个元素是一个引用(8字节),总共也就几MB,一般不需要担心。但如果你的List本身是用LinkedList存储的,排序时还要转成数组再转回来,中间有额外拷贝成本。不过实际开发中ArrayList才是主流,LinkedList的排序场景很少遇到,知道有这么回事就行。
另外一个容易忽略的点:Comparator.comparing里如果用Lambda捕获了外部变量,比如从请求参数里取到的排序方向,那这个Comparator就无法被静态复用。每次请求都要新建一个Comparator对象。这本身不是问题,但如果你在循环里对多个列表用同一个排序规则排序,最好把Comparator提取出来复用,减少对象创建。
4.4 Stream sorted与List.sort怎么选
Java 8后很多人喜欢用Stream做排序:
List<Map<String, Object>> sortedList = list.stream() .sorted(Comparator.comparing(m -> (Integer) m.get("age"))) .collect(Collectors.toList());区别在于:list.sort会修改原列表,而stream.sorted会生成一个新的列表,原列表保持不变。
这是一个很重要的语义差异。如果你的业务要求“排序前和排序后的数据都保留”,用stream.sorted更合适;如果只是原地排序展示,用list.sort更省内存。别搞混,否则会导致数据状态混乱的bug。
性能上,stream.sorted因为要创建Stream、收集器等额外开销,在相同数据量下通常比list.sort稍慢一点点。但差距不大,日常业务可以忽略。我更推荐的做法是:默认使用list.sort,只有当你需要保留原列表时才用stream.sorted。
5. 踩坑实录:这些问题我全遇到过
5.1 ClassCastException:value类型不一致
现象:排序时抛java.lang.ClassCastException: java.lang.Integer cannot be cast to java.lang.String。
原因:Map里的同一个字段,在不同数据里的实际类型不一样。比如有的记录里age是Integer,有的记录里age是String。你强转为Integer,遇到String就炸了。
解决思路:提取排序key时统一做类型归一化转换。建议写一个工具方法,把Object类型的value安全地转换成你需要的值。如果是数值字段,就用前面讲过的Number判断加上字符串兜底;如果是时间字段,统一解析成long毫秒值再比较。宁可多写几行工具代码,也不要让类型问题在线上炸锅。
5.2 遇到null就NPE
现象:NullPointerException,堆栈指向compare方法里的compareTo调用。
原因:Map.get(key)返回了null,你又直接调用了compareTo。
解决思路:给所有可能为null的字段加上nullsFirst或nullsLast处理。优先推荐用Comparator.nullsLast(Comparator.comparing(...))统一包裹,语义是空值排最后。千万别用“默认值”代替null,比如StringUtils.defaultIfEmpty(value, ""),这样虽然不报错了,但空值和真实空字符串会被混在一起,排序结果可能不符合预期。
5.3 日期字符串排序错误
现象:按createTime排序,结果发现“2023-09-01”排在了“2023-08-31”前面。
原因:String的字典序比较和日期顺序不是一回事。字典序比较逐字符比过去,遇到字符顺序不同就直接返回。如果数据是“2024-01-01”和“2023-12-31”,字符序会在第2位就分出结果,根本不会轮到日期语义的比较。
解决思路:日期字段绝对不能用String.compareTo直接排。先统一转换成时间戳:
list.sort(Comparator.comparing(m -> { Object val = m.get("createTime"); if (val == null) { return 0L; } // 这里按实际数据格式解析,可以是LocalDateTime.parse或SimpleDateFormat }));转换后再用Long.compare比较。如果日期格式不统一,可以先做数据清洗。别指望格式乱糟糟的数据能排对。
5.4 中文排序结果怪怪的
现象:按“姓名”排序,结果不是按拼音排的,排序结果看着像是按Unicode编码排的。
原因:String.compareTo按Unicode码点比较,对于中文来说,这种顺序和拼音顺序没什么关系。你看到的“怪怪的”顺序,其实就是Unicode编码顺序。
解决思路:使用java.text.Collator,指定Locale.CHINA:
Comparator<String> chinaComparator = Collator.getInstance(Locale.CHINA); list.sort(Comparator.comparing(m -> (String) m.get("name"), chinaComparator));Collator会按照中文的语言习惯排序(默认按拼音)。但要注意,Collator的性能比普通字符串比较差一些,数据量大时建议提前算好排序字段,或者只用它排序一次,不要反复创建实例。
5.5reversed()用错位置
现象:多字段排序时,只想让“年龄”倒序,结果“部门”也倒序了。
原因:reversed()方法是对整个Comparator取反,而不是只对链上的某一个环节。
// 错误写法:部门、年龄全都倒序了 list.sort(Comparator .comparing((Map<String, Object> m) -> (String) m.get("dept")) .thenComparing(m -> (Integer) m.get("age")) .reversed()); // 正确写法:只让年龄倒序 list.sort(Comparator .comparing((Map<String, Object> m) -> (String) m.get("dept")) .thenComparing(Comparator.comparing(m -> (Integer) m.get("age")).reversed()));正确写法里的reversed()作用在“年龄比较器”上,而不是整个链上。特别提醒:reversed()返回的是新对象,不会修改原来的比较器,所以你可以安全地把它赋值给一个新变量复用。
5.6 排序把原数据结构改了
现象:排序后,发现自己业务逻辑里其他地方用的原列表顺序也变了,导致数据展示错乱。
原因:list.sort和Collections.sort是原地排序,会直接修改列表元素的顺序。如果你不希望原列表被改动,就必须用stream.sorted生成新列表。
排查这个问题的办法也很简单:看排序前后的列表引用地址,如果只是原地排序,System.identityHashCode不会变;如果是新建的列表,引用地址会变。不过这种问题一般靠日志+代码审查就能定位。
6. 最后分享一点个人经验
做这个需求做得多了,我自己总结了一套判断顺序:拿到排序需求,先不要急着写代码,先问清楚四个问题——排序字段是不是可空的?字段类型会不会混?要不要多字段联合排序?排序后原数据能不能变?这四个问题定下来,代码怎么写基本就清楚了。
工具方法一定要提前沉淀好。List<Map>排序这种场景出现频率太高了,我建议团队里封装一个通用的LambdaSortUtils工具类,提供几个静态方法:单字段排序、多字段排序、动态字段排序、时间字段排序。把参数设计成(List<Map>, String field, boolean asc)这种形式,这样业务代码调用的时候只需要传字段名和方向,底层的null处理和类型转换全在工具类里消化掉。改了一处,全项目受益。
还有一个值得养成习惯的小细节:写Comparator之前,先想想这个排序规则会不会被复用。如果会被复用,提前提取成静态常量,避免每个请求都重新创建Comparator对象。尤其在高并发场景下,减少无意义的对象创建对GC压力是有帮助的。虽然单个Comparator对象的开销微乎其微,但量一旦上来,该省的还是要省。
对List<Map>排序这个需求,说复杂也复杂,说简单也简单。掌握了Comparator的原理,理解了null处理、类型统一、链式调用这几个关键点,你会发现大多数排序需求都是同一套思路的变体。希望这篇内容能帮你少走一些弯路。下一次再遇到排序需求,直接打开这篇文章对照着用,就够了。