
引言:处理列表对象去重的挑战
在数据处理中,我们经常遇到需要从列表中移除重复项的场景。然而,简单的去重往往不能满足所有需求。例如,当列表中存在多个具有相同标识符(id)的对象时,我们可能需要根据某个特定属性(如时间戳)来决定保留哪一个。本教程将聚焦于一个典型场景:给定一个包含 student 对象的列表,每个 student 对象都有一个 id 和一个 startdatetime。如果存在多个 student 对象具有相同的 id,我们希望只保留其中 startdatetime 最新的那个。
考虑以下 Student 类定义及其示例数据:
package org.example;
import java.time.LocalDateTime;
import java.util.Objects; // 导入 Objects 类
public class Student {
private String id;
private LocalDateTime startDatetime;
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public LocalDateTime getStartDatetime() {
return startDatetime;
}
public void setStartDatetime(LocalDateTime startDatetime) {
this.startDatetime = startDatetime;
}
public Student(String id, LocalDateTime startDatetime) {
this.id = id;
this.startDatetime = startDatetime;
}
@Override
public String toString() {
return "Student{id='" + id + "', startDatetime=" + startDatetime + '}';
}
// 建议重写 equals 和 hashCode,尽管本例中不是必需的,但对于集合操作是良好实践
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Student student = (Student) o;
return Objects.equals(id, student.id) && Objects.equals(startDatetime, student.startDatetime);
}
@Override
public int hashCode() {
return Objects.hash(id, startDatetime);
}
}初始数据示例如下:
Liststudents = List.of( new Student("1", LocalDateTime.now()), new Student("1", LocalDateTime.of(2000, 2, 1, 1, 1)), new Student("1", LocalDateTime.of(1990, 2, 1, 1, 1)), new Student("2", LocalDateTime.of(1990, 2, 1, 1, 1)) );
我们期望的结果是:对于ID为"1"的学生,保留 LocalDateTime.now() 对应的记录;对于ID为"2"的学生,保留其唯一的记录。最终列表应只包含两条记录。
核心解决方案:Collectors.toMap 的三参数用法
Java Stream API 提供了强大而灵活的 Collectors 工具类,其中 Collectors.toMap 的三参数版本是解决此类问题的关键。其方法签名通常为:
立即学习“Java免费学习笔记(深入)”;
public staticCollector > toMap( Function super T, ? extends K> keyMapper, Function super T, ? extends U> valueMapper, BinaryOperator mergeFunction )
这个方法接受三个参数:
- keyMapper:一个函数,用于从流中的每个元素(T 类型)提取出作为 Map 键(K 类型)的值。
- valueMapper:一个函数,用于从流中的每个元素(T 类型)提取出作为 Map 值(U 类型)的值。
- mergeFunction:一个二元操作符,用于处理当两个或多个流元素映射到相同的键时如何合并它们的值。这是解决我们去重逻辑的关键所在。
1. keyMapper:提取 ID 作为键
对于我们的 Student 对象,我们希望根据 id 进行去重。因此,keyMapper 应该是一个从 Student 对象中获取其 id 的函数引用:Student::getId。
2. valueMapper:保留原始对象作为值
我们希望在去重后保留完整的 Student 对象,而不是其某个属性。因此,valueMapper 应该简单地返回原始 Student 对象本身。这可以通过 Function.identity() 实现。
3. mergeFunction:解决冲突并保留最新记录
这是最核心的部分。当 Collectors.toMap 遇到具有相同键(id)的多个 Student 对象时,mergeFunction 会被调用来决定保留哪一个。我们的目标是保留 startDatetime 最新的那个。
我们可以使用 BinaryOperator.maxBy 结合 Comparator.comparing 来实现这一逻辑:
- Comparator.comparing(Student::getStartDatetime):这会创建一个 Comparator,用于比较两个 Student 对象的 startDatetime 属性。
- BinaryOperator.maxBy(Comparator.comparing(Student::getStartDatetime)):BinaryOperator.maxBy 接受一个 Comparator,并返回一个 BinaryOperator,该操作符会在两个输入值中选择由 Comparator 定义的“最大”值。在这里,“最大”意味着 startDatetime 最新的 Student 对象。
因此,mergeFunction 的完整表达式为:BinaryOperator.maxBy(Comparator.comparing(Student::getStartDatetime))。
实战代码示例
将上述概念整合到完整的 Java 代码中:
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.function.BinaryOperator;
import java.util.function.Function;
import java.util.stream.Collectors;
public class Main {
// Student 类定义(与上面保持一致)
public static class Student {
private String id;
private LocalDateTime startDatetime;
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public LocalDateTime getStartDatetime() {
return startDatetime;
}
public void setStartDatetime(LocalDateTime startDatetime) {
this.startDatetime = startDatetime;
}
public Student(String id, LocalDateTime startDatetime) {
this.id = id;
this.startDatetime = startDatetime;
}
@Override
public String toString() {
return "Student{id='" + id + "', startDatetime=" + startDatetime + '}';
}
}
public static void main(String[] args) {
// 原始学生列表
List students = new ArrayList<>() {{
add(new Student("1", LocalDateTime.now())); // 最新的ID为1的记录
add(new Student("1", LocalDateTime.of(2000, 2, 1, 1, 1)));
add(new Student("1", LocalDateTime.of(1990, 2, 1, 1, 1)));
add(new Student("2", LocalDateTime.of(1990, 2, 1, 1, 1)));
add(new Student("3", LocalDateTime.of(2020, 1, 1, 0, 0))); // 新增一个不重复的记录
add(new Student("3", LocalDateTime.of(2019, 1, 1, 0, 0))); // 较旧的ID为3的记录
}};
System.out.println("原始学生列表:");
students.forEach(System.out::println);
System.out.println("--------------------");
// 使用 Stream API 去重并保留最新记录
List uniqueStudents = students.stream()
.collect(Collectors.toMap(
Student::getId, // keyMapper: 以ID作为键
Function.identity(), // valueMapper: 保留原始Student对象作为值
BinaryOperator.maxBy(Comparator.comparing(Student::getStartDatetime)) // mergeFunction: 冲突时保留startDatetime最新的
))
.values() // 获取Map中所有的值(去重后的Student对象)
.stream() // 将值集合转换为新的Stream
.sorted(Comparator.comparing(Student::getStartDatetime)) // 可选:根据startDatetime排序结果
.toList(); // Java 16+ 新特性,等价于 .collect(Collectors.toList())
System.out.println("去重并保留最新记录后的学生列表:");
uniqueStudents.forEach(System.out::println);
}
} 运行结果示例(LocalDateTime.now() 会根据运行时间变化):
原始学生列表:
Student{id='1', startDatetime=2023-10-27T10:30:45.123456}
Student{id='1', startDatetime=2000-02-01T01:01}
Student{id='1', startDatetime=1990-02-01T01:01}
Student{id='2', startDatetime=1990-02-01T01:01}
Student{id='3', startDatetime=2020-01-01T00:00}
Student{id='3', startDatetime=2019-01-01T00:00}
--------------------
去重并保留最新记录后的学生列表:
Student{id='2', startDatetime=1990-02-01T01:01}
Student{id='1', startDatetime=2023-10-27T10:30:45.123456} // 此处的日期时间会是运行时的当前时间
Student{id='3', startDatetime=2020-01-01T00:00}可以看到,ID为"1"和"3"的重复记录已被成功去重,并保留了 startDatetime 最新的那一条。最终列表也根据 startDatetime 进行了排序。
结果转换与后续处理
在上述代码中,collect(Collectors.toMap(...)) 的结果是一个 Map
- .values():返回 Map 中所有值的 Collection 视图。
- .stream():将这个 Collection 转换为一个新的 Stream。
- .sorted(Comparator.comparing(Student::getStartDatetime)):这是一个可选步骤,用于对最终结果列表按照 startDatetime 进行升序排序。如果不需要特定顺序,可以省略此步骤。
- .toList():Java 16 引入的便捷方法,用于将 Stream 收集为不可变的 List。对于早期 Java 版本,可以使用 collect(Collectors.toList())。
注意事项与最佳实践
- 性能考量:Collectors.toMap 内部会构建一个 HashMap 来存储中间结果。对于非常大的数据集,这会产生一定的内存开销。然而,对于大多数常见场景,这种方式的性能表现是可接受的,并且其代码的简洁性优势显著。
- 可读性与维护性:使用 Stream API 结合 Collectors.toMap 能够以声明式的方式表达复杂的业务逻辑,使得代码意图清晰,易于理解和维护,避免了传统循环中常见的嵌套条件判断。
- 空值处理:如果 keyMapper 或 valueMapper 可能返回 null,或者 Comparator 在比较时遇到 null,可能会抛出 NullPointerException。在实际应用中,需要根据具体业务需求进行 null 值检查或处理。例如,如果 startDatetime 可能为 null,可以使用 Comparator.nullsFirst() 或 Comparator.nullsLast()。
- Java 版本兼容性:.toList() 方法是 Java 16 及更高版本引入的。如果您的项目使用较早的 Java 版本(如 Java 8 或 11),请使用 collect(Collectors.toList())。
- 通用性:这种模式不仅适用于根据日期去重,还可以根据任何可比较的属性(如版本号、优先级等)去重,只需调整 Comparator 的逻辑即可。例如,如果需要保留“最小”值,可以使用 BinaryOperator.minBy。
总结
通过本教程,我们深入探讨了如何利用 Java Stream API 中的 Collectors.toMap 的三参数版本,结合 Function.identity() 和 BinaryOperator.maxBy(Comparator.comparing(...)),优雅且高效地解决列表中对象去重并保留最新记录的问题。这种声明式编程风格不仅提升了代码的简洁性和可读性,也充分展现了 Java Stream 在处理复杂集合操作时的强大能力。掌握这一模式,将有助于您在日常开发中编写出更加健壮和现代的 Java 代码。










