
业务场景与问题描述
在日常的数据处理中,我们经常会遇到需要从一个对象列表中提取唯一记录的场景。然而,这里的“唯一”可能并非指所有字段都完全相同,而是基于某个或某几个特定字段的组合。更进一步,当这些组合字段出现重复时,我们可能需要根据另一个字段(例如时间戳或版本号)来决定保留哪一条记录。
以员工数据为例,假设我们有一个Employee对象列表,其定义如下:
import lombok.Data;
import lombok.AllArgsConstructor;
import java.time.LocalDateTime;
@Data
@AllArgsConstructor
public class Employee {
private String firstName;
private String lastName;
private double salary;
private LocalDateTime getSalaryDate; // 更改为更具描述性的字段名
}该列表中可能存在多条记录拥有相同的firstName和lastName,但salary和getSalaryDate(获取薪资的日期)不同。例如:
Listemployees = new ArrayList<>(); employees.add(new Employee("John", "Smith", 10, LocalDateTime.of(2022, 9, 1, 0, 0))); employees.add(new Employee("John", "Smith", 20, LocalDateTime.of(2022, 10, 1, 0, 0))); employees.add(new Employee("John", "Smith", 5, LocalDateTime.of(2022, 11, 1, 0, 0))); employees.add(new Employee("Kelly", "Jones", 12, LocalDateTime.of(2022, 3, 1, 0, 0))); employees.add(new Employee("Sara", "Kim", 21, LocalDateTime.of(2022, 3, 1, 0, 0))); employees.add(new Employee("Sara", "Kim", 7, LocalDateTime.of(2022, 7, 1, 0, 0)));
我们的目标是:对于每对唯一的firstName和lastName组合,只保留一条记录,且这条记录必须是getSalaryDate最新的那一条。
期望的输出结果应为:
立即学习“Java免费学习笔记(深入)”;
- "John", "Smith", 5, 2022-11-01
- "Kelly", "Jones", 12, 2022-03-01
- "Sara", "Kim", 7, 2022-07-01
使用Java Stream API进行高效过滤
Java 8引入的Stream API为处理集合数据提供了强大而灵活的工具。针对上述问题,我们可以利用Collectors.toMap结合自定义的合并函数(merge function)来实现。
核心思路
- 生成唯一键: 为了识别firstName和lastName的唯一组合,我们需要将这两个字段组合成一个唯一的键。最简单的方法是字符串拼接。
- 映射值: 将原始的Employee对象作为Map的值。
- 处理键冲突: 当不同的Employee对象生成了相同的键时(即firstName和lastName相同),Collectors.toMap允许我们提供一个合并函数来决定保留哪个值。在这个函数中,我们将比较两个Employee对象的getSalaryDate,保留日期最新的那个。
- 提取结果: 最终,从生成的Map中提取所有的值,即为我们所需的过滤后的Employee列表。
实现步骤与代码示例
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.function.Function;
import java.util.stream.Collectors;
public class EmployeeFilterTutorial {
// Employee 类定义(同上,为了完整性再次列出)
@Data
@AllArgsConstructor
public static class Employee {
private String firstName;
private String lastName;
private double salary;
private LocalDateTime getSalaryDate;
}
public static void main(String[] args) {
List employees = new ArrayList<>();
employees.add(new Employee("John", "Smith", 10, LocalDateTime.of(2022, 9, 1, 0, 0)));
employees.add(new Employee("John", "Smith", 20, LocalDateTime.of(2022, 10, 1, 0, 0)));
employees.add(new Employee("John", "Smith", 5, LocalDateTime.of(2022, 11, 1, 0, 0)));
employees.add(new Employee("Kelly", "Jones", 12, LocalDateTime.of(2022, 3, 1, 0, 0)));
employees.add(new Employee("Sara", "Kim", 21, LocalDateTime.of(2022, 3, 1, 0, 0)));
employees.add(new Employee("Sara", "Kim", 7, LocalDateTime.of(2022, 7, 1, 0, 0)));
Collection filteredEmployees = employees.stream()
.collect(Collectors.toMap(
// Key Mapper: 组合 firstName 和 lastName 作为唯一键
employee -> employee.getFirstName() + employee.getLastName(),
// Value Mapper: 将 Employee 对象本身作为值
Function.identity(),
// Merge Function: 处理键冲突,保留 getSalaryDate 最新的 Employee
(existingEmployee, newEmployee) ->
existingEmployee.getSalaryDate().isAfter(newEmployee.getSalaryDate()) ? existingEmployee : newEmployee
))
.values(); // 从 Map 中获取所有值,即为过滤后的 Employee 集合
// 打印结果
filteredEmployees.forEach(System.out::println);
}
} 代码解析
- employees.stream(): 创建一个Employee对象的流。
-
collect(Collectors.toMap(...)): 这是核心操作,用于将流中的元素收集到一个Map中。Collectors.toMap有三个参数:
- keyMapper (employee -> employee.getFirstName() + employee.getLastName()): 这是一个Function,用于从流中的每个元素(Employee对象)中提取键。这里我们将firstName和lastName拼接起来,形成一个字符串作为Map的键。
- valueMapper (Function.identity()): 这是一个Function,用于从流中的每个元素中提取值。Function.identity()是一个便捷方法,表示将元素本身作为值。
-
mergeFunction ((existingEmployee, newEmployee) -> existingEmployee.getSalaryDate().isAfter(newEmployee.getSalaryDate()) ? existingEmployee : newEmployee): 这是一个BinaryOperator,当keyMapper生成相同的键时(即发生键冲突时),它会被调用来决定保留哪个值。
- existingEmployee:Map中已经存在的与当前键关联的Employee对象。
- newEmployee:当前正在处理的、与当前键关联的Employee对象。
- 我们通过比较它们的getSalaryDate,保留日期更晚(isAfter返回true)的那个Employee对象。
-
.values(): Collectors.toMap操作完成后,返回的是一个Map
。我们只需要最终过滤后的Employee对象,因此调用.values()方法获取Map中所有值的集合。
运行结果
Employee(firstName=John, lastName=Smith, salary=5.0, getSalaryDate=2022-11-01T00:00) Employee(firstName=Sara, lastName=Kim, salary=7.0, getSalaryDate=2022-07-01T00:00) Employee(firstName=Kelly, lastName=Jones, salary=12.0, getSalaryDate=2022-03-01T00:00)
可以看到,输出结果与我们的预期完全一致,对于每个独特的姓名组合,都只保留了拥有最新薪资获取日期的员工记录。
注意事项与扩展
- 键的唯一性: 拼接字符串作为键是一种简单有效的方法。对于更复杂的场景,可以考虑自定义一个Pair类或record(Java 16+)来封装多个字段作为键,并确保正确实现其equals()和hashCode()方法。
- LocalDateTime的比较: LocalDateTime提供了isAfter()、isBefore()和isEqual()等方法,使得日期时间比较非常直观和安全。
-
空值处理: 如果getSalaryDate字段可能为null,则在合并函数中需要额外添加null值检查,以避免NullPointerException。例如:
(e1, e2) -> { if (e1.getSalaryDate() == null) return e2; if (e2.getSalaryDate() == null) return e1; return e1.getSalaryDate().isAfter(e2.getSalaryDate()) ? e1 : e2; } - 性能考量: 对于非常大的数据集,Collectors.toMap通常表现良好,因为它只进行一次遍历。然而,键的生成(尤其是字符串拼接)会产生额外的对象,这在极端性能敏感的场景下可能需要优化。
- 选择其他条件: 如果需要选择日期最旧的记录,只需将合并函数中的比较逻辑反转即可:e1.getSalaryDate().isBefore(e2.getSalaryDate()) ? e1 : e2。
总结
通过本教程,我们学习了如何巧妙地运用Java Stream API中的Collectors.toMap,结合自定义的键映射和合并函数,来解决复杂的数据过滤和聚合问题。这种方法不仅代码简洁、可读性强,而且在处理大量数据时表现出良好的性能。掌握这种模式,将大大提升在Java中进行数据处理的效率和灵活性。










