列表里混着 null 时,直接调用 Comparator.naturalOrder() 往往会在排序过程中抛出空指针异常。Java 的解决办法不是把空值偷偷改成空字符串或 0,而是把“空值放哪里”明确写进比较器:Comparator.nullsFirst(cmp) 让 null 排在非空值前面,Comparator.nullsLast(cmp) 则把它放到末尾;两个非空值仍由传入的 cmp 决定顺序。
官方文档:https://docs.oracle.com/en/java/javase/26/docs/api/java.base/java/util/Comparator.html
nullsFirst只负责空值位置,非空值的排序规则仍要交给内部比较器。- 对对象字段排序时,先构造字段比较器,再用
nullsFirst或nullsLast包在外层。 List.sort是稳定排序;比较结果为 0 的元素不会被重新排列,但去重集合还要单独考虑比较器相等语义。
先把 null 的排序语义说清楚
问题通常从一段看似正常的代码开始:列表中的姓名或日期大部分有值,少数记录还没有填充字段。若直接用自然顺序比较器,比较器收到 null 后没有可用的非空对象参与比较,排序就可能失败。nullsFirst 和 nullsLast 把这个判断提升为比较器契约,而不是散落在业务循环里的特判。
| 写法 | null 的位置 | 非 null 值如何比较 |
|---|---|---|
nullsFirst(cmp) | 小于任意非 null 值 | 交给 cmp |
nullsLast(cmp) | 大于任意非 null 值 | 交给 cmp |
naturalOrder() | 不负责 null 保护 | 按对象的自然顺序 |

还有两个容易忽略的细节:两个 null 会被认为相等;如果内部比较器本身是 null,返回的比较器会把所有非空值视为相等。因此,生产代码通常显式传入自然顺序、字符串忽略大小写比较器或业务字段比较器,不把“内部比较器为空”当成默认排序方案。
把 null 处理包装在字段比较器外层
对对象字段排序时,先决定“字段值怎么比”,再决定“字段值为 null 时放哪儿”。Comparator.comparing 负责从对象取出字段,但它不会自动替字段值做空值策略;把 nullsFirst 放在字段比较器外面,才能让字段值的空值判断先于内部比较。
import java.util.Comparator;
import java.util.List;
record User(String name, Integer score) {}
public final class NullAwareSort {
public static void sortByScore(List users) {
// 先定义 Integer 的自然顺序,再决定 null 排在前面
Comparator scoreOrder = Comparator.nullsFirst(Comparator.naturalOrder());
// comparing 只负责取出 score,空值策略由 scoreOrder 统一处理
users.sort(Comparator.comparing(User::score, scoreOrder));
}
}
这里的关键不是把 Comparator.nullsFirst 直接套在 Comparator.comparing(User::score) 的结果上,而是让它比较“score 字段的值”。如果把包装层级放错,比较器可能只保护 User 对象本身,却没有保护取出的字段。
字符串字段也同理。比如产品名需要忽略大小写,先写出 String.CASE_INSENSITIVE_ORDER,再包上 nullsLast,就能表达“有名字的记录按不区分大小写排序,缺失名字的记录最后出现”。

用 thenComparing 保留可读的次级排序
只按一个可能为空的字段排序,常常会得到大量比较结果为 0 的元素。稳定排序会保留它们在原列表中的相对顺序,但如果业务希望结果在分页或重复执行时更容易解释,可以明确追加次级键。
import java.util.Comparator;
import java.util.List;
record Ticket(String priority, String id) {}
public final class TicketOrder {
public static void sort(List tickets) {
// 优先级缺失时放到最后,非空优先级按字符串自然顺序比较
Comparator priorityOrder =
Comparator.nullsLast(Comparator.naturalOrder());
// id 作为次级键,让相同优先级的记录拥有明确的业务顺序
Comparator order = Comparator.comparing(Ticket::priority, priorityOrder)
.thenComparing(Ticket::id);
tickets.sort(order);
}
}
thenComparing 只有在前一个比较器返回 0 时才比较下一个键,因此它不会改变不同优先级的排列。若 id 也可能为 null,就为它单独准备 Comparator.nullsLast(Comparator.naturalOrder()),不要假定次级键天然非空。
反转和去重集合是两个边界
reversed() 会反转整个比较器的结果,包含空值位置。想要“非空值降序,但 null 仍然在最后”,不要先写 nullsLast(naturalOrder()).reversed(),因为反转后 null 会跑到前面;应先反转非空值比较器,再重新包一层 nullsLast。
import java.util.Comparator;
public final class DescendingNullable {
public static Comparator scoreDescending() {
// 只反转非空值的顺序,再把 null 固定到最后
return Comparator.nullsLast(Comparator.reverseOrder());
}
}
另外,List.sort 的稳定性只说明相等元素的相对顺序不会被主动打乱。把同一个比较器用于 TreeSet 或 TreeMap 时,比较结果为 0 还会影响集合的唯一性或键覆盖行为。若业务身份是 id,就让比较器在必要时继续比较 id,不要用显示字段的相等代替对象身份。
| 场景 | 推荐写法 | 重点检查 |
|---|---|---|
| null 放前面 | nullsFirst(nonNullComparator) | 内部比较器负责非空值 |
| null 放后面且非空降序 | nullsLast(Comparator.reverseOrder()) | 不要反转外层空值策略 |
| 对象字段可能为空 | comparing(keyExtractor, nullAwareComparator) | 保护字段值而不是只保护对象 |
| TreeSet / TreeMap | 追加能区分身份的次级键 | 比较结果为 0 可能意味着重复 |
相关问题
Comparator.nullsFirst 能处理两个 null 吗?
能。两个 null 会比较为相等;真正的排序位置由 null 与非 null 值之间的比较结果决定。
为什么 nullsLast(...).reversed() 会让 null 排到前面?
因为 reversed() 反转的是整个比较器,包括 null 与非 null 的关系。要保持 null 在末尾,应只反转非空值比较器,再用 nullsLast 包装。
字段为 null 时应该用空字符串代替吗?
通常不建议。空字符串可能与真实值混淆,也会改变业务排序语义;更清楚的做法是保留 null,并用显式空值比较器表达位置。

Java 虚拟线程为什么会被锁固定到载体线程
