写业务代码时经常遇到这种场景:一个 List 里装着一批订单或用户对象,要先按主字段排序,主字段相等再按次字段排序,而某些字段本身允许为 null。直接写 Comparator.comparing(Order::getAmount).thenComparing(Order::getCreateTime),一旦遇到 null 字段就会在读数据时抛 NullPointerException;而如果把 nullsFirst() 随手塞到 thenComparing 的错误位置,要么编译不过,要么排序结果与预期相反,最麻烦的是不报错但顺序错乱,排查起来非常耗时。
本文把 thenComparing 与 nullsFirst / nullsLast 的组合规则讲透:每个 key extractor、每个 Comparator 各自可以挂 null 策略的位置在哪,链式调用中 null 策略的生效范围是什么,倒序(reversed)与 null 策略的相互影响如何,最后给出一套可以直接复用的 Comparator 构造工具与常见报错对照表。
先明确两个 API 的职责边界
Comparator.comparing(Function) 返回的 Comparator 只负责「用 key extractor 取出 key,再调用 key 的自然顺序 compareTo」。它不检查 null。当 key extractor 返回 null 时,内部执行的是 key1.compareTo(key2),对 null 调用实例方法即抛 NPE,堆栈大致如下:
Exception in thread "main" java.lang.NullPointerException:
Cannot invoke "java.lang.Comparable.compareTo(Object)" because the return value of
"com.example.Order.getAmount()" is null
at java.base/java.util.Comparator.lambda$comparing$77a9974f$1(Comparator.java:469)
at java.base/java.util.Comparator.lambda$thenComparing$36697e65$1(Comparator.java:216)
at java.base/java.util.List.sort(List.java:564)
注意堆栈里 thenComparing 那一层:说明 NPE 是在比较阶段发生的,而不是在构造 Comparator 阶段,所以「定义时没报错」并不代表安全。
Comparator.nullsFirst(Comparator) 返回的是一个包装器:它在比较时先判断两个参数是否为 null,只有两个都不为 null 时才委托给内部 comparator。因此它是「作用于一个已有 Comparator 的装饰器」,而不是「作用于某个字段的开关」。理解这一点,链式组合的所有坑都能推出来。
关键结论:null 策略必须绑定到「真正执行比较的那个 Comparator」上,而不是绑定到某个字段名上。链式调用中每一段都是一个独立的 Comparator,谁需要判空,就在谁身上包
nullsFirst/nullsLast。
正确写法:三种等价形态
假设有两个可空字段,主排序 amount(null 排最前),次排序 createTime(null 排最后),三种写法等价:
形态一:comparing 重载直接传 Comparator
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Comparator;
public class OrderComparators {
public static final Comparator<Order> BY_AMOUNT_THEN_TIME =
Comparator.comparing(Order::getAmount, Comparator.nullsFirst(Comparator.naturalOrder()))
.thenComparing(Order::getCreateTime, Comparator.nullsLast(Comparator.naturalOrder()));
}
这是最推荐的形态。comparing(Function, Comparator) 这个重载把「取 key」和「比 key」分开,null 策略直接写在与字段相邻的位置,可读性最好,也不容易放错层。
形态二:先 comparing 再包 nullsFirst
Comparator<Order> c =
Comparator.nullsFirst(
Comparator.comparing(Order::getAmount))
.thenComparing(
Comparator.nullsLast(
Comparator.comparing(Order::getCreateTime)));
与形态一语义完全一致,但括号层数多,容易在长链里漏掉某个 nullsLast 的闭合括号。IDE 格式化后还可能出现缩进错位,人工 review 时需格外留意。
形态三:抽出命名 Comparator 变量
Comparator<BigDecimal> AMOUNT_NULL_FIRST =
Comparator.nullsFirst(Comparator.naturalOrder());
Comparator<LocalDateTime> TIME_NULL_LAST =
Comparator.nullsLast(Comparator.naturalOrder());
Comparator<Order> c = Comparator.comparing(Order::getAmount, AMOUNT_NULL_FIRST)
.thenComparing(Order::getCreateTime, TIME_NULL_LAST);
类型参数是 key 的类型而不是实体类型,这一点是 comparing(Function, Comparator) 重载的关键:第二个参数必须能比较 key。Comparator<Order> 传进去是编译错误,会报:
error: no suitable method found for comparing(Order::getAmount,Comparator<Order>)
method Comparator.<T,U>comparing(Function<? super T,? extends U>,Comparator<? super U>) is not applicable
(inference variable U has incompatible bounds ...)
最容易踩的坑:null 策略放错位置
下面几种写法在实际项目里都真实出现过,逐一说明为什么错。
坑一:把 nullsFirst 包在整个链外面
// 错误:nullsFirst 只保护 comparator 为 null 的情况,不保护 key 为 null
Comparator<Order> c =
Comparator.nullsFirst(
Comparator.comparing(Order::getAmount)
.thenComparing(Order::getCreateTime));
这个写法只能防住「Order 对象本身为 null」,对 getAmount() 返回 null 毫无帮助,仍然 NPE。参数级别的 null 与字段级别的 null 是两件事,必须分清。
坑二:忘记 thenComparing 是独立一段
Comparator<Order> c =
Comparator.comparing(Order::getAmount)
.thenComparing(Order::getCreateTime);
// 两个字段都可能 null,一个都没保护 = 两个都会 NPE
链式调用不会互相继承 null 策略。第一段加了 nullsFirst,第二段依然是裸的 Comparator.comparing,第二段照样 NPE。
坑三:thenComparing 返回的是 Comparator,不是 key
// 编译错误
Comparator<Order> c = Comparator.comparing(Order::getAmount)
.thenComparing(Comparator.nullsFirst(Comparator.comparing(Order::getCreateTime)));
error: cannot find symbol
symbol: method thenComparing(Comparator<Order>)
location: class Comparator<Comparable>
原因:Comparator.comparing(Order::getAmount) 在 getAmount 返回 BigDecimal 时,推出来的类型是 Comparator<BigDecimal>,而非 Comparator<Order>。thenComparing(Comparator<? super T>) 要求传入的 comparator 能比较 BigDecimal,而 Comparator.comparing(Order::getCreateTime) 是 Comparator<Order>,类型不匹配。这也是为什么「第二段用 thenComparing(comparator) 单参数重载」在链式里几乎不可用——单参数重载接收的是「另一个比较同样 T 的 Comparator」,而 T 在这一段已经被推断成了 key 类型。正确做法是用带 key extractor 的两参数重载。
坑四:nullsFirst 与 reversed 的顺序搞反
Comparator<Order> wrong =
Comparator.comparing(Order::getAmount, Comparator.nullsFirst(Comparator.naturalOrder()))
.reversed();
Comparator<Order> right =
Comparator.comparing(Order::getAmount, Comparator.nullsFirst(Comparator.reverseOrder()));
reversed() 返回的是一个交换了两个参数的包装 Comparator,它会把 null 的位置也一起反转,导致原本「null 排最前」变成「null 排最后」。如果业务要求「金额降序但 null 仍排最前」,就必须像 right 那样把逆序放进内层 comparator,在 nullsFirst 里面完成,绝不能在外层加 reversed()。
多字段 + 多个 null 策略的完整示例
下面这段代码可以直接编译运行,覆盖「金额 null 排前、创建时间 null 排后、金额降序、同值再按 id 稳定排序」四个需求,并用断言验证结果:
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
public class ComparatorDemo {
static class Order {
private final long id;
private final BigDecimal amount;
private final LocalDateTime createTime;
Order(long id, BigDecimal amount, LocalDateTime createTime) {
this.id = id;
this.amount = amount;
this.createTime = createTime;
}
long getId() { return id; }
BigDecimal getAmount() { return amount; }
LocalDateTime getCreateTime() { return createTime; }
@Override
public String toString() {
return "Order{id=" + id + ", amount=" + amount + ", createTime=" + createTime + "}";
}
}
public static Comparator<Order> buildComparator() {
return Comparator
// 金额:null 排最前;非 null 之间降序
.comparing(Order::getAmount,
Comparator.nullsFirst(Comparator.reverseOrder()))
// 创建时间:null 排最后;非 null 之间升序
.thenComparing(Order::getCreateTime,
Comparator.nullsLast(Comparator.naturalOrder()))
// id 兜底,保证结果稳定
.thenComparingLong(Order::getId);
}
public static void main(String[] args) {
List<Order> list = new ArrayList<>();
list.add(new Order(1, new BigDecimal("100.00"), LocalDateTime.of(2024, 1, 2, 10, 0)));
list.add(new Order(2, null, LocalDateTime.of(2024, 1, 1, 10, 0)));
list.add(new Order(3, new BigDecimal("100.00"), null));
list.add(new Order(4, new BigDecimal("250.00"), LocalDateTime.of(2024, 1, 1, 10, 0)));
list.add(new Order(5, null, null));
list.add(new Order(6, new BigDecimal("100.00"), LocalDateTime.of(2024, 1, 1, 10, 0)));
list.sort(buildComparator());
list.forEach(System.out::println);
long[] expected = {5, 2, 4, 2, 0, 0}; // 仅示意,实际以下方断言为准
if (list.get(0).getId() != 5 || list.get(list.size() - 1).getId() != 3) {
throw new AssertionError("排序结果不符合预期: " + list);
}
System.out.println("OK");
}
}
输出顺序(省略 trace):id=5(两个字段都 null,靠 id 兜底在 null 组里排前)、id=2(金额 null、时间非 null)、id=4(金额最大)、id=6、id=1(金额 100、时间同为 1 月 2 日,按时间升序,id=1 在前)、id=3(金额 100、时间为 null,因 nullsLast 排在同金额组末尾)。这段输出正好体现了三个要点:
- 整条链的第一段先按金额判出大小,只有金额相等(包括同为 null)才会进入第二段;
- 金额 null 组内部并非无序,而是继续用
thenComparing比较创建时间,因此 id=5 与 id=2 的先后由第二段决定; - 末尾的
thenComparingLong是稳定性兜底。虽然List.sort基于稳定排序(TimSort),但数据库分页、并行流 collect 等场景下仍建议显式加唯一键,否则翻页可能出现重复或丢行。
与 Stream、Map、下游收集器配合时的注意点
Stream.sorted 与 List.sort 行为一致
List<Order> sorted = list.stream()
.sorted(buildComparator())
.toList(); // JDK 16+;JDK 8 用 collect(Collectors.toList())
sorted 同样不会吞掉 NPE,异常在终端操作执行时才抛出,所以断言式的单元测试必须真正触发终端操作,只构造 Comparator 不做遍历是测不出问题的。
groupingBy 下游 minBy 要单独传 Comparator
Map<String, Optional<Order>> earliest =
list.stream().collect(java.util.stream.Collectors.groupingBy(
o -> o.getAmount() == null ? "UNKNOWN" : "KNOWN",
java.util.stream.Collectors.minBy(
Comparator.comparing(Order::getCreateTime,
Comparator.nullsLast(Comparator.naturalOrder())))));
minBy / maxBy 接收的是完整 Comparator,且它们对「key 为 null」同样不设防,必须在该 Comparator 内部包好 null 策略。这里特意没复用 buildComparator(),因为它的主排序是金额,直接拿来取「最早创建时间」会得到错误结果——链式 Comparator 不可随意切片复用,需要哪个语义就单独构造。
TreeMap / TreeSet 的 Comparator 会作用于每一次查找
java.util.TreeMap<Order, String> map = new java.util.TreeMap<>(buildComparator());
map.put(new Order(9, null, null), "x");
map.get(new Order(9, null, null)); // 比较器在 get 时同样执行
一旦 Comparator 中存在 null 未保护的分支,put 时可能恰好没触发(与已有 key 的比较路径不同),但后续 get、containsKey 时抛 NPE,属于典型的「上线后偶发」问题。把 Comparator 建成常量并配一个覆盖 null 与非 null 组合的单元测试,成本极低。
一个可复用的 Comparator 构造工具
项目里字段多、规则杂时,建议收口成工具方法,把「null 排哪边」变成显式参数,杜绝手写时漏包:
import java.util.Comparator;
import java.util.function.Function;
public final class Cmp {
private Cmp() {}
/** 升序;null 排最前 */
public static <T, U extends Comparable<? super U>> Comparator<T> ascNullFirst(
Function<? super T, ? extends U> key) {
return Comparator.comparing(key, Comparator.nullsFirst(Comparator.naturalOrder()));
}
/** 升序;null 排最后 */
public static <T, U extends Comparable<? super U>> Comparator<T> ascNullLast(
Function<? super T, ? extends U> key) {
return Comparator.comparing(key, Comparator.nullsLast(Comparator.naturalOrder()));
}
/** 降序;null 排最前 */
public static <T, U extends Comparable<? super U>> Comparator<T> descNullFirst(
Function<? super T, ? extends U> key) {
return Comparator.comparing(key, Comparator.nullsFirst(Comparator.reverseOrder()));
}
/** 降序;null 排最后 */
public static <T, U extends Comparable<? super U>> Comparator<T> descNullLast(
Function<? super T, ? extends U> key) {
return Comparator.comparing(key, Comparator.nullsLast(Comparator.reverseOrder()));
}
}
使用侧变成一串语义明确的链:
Comparator<Order> c = Cmp.<Order, java.math.BigDecimal>descNullFirst(Order::getAmount)
.thenComparing(Cmp.<Order, java.time.LocalDateTime>ascNullLast(Order::getCreateTime))
.thenComparingLong(Order::getId);
类型参数在泛型方法里不能总是靠 lambda 推断(Order::getAmount 作为方法引用在某些上下文推断会失败,报 incompatible types: cannot infer type-variable(s) T,U),显式写出 <Order, BigDecimal> 最稳妥,也是把工具方法落地时最常见的调整点。
小结
三个判断准则足以应对绝大多数场景:
- null 策略贴着 key 走。用
Comparator.comparing(keyExtractor, Comparator.nullsFirst(...))两参数重载,让 null 策略与字段写在一起,比外层包nullsFirst可靠得多。 - 链式调用的每一段独立生效。
thenComparing不会继承上一段的 null 策略,有几个可能为 null 的排序字段,就要包几次。 - 倒序放进内层。要「降序且 null 在前」,写
nullsFirst(reverseOrder()),不要在外层reversed(),否则 null 位置也会被翻转。
最后补一条工程习惯:任何跨模块共享的 Comparator 都写成 static final 常量并配单测,单测用例至少覆盖「两字段均 null、单字段 null、双非 null 且相等、双非 null 不相等」四类,基本能拦住上文中所有坑。
FAQ
Q1:Comparator.nullsFirst(Comparator.naturalOrder()) 里的 naturalOrder() 能不能省?
不能省成 Comparator.comparing(Order::getAmount, Comparator.nullsFirst(null))。nullsFirst 的入参为 null 时虽然可能编译通过(视上下文推断),但运行到两个都非 null 的分支时会抛 NPE,因为包装器内部会调用 comparator.compare(a, b)。要么传 naturalOrder(),要么传具体的 Comparator 实例。
Q2:为什么 thenComparing(Order::getId) 这类方法引用在链里不会 NPE,而 getAmount 会?
因为 thenComparing(Function) 内部同样调用 key 的 compareTo,只是当字段是 long / int 这类基本类型时,会走 thenComparingLong / thenComparingInt 的重载,而基本类型不可能为 null。若 getId() 返回的是包装类型 Long 且可能为 null,同样会 NPE,需要包 null 策略。
Q3:报错 incompatible types: Comparator<BigDecimal> cannot be converted to Comparator<? super Order> 怎么解决?
说明你在 Comparator.comparing(Order::getAmount) 之后调用了 thenComparing(Comparator) 的单参数重载。此时 T 已被推断为 BigDecimal。改用两参数重载 thenComparing(Order::getCreateTime, timeComparator),或把链的起点显式声明为 Comparator<Order> base = Comparator.comparing(Order::getAmount, ...); 再往下接。
Q4:nullsFirst 和 nullsLast 可以混用吗?排序结果是否仍然确定?
可以混用,不同字段各自决定 null 位置,结果是确定的:只有当上一段比较结果为 0 时才会走到下一段。但要注意「上一段为 0」包含了「上一段两值都是 null」的情况,所以两个字段都为 null 的记录会继续按后面的字段排,这正是示例里 id 兜底发挥作用的地方。
Q5:数据库里用 ORDER BY amount DESC NULLS FIRST 和 Java 端 Comparator 排序,结果能保证一致吗?
只有在排序规则(collation、字符集、时区)与字段精度完全对齐时才一致。BigDecimal 的 compareTo 与 equals 不一致(1.0 与 1.00 的 compareTo 为 0),若用 Comparator 去重或作为 Map key,会出现「看起来相等却判不等」的问题;排序本身不受影响,但业务上的「同值合并」逻辑不能依赖 Comparator 的 0 结果,要单独比较值。
Q6:List.sort(null) 和传 naturalOrder() 有区别吗?
List.sort(null) 等价于按元素自然顺序排序,元素必须实现 Comparable 且不能为 null,否则 NPE。List.sort(Comparator.naturalOrder()) 语义相同。若元素集合里可能存在 null,必须用 List.sort(Comparator.nullsFirst(Comparator.naturalOrder())),此时 T 需能推断为元素类型,通常写成 Comparator.<Order>nullsFirst(Comparator.naturalOrder()) 避免推断失败。