17370845950

Java Stream 进阶:优雅地移除重复对象并保留最新记录

本教程详细阐述如何利用 Java Stream API 高效处理列表中具有重复ID的对象,并仅保留每个ID对应的最新记录。我们将重点介绍 Collectors.toMap 的三参数版本,结合 BinaryOperator.maxBy 和 Comparator.comparing,以声明式方式实现复杂的去重逻辑,确保数据完整性和代码简洁性。

引言:处理列表对象去重的挑战

在数据处理中,我们经常遇到需要从列表中移除重复项的场景。然而,简单的去重往往不能满足所有需求。例如,当列表中存在多个具有相同标识符(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);
    }
}

初始数据示例如下:

List students = 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 的三参数版本是解决此类问题的关键。其方法签名通常为:

public static  Collector> toMap(
    Function keyMapper,
    Function valueMapper,
    BinaryOperator mergeFunction
)

这个方法接受三个参数:

  1. keyMapper:一个函数,用于从流中的每个元素(T 类型)提取出作为 Map 键(K 类型)的值。
  2. valueMapper:一个函数,用于从流中的每个元素(T 类型)提取出作为 Map 值(U 类型)的值。
  3. 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=2025-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=2025-01-01T00:00}
Student{id='3', startDatetime=2019-01-01T00:00}
--------------------
去重并保留最新记录后的学生列表:
Student{id='2', startDatetime=1990-02-01T01:01}
Student{id='1', startDatetime=2025-10-27T10:30:45.123456} // 此处的日期时间会是运行时的当前时间
Student{id='3', startDatetime=2025-01-01T00:00}

可以看到,ID为"1"和"3"的重复记录已被成功去重,并保留了 startDatetime 最新的那一条。最终列表也根据 startDatetime 进行了排序。

结果转换与后续处理

在上述代码中,collect(Collectors.toMap(...)) 的结果是一个 Map。我们需要的是一个 List,因此我们通过 Map.values() 获取到 Map 中所有去重后的 Student 对象集合,然后将其转换为一个新的 Stream,并最终收集为 List。

  • .values():返回 Map 中所有值的 Collection 视图。
  • .stream():将这个 Collection 转换为一个新的 Stream。
  • .sorted(Comparator.comparing(Student::getStartDatetime)):这是一个可选步骤,用于对最终结果列表按照 startDatetime 进行升序排序。如果不需要特定顺序,可以省略此步骤。
  • .toList():Java 16 引入的便捷方法,用于将 Stream 收集为不可变的 List。对于早期 Java 版本,可以使用 collect(Collectors.toList())。

注意事项与最佳实践

  1. 性能考量:Collectors.toMap 内部会构建一个 HashMap 来存储中间结果。对于非常大的数据集,这会产生一定的内存开销。然而,对于大多数常见场景,这种方式的性能表现是可接受的,并且其代码的简洁性优势显著。
  2. 可读性与维护性:使用 Stream API 结合 Collectors.toMap 能够以声明式的方式表达复杂的业务逻辑,使得代码意图清晰,易于理解和维护,避免了传统循环中常见的嵌套条件判断。
  3. 空值处理:如果 keyMapper 或 valueMapper 可能返回 null,或者 Comparator 在比较时遇到 null,可能会抛出 NullPointerException。在实际应用中,需要根据具体业务需求进行 null 值检查或处理。例如,如果 startDatetime 可能为 null,可以使用 Comparator.nullsFirst() 或 Comparator.nullsLast()。
  4. Java 版本兼容性:.toList() 方法是 Java 16 及更高版本引入的。如果您的项目使用较早的 Java 版本(如 Java 8 或 11),请使用 collect(Collectors.toList())。
  5. 通用性:这种模式不仅适用于根据日期去重,还可以根据任何可比较的属性(如版本号、优先级等)去重,只需调整 Comparator 的逻辑即可。例如,如果需要保留“最小”值,可以使用 BinaryOperator.minBy。

总结

通过本教程,我们深入探讨了如何利用 Java Stream API 中的 Collectors.toMap 的三参数版本,结合 Function.identity() 和 BinaryOperator.maxBy(Comparator.comparing(...)),优雅且高效地解决列表中对象去重并保留最新记录的问题。这种声明式编程风格不仅提升了代码的简洁性和可读性,也充分展现了 Java Stream 在处理复杂集合操作时的强大能力。掌握这一模式,将有助于您在日常开发中编写出更加健壮和现代的 Java 代码。