导读:本期聚焦于小伙伴创作的《如何通过Stream.distinct对自定义对象去重并正确重写hashCode与equals?》,敬请观看详情。自定义对象直接放进Stream做distinct往往一个都去不掉,根源在于默认按对象内存地址判等。Java要求两个相等对象必须有相同hashCode,且equals返回真,distinct底层依赖这两个方法。若只重写equals不重写hashCode,元素会被分到不同哈希桶,判重失效。实战中可基于业务主键组合生成hashCode,用Objects.equals逐字段比较。下面以订单对象为例,演示如何重写方法并通过List转Stream去重,同时说明可变字段参与计算带来的坑,以及用record类型简化实现的思路。

在Java开发中,使用Stream API处理集合数据已经非常普遍,其中distinct方法常被用来去除重复元素。但当处理的是我们自己定义的业务对象时,很多开发者会发现distinct似乎完全不起作用,所有对象都被保留了下来。这背后的原因和Object类中hashCode与equals的设计机制密切相关,只有正确理解并改写这两个方法,才能让自定义对象的去重真正生效。

一、为什么自定义对象直接distinct会失败

Stream的distinct操作在并行流中会使用ConcurrentHashMap来记录已出现的元素,在顺序流中也会借助相似的状态记录方式,其核心判定逻辑就是先比较对象的hashCode,再调用equals方法。如果我们的类没有重写这两个方法,那么使用的是Object类的默认实现:hashCode返回对象内存地址相关的整数,equals比较的是引用是否指向同一个实例。

对于new出来的两个内容相同但内存地址不同的自定义对象,默认equals会返回false,hashCode也几乎必然不同,因此distinct会认为它们是不同的元素,全部保留。这就导致了业务上明明是重复数据,却在去重步骤中被漏掉。理解这一点是解决问题的前提,我们不能指望distinct自动识别业务层面的相等性。

二、正确重写hashCode与equals的原则

Java规范明确要求:如果两个对象根据equals方法是相等的,那么调用这两个对象的hashCode必须产生相同的整数结果;反之不强制要求,但良好的实践应尽量减少碰撞。因此去重时必须同时重写二者,且参与equals比较的字段,也要参与hashCode的计算。

以一个简单的订单类Order为例,假设业务主键是订单号orderId和用户ID userId,只要这两项相同就视为同一订单。我们可以用IDE自动生成或手动编写,核心是使用java.util.Objects工具类来保证空安全。下面给出一个典型实现:

import java.util.Objects;

public class Order {
    private String orderId;
    private String userId;
    private double amount;

    public Order(String orderId, String userId, double amount) {
        this.orderId = orderId;
        this.userId = userId;
        this.amount = amount;
    }

    // 只使用业务主键字段参与判等
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Order order = (Order) o;
        return Objects.equals(orderId, order.orderId) &&
               Objects.equals(userId, order.userId);
    }

    // 基于相同字段生成哈希值
    @Override
    public int hashCode() {
        return Objects.hash(orderId, userId);
    }

    // 省略getter和setter
}

上述代码中,amount字段没有被纳入equals和hashCode,说明金额变化不影响订单唯一性,这符合多数业务场景。需要注意的是,如果参与计算的字段本身是可变对象且在放入流之后发生了修改,会导致后续hashCode不一致,因此应尽量使用不可变字段或确保在去重前完成赋值。

三、结合Stream.distinct完成去重实战

当Order类正确重写方法后,就可以利用Stream流水线轻松去重。常见做法是把集合转为流,调用distinct,再收集回List。以下代码演示了如何从包含重复订单的列表中提取唯一订单:

import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;

public class DistinctDemo {
    public static void main(String[] args) {
        List<Order> orders = new ArrayList<>();
        orders.add(new Order("A001", "U1", 100.0));
        orders.add(new Order("A001", "U1", 200.0)); // 业务重复
        orders.add(new Order("A002", "U1", 150.0));

        List<Order> uniqueOrders = orders.stream()
                .distinct()
                .collect(Collectors.toList());

        System.out.println("去重后数量: " + uniqueOrders.size());
        // 输出应为2,A001重复项被合并
    }
}

运行上述示例,distinct会依据我们重写的hashCode将元素分桶,对同桶内对象调用equals,从而识别出第一个和第二个Order业务相等,只保留首次出现的元素。这种写法比手动维护Set并写循环判断要简洁很多,也更容易并行化。

在真实项目中,如果去重逻辑仅使用一次,且不想污染实体类,也可以不重写类方法,而是在流中利用Collectors.toMap或自定义TreeSet配合Comparator实现,但那样会失去distinct语义的直观性。对于全局通用的领域对象,重写hashCode与equals仍是最规范的做法。

四、使用Record类型简化实现

从Java 16开始,record类型自动根据声明组件生成equals和hashCode,且语义正是基于所有组件的值比较。如果我们的去重维度恰好等于record的全部字段,那么直接定义为record就能零样板代码获得正确行为。

public record OrderKey(String orderId, String userId) {}

// 使用时提取key去重,或若整个订单就是record且字段合适:
public record OrderRecord(String orderId, String userId, double amount) {}

// OrderRecord自带正确的hashCode与equals,可直接distinct

不过record中所有字段都参与判等,如果像前面例子那样希望忽略amount,就不适合直接用record做实体,而可以用record作为临时的去重键。总之,掌握hashCode与equals的约定,才能灵活选用传统类或record,让Stream.distinct在自定义对象上精准生效。

五、常见误区与排查建议

一个典型错误是只重写equals却忘记重写hashCode,结果在HashSet或distinct中依然出现重复。另一个误区是在equals里使用 getClass() 比较类型,导致继承场景下子类无法与父类判等,若业务需要可改用 instanceof 判断。排查时可在对象上打印hashCode,确认相同业务值的实例是否产出相同哈希。

此外,当对象被当作Map的键或放入HashSet后,不要修改参与hashCode的字段,否则会破坏集合内部结构,造成找不到元素或重复存储。把这些细节控制好,Stream.distinct就能成为处理自定义对象去重时的可靠工具。

Stream_distincthashCodeequals修改时间:2026-08-09 21:39:37

免责声明:​ 已尽一切努力确保本网站所含信息的准确性。网站内容多为原创整理与精心编撰,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们处理。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。